Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958a00c8fc | ||
|
|
e0f761da2b | ||
|
|
80739a4528 | ||
|
|
c5988ec7a6 | ||
|
|
63a56c47ba | ||
|
|
c82c21c0cd | ||
|
|
a339e722a6 | ||
|
|
a7415964a7 | ||
|
|
767c9a7424 | ||
|
|
d197d30eb0 | ||
|
|
452de8b069 | ||
|
|
92141c6564 | ||
|
|
9e1504ddb7 | ||
|
|
a72279f978 | ||
|
|
9cc8b28122 | ||
|
|
02ca3549d5 | ||
|
|
5485bac339 | ||
|
|
d0dc01deb1 | ||
|
|
a76f25321a | ||
|
|
2410ebb7dc | ||
|
|
1d6c520945 | ||
|
|
10ad7b7f41 | ||
|
|
aebe7ed586 | ||
|
|
5b42bf1504 | ||
|
|
5ab63e8b27 | ||
|
|
4db832bc8c | ||
|
|
736a8bccf9 | ||
|
|
bd69b37524 | ||
|
|
e7402dda4d | ||
|
|
6b0d2d1b0a | ||
|
|
7a4a77e3fc | ||
|
|
2e82e854bf | ||
|
|
ac860d3165 | ||
|
|
8176635eb8 | ||
|
|
a730a18794 | ||
|
|
40d0753637 | ||
|
|
db630e315b | ||
|
|
53530dc16d | ||
|
|
974b74ee9f | ||
|
|
ab05ce589d | ||
|
|
ce3f081a0a | ||
|
|
63fba4138e | ||
|
|
d58a8c2479 | ||
|
|
81f7b1a9ac | ||
|
|
9e303426a7 | ||
|
|
d4fc0f1fee | ||
|
|
f8403ddfbc | ||
|
|
428da471d1 | ||
|
|
271844efb1 | ||
|
|
07cad19988 | ||
|
|
8dacad7a59 | ||
|
|
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 | ||
|
|
a98ab8c275 | ||
|
|
e22463874c | ||
|
|
256039264e | ||
|
|
e459da7c20 | ||
|
|
e84b5cf674 | ||
|
|
cc70fe2b29 | ||
|
|
6bed715b7f | ||
|
|
dbf8c8856b | ||
|
|
62127a33f5 | ||
|
|
2fffe4a368 | ||
|
|
c9054e5b4d | ||
|
|
5cab15422d | ||
|
|
439db8117a | ||
|
|
675820532c | ||
|
|
4edfc55c37 | ||
|
|
480aaa24b2 | ||
|
|
185af65519 | ||
|
|
8fecf67a7f |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
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,9 +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>@Malio 2025 · v{{ appVersion }}</p>
|
<p class="text-xs text-base-content/40 font-medium tracking-wide">
|
||||||
|
© Malio {{ new Date().getFullYear() }}
|
||||||
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
to="/changelog"
|
||||||
|
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
|
||||||
|
>
|
||||||
|
v{{ appVersion }}
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
55
app/components/CommentDocumentList.vue
Normal file
55
app/components/CommentDocumentList.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="documents.length" class="space-y-1 mt-2">
|
||||||
|
<div
|
||||||
|
v-for="doc in documents"
|
||||||
|
:key="doc.id"
|
||||||
|
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<component
|
||||||
|
:is="documentIcon(doc).component"
|
||||||
|
class="w-4 h-4 flex-shrink-0"
|
||||||
|
:class="documentIcon(doc).colorClass"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ doc.name || doc.filename }}</span>
|
||||||
|
<span class="text-base-content/40 flex-shrink-0">{{ formatSize(doc.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(doc)"
|
||||||
|
:title="canPreviewDocument(doc) ? 'Consulter' : 'Aperçu non disponible'"
|
||||||
|
@click="openPreview(doc)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="downloadDocument(doc)"
|
||||||
|
>
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CommentDocument } from '~/composables/useComments'
|
||||||
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
|
import { formatSize, documentIcon, downloadDocument } from '~/shared/utils/documentDisplayUtils'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
documents: CommentDocument[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const openPreview = (doc: CommentDocument) => {
|
||||||
|
if (!canPreviewDocument(doc)) return
|
||||||
|
// Open file URL in new tab for preview
|
||||||
|
if (doc.fileUrl) {
|
||||||
|
window.open(doc.fileUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
270
app/components/CommentSection.vue
Normal file
270
app/components/CommentSection.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<IconLucideMessageSquare class="w-5 h-5" />
|
||||||
|
Commentaires
|
||||||
|
<span v-if="openComments.length" class="badge badge-warning badge-sm">
|
||||||
|
{{ openComments.length }}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
v-if="showResolved && resolvedComments.length"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="showResolvedList = !showResolvedList"
|
||||||
|
>
|
||||||
|
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire d'ajout -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="newContent"
|
||||||
|
class="textarea textarea-bordered flex-1 text-sm"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Ajouter un commentaire..."
|
||||||
|
:disabled="submitting"
|
||||||
|
@keydown.ctrl.enter="handleSubmit"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-1 self-end">
|
||||||
|
<label
|
||||||
|
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
|
||||||
|
data-tip="Joindre des fichiers"
|
||||||
|
>
|
||||||
|
<IconLucidePaperclip class="w-4 h-4" />
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFilesSelected"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm btn-square"
|
||||||
|
:disabled="!newContent.trim() || submitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
<IconLucideSend v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Selected files preview -->
|
||||||
|
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="(file, i) in selectedFiles"
|
||||||
|
:key="i"
|
||||||
|
class="badge badge-sm badge-outline gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideFile class="w-3 h-3" />
|
||||||
|
{{ file.name }}
|
||||||
|
<button type="button" class="ml-1" @click="removeFile(i)">
|
||||||
|
<IconLucideX class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des commentaires ouverts -->
|
||||||
|
<div v-if="loadingComments" class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner loading-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
|
||||||
|
Aucun commentaire ouvert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="comment in openComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200 rounded-lg p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
<!-- Documents attachés -->
|
||||||
|
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||||
|
<span>
|
||||||
|
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="canEdit" class="flex gap-1">
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleDelete(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash2 class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commentaires résolus -->
|
||||||
|
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
|
||||||
|
<div class="divider text-xs text-base-content/40">
|
||||||
|
Résolus
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="comment in resolvedComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||||
|
>
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
<!-- Documents attachés (résolus) -->
|
||||||
|
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||||
|
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||||
|
<span v-if="comment.resolvedByName">
|
||||||
|
Résolu par {{ comment.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import CommentDocumentList from '~/components/CommentDocumentList.vue'
|
||||||
|
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||||
|
import IconLucideSend from '~icons/lucide/send'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucidePaperclip from '~icons/lucide/paperclip'
|
||||||
|
import IconLucideFile from '~icons/lucide/file'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string
|
||||||
|
showResolved?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
} = useComments()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const newContent = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const loadingComments = ref(false)
|
||||||
|
const showResolvedList = ref(false)
|
||||||
|
const selectedFiles = ref<File[]>([])
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const getDocuments = (comment: Comment): CommentDocument[] =>
|
||||||
|
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
|
||||||
|
|
||||||
|
const openComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'open'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'resolved'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatCommentDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilesSelected = (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
if (input.files) {
|
||||||
|
selectedFiles.value.push(...Array.from(input.files))
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
selectedFiles.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loadingComments.value = true
|
||||||
|
const [openResult, resolvedResult] = await Promise.all([
|
||||||
|
fetchComments(props.entityType, props.entityId, 'open'),
|
||||||
|
props.showResolved
|
||||||
|
? fetchComments(props.entityType, props.entityId, 'resolved')
|
||||||
|
: Promise.resolve({ success: true, data: [] as Comment[] }),
|
||||||
|
])
|
||||||
|
const open = openResult.success ? (openResult.data ?? []) : []
|
||||||
|
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
|
||||||
|
comments.value = [...open, ...resolved]
|
||||||
|
loadingComments.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const content = newContent.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
submitting.value = true
|
||||||
|
const result = await createComment(
|
||||||
|
props.entityType,
|
||||||
|
props.entityId,
|
||||||
|
content,
|
||||||
|
props.entityName,
|
||||||
|
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
|
||||||
|
)
|
||||||
|
submitting.value = false
|
||||||
|
if (result.success) {
|
||||||
|
newContent.value = ''
|
||||||
|
selectedFiles.value = []
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (commentId: string) => {
|
||||||
|
const result = await resolveComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (commentId: string) => {
|
||||||
|
const result = await deleteComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.entityId) {
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -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,210 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="componentDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 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 v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||||
:key="constructeur.id"
|
</span>
|
||||||
class="badge badge-outline badge-sm"
|
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
||||||
>
|
{{ displayProductName }}
|
||||||
{{ constructeur.name }}
|
</span>
|
||||||
</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="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
|
||||||
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
|
</span>
|
||||||
|
<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}`"
|
||||||
|
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 +215,29 @@
|
|||||||
@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"
|
:can-edit="isEditMode"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
:delete-disabled="uploadingDocuments"
|
||||||
>
|
empty-text="Aucun document lié à ce composant."
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@preview="openPreview"
|
||||||
<div
|
@edit="openEditModal"
|
||||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
@delete="removeDocument"
|
||||||
: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 +245,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)"
|
||||||
@@ -445,24 +296,15 @@ import {
|
|||||||
formatConstructeurContact as formatConstructeurContactSummary,
|
formatConstructeurContact as formatConstructeurContactSummary,
|
||||||
resolveConstructeurs,
|
resolveConstructeurs,
|
||||||
uniqueConstructeurIds,
|
uniqueConstructeurIds,
|
||||||
|
parseConstructeurLinksFromApi,
|
||||||
} from '~/shared/constructeurUtils'
|
} from '~/shared/constructeurUtils'
|
||||||
import {
|
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 +312,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 {
|
||||||
@@ -489,6 +332,7 @@ const {
|
|||||||
ensureDocumentsLoaded,
|
ensureDocumentsLoaded,
|
||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
|
editDocument,
|
||||||
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -503,6 +347,21 @@ const {
|
|||||||
updateCustomField: updateComponentCustomField,
|
updateCustomField: updateComponentCustomField,
|
||||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||||
|
|
||||||
|
// --- Document edit modal ---
|
||||||
|
const editingDocument = ref(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
|
const openEditModal = (doc) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDocumentUpdated = async (data) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
await editDocument(editingDocument.value.id, data)
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// --- Collapse state ---
|
// --- Collapse state ---
|
||||||
const isCollapsed = ref(true)
|
const isCollapsed = ref(true)
|
||||||
|
|
||||||
@@ -526,26 +385,47 @@ 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()
|
||||||
|
|
||||||
const componentConstructeurIds = computed(() =>
|
const componentConstructeurLinks = computed(() =>
|
||||||
uniqueConstructeurIds(
|
parseConstructeurLinksFromApi(
|
||||||
props.component,
|
|
||||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||||
props.component.constructeur ? [props.component.constructeur] : [],
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const componentConstructeursDisplay = computed(() =>
|
const supplierReferenceMap = computed(() => {
|
||||||
resolveConstructeurs(
|
const map = new Map()
|
||||||
componentConstructeurIds.value,
|
componentConstructeurLinks.value.forEach(l => {
|
||||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
|
||||||
props.component.constructeur ? [props.component.constructeur] : [],
|
})
|
||||||
constructeurs.value,
|
return map
|
||||||
),
|
})
|
||||||
|
|
||||||
|
const componentConstructeurIds = computed(() =>
|
||||||
|
componentConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const componentConstructeursDisplay = computed(() => {
|
||||||
|
// Extract nested constructeur objects from link entries
|
||||||
|
const linkConstructeurs = componentConstructeurLinks.value
|
||||||
|
.filter(l => l.constructeur && l.constructeur.id)
|
||||||
|
.map(l => l.constructeur)
|
||||||
|
return resolveConstructeurs(
|
||||||
|
componentConstructeurIds.value,
|
||||||
|
linkConstructeurs,
|
||||||
|
constructeurs.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const formatConstructeurContact = (constructeur) =>
|
const formatConstructeurContact = (constructeur) =>
|
||||||
formatConstructeurContactSummary(constructeur)
|
formatConstructeurContactSummary(constructeur)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
:locked-type-label="displayedRootTypeLabel"
|
:locked-type-label="displayedRootTypeLabel"
|
||||||
:allow-subcomponents="allowSubcomponents"
|
:allow-subcomponents="allowSubcomponents"
|
||||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||||
:restricted-mode="restrictedMode"
|
|
||||||
is-root
|
is-root
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,10 +55,6 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: Infinity,
|
default: Infinity,
|
||||||
},
|
},
|
||||||
restrictedMode: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
||||||
:option-label="componentOptionLabel"
|
:option-label="componentOptionLabel"
|
||||||
:option-description="componentOptionDescription"
|
:option-description="componentOptionDescription"
|
||||||
|
server-search
|
||||||
@search="fetchComponentOptions"
|
@search="fetchComponentOptions"
|
||||||
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
||||||
/>
|
/>
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||||
:option-label="pieceOptionLabel"
|
:option-label="pieceOptionLabel"
|
||||||
:option-description="pieceOptionDescription"
|
:option-description="pieceOptionDescription"
|
||||||
|
server-search
|
||||||
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
|
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
|
||||||
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
||||||
/>
|
/>
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||||
:option-label="productOptionLabel"
|
:option-label="productOptionLabel"
|
||||||
:option-description="productOptionDescription"
|
:option-description="productOptionDescription"
|
||||||
|
server-search
|
||||||
@search="(term) => fetchProductOptions(productAssignment, term)"
|
@search="(term) => fetchProductOptions(productAssignment, term)"
|
||||||
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
|
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
|
||||||
/>
|
/>
|
||||||
@@ -134,76 +137,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 +187,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 +250,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>
|
||||||
|
|||||||
130
app/components/ComposantSelect.vue
Normal file
130
app/components/ComposantSelect.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="modelValue ?? undefined"
|
||||||
|
:options="composantOptions"
|
||||||
|
:loading="loading"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
size="sm"
|
||||||
|
option-value="id"
|
||||||
|
:option-label="formatLabel"
|
||||||
|
:disabled="disabled"
|
||||||
|
server-search
|
||||||
|
@update:modelValue="updateValue"
|
||||||
|
@search="handleSearch"
|
||||||
|
>
|
||||||
|
<template #option-description="{ option }">
|
||||||
|
<span class="text-xs text-base-content/60">
|
||||||
|
{{ formatDescription(option) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</SearchSelect>
|
||||||
|
<p v-if="helperText" class="text-xs text-base-content/60">
|
||||||
|
{{ helperText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
emptyText?: string
|
||||||
|
helperText?: string
|
||||||
|
disabled?: boolean
|
||||||
|
typeComposantId?: string | null
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: 'Sélectionner un composant…',
|
||||||
|
emptyText: 'Aucun composant disponible',
|
||||||
|
helperText: '',
|
||||||
|
disabled: false,
|
||||||
|
typeComposantId: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { loading: globalLoading, loadComposants } = useComposants()
|
||||||
|
|
||||||
|
const localComposants = ref<any[]>([])
|
||||||
|
const localLoading = ref(false)
|
||||||
|
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||||
|
|
||||||
|
const composantOptions = computed(() => localComposants.value)
|
||||||
|
|
||||||
|
const loadFilteredComposants = async (search = '') => {
|
||||||
|
if (!props.typeComposantId) return
|
||||||
|
localLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loadComposants({ typeComposantId: props.typeComposantId, search, itemsPerPage: 200, force: true })
|
||||||
|
if (result.success && result.data?.items) {
|
||||||
|
localComposants.value = result.data.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
|
console.error('Erreur lors du chargement des composants:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
localLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleSearch = (term: string) => {
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce)
|
||||||
|
searchDebounce = setTimeout(() => loadFilteredComposants(term.trim()), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFilteredComposants()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.typeComposantId,
|
||||||
|
() => {
|
||||||
|
loadFilteredComposants()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateValue = (value: string | number | null | undefined) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLabel = (option: any) => {
|
||||||
|
if (!option) return ''
|
||||||
|
const name = option.name || 'Composant'
|
||||||
|
return option.reference ? `${name} — ${option.reference}` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDescription = (option: any) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
const typeName = option?.typeComposant?.name
|
||||||
|
if (typeName) {
|
||||||
|
parts.push(typeName)
|
||||||
|
}
|
||||||
|
if (option?.reference) {
|
||||||
|
parts.push(`Ref. ${option.reference}`)
|
||||||
|
}
|
||||||
|
if (option?.prix !== undefined && option.prix !== null) {
|
||||||
|
const price = Number(option.prix)
|
||||||
|
if (!Number.isNaN(price)) {
|
||||||
|
parts.push(`${price.toFixed(2)} €`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' • ') : 'Sans référence'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
93
app/components/ConstructeurLinksTable.vue
Normal file
93
app/components/ConstructeurLinksTable.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="modelValue.length" class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fournisseur</th>
|
||||||
|
<th>Réf. fournisseur</th>
|
||||||
|
<th v-if="!readonly" class="w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(link, index) in modelValue" :key="link.constructeurId">
|
||||||
|
<td class="font-medium">
|
||||||
|
{{ getConstructeurName(link) }}
|
||||||
|
<div v-if="getConstructeurContact(link)" class="text-xs text-gray-500">
|
||||||
|
{{ getConstructeurContact(link) }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-if="!readonly"
|
||||||
|
:value="link.supplierReference || ''"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
placeholder="Réf. fournisseur"
|
||||||
|
@input="updateReference(index, ($event.target as HTMLInputElement).value)"
|
||||||
|
>
|
||||||
|
<span v-else>{{ link.supplierReference || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="!readonly">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
aria-label="Retirer"
|
||||||
|
@click="removeLink(index)"
|
||||||
|
>
|
||||||
|
<IconLucideX class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||||
|
import { formatConstructeurContact } from '~/shared/constructeurUtils'
|
||||||
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Array as PropType<ConstructeurLinkEntry[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: ConstructeurLinkEntry[]): void
|
||||||
|
(e: 'remove', constructeurId: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { getConstructeurById } = useConstructeurs()
|
||||||
|
|
||||||
|
const getConstructeurName = (link: ConstructeurLinkEntry): string =>
|
||||||
|
link.constructeur?.name || getConstructeurById(link.constructeurId)?.name || link.constructeurId
|
||||||
|
|
||||||
|
const getConstructeurContact = (link: ConstructeurLinkEntry): string => {
|
||||||
|
const c = link.constructeur || getConstructeurById(link.constructeurId)
|
||||||
|
return formatConstructeurContact(c as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateReference = (index: number, value: string) => {
|
||||||
|
const updated = [...props.modelValue]
|
||||||
|
const entry = updated[index]
|
||||||
|
if (!entry) return
|
||||||
|
updated[index] = { ...entry, supplierReference: value || null }
|
||||||
|
emit('update:modelValue', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLink = (index: number) => {
|
||||||
|
const removed = props.modelValue[index]
|
||||||
|
const updated = props.modelValue.filter((_, i) => i !== index)
|
||||||
|
emit('update:modelValue', updated)
|
||||||
|
if (removed) emit('remove', removed.constructeurId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -20,16 +20,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="openDropdown"
|
v-if="openDropdown"
|
||||||
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="options.length === 0"
|
v-if="filteredOptions.length === 0"
|
||||||
class="px-3 py-2 text-xs text-gray-500"
|
class="px-3 py-2 text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
Aucun fournisseur trouvé
|
Aucun fournisseur trouvé
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="option in options"
|
v-for="option in filteredOptions"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||||
@@ -164,8 +164,7 @@ const openCreateModal = ref(false)
|
|||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const options = ref<ConstructeurSummary[]>([])
|
const options = ref<ConstructeurSummary[]>([])
|
||||||
const selectedIds = ref<string[]>([])
|
const selectedIds = ref<string[]>([])
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let lastSearchTerm = ''
|
|
||||||
|
|
||||||
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
const seen = new Map<string, ConstructeurSummary>()
|
const seen = new Map<string, ConstructeurSummary>()
|
||||||
@@ -182,32 +181,22 @@ const normalizedInitialOptions = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
const normalized = uniqueOptions([
|
options.value = uniqueOptions([
|
||||||
...normalizedInitialOptions.value,
|
...normalizedInitialOptions.value,
|
||||||
...items,
|
...items,
|
||||||
])
|
])
|
||||||
const limited = normalized.slice(0, 10)
|
|
||||||
|
|
||||||
selectedIds.value.forEach((id) => {
|
|
||||||
if (!limited.some((item) => item.id === id)) {
|
|
||||||
const match =
|
|
||||||
normalized.find((item) => item.id === id) ||
|
|
||||||
constructeurs.value.find((item) => item.id === id)
|
|
||||||
if (match) {
|
|
||||||
if (limited.length >= 10) {
|
|
||||||
limited.pop()
|
|
||||||
}
|
|
||||||
limited.unshift(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
options.value = uniqueOptions([
|
|
||||||
...normalizedInitialOptions.value,
|
|
||||||
...limited,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
const term = searchTerm.value.trim().toLowerCase()
|
||||||
|
if (!term) return options.value
|
||||||
|
return options.value.filter((option) =>
|
||||||
|
(option.name ?? '').toLowerCase().includes(term)
|
||||||
|
|| (option.email && option.email.toLowerCase().includes(term))
|
||||||
|
|| (option.phone && option.phone.toLowerCase().includes(term))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const createForm = ref({
|
const createForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -257,46 +246,20 @@ const extractDataArray = (data: unknown): ConstructeurSummary[] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ensureOptionsLoaded = async (force = false) => {
|
const ensureOptionsLoaded = async (force = false) => {
|
||||||
if (!force && !searchTerm.value && constructeurs.value.length) {
|
if (!force && constructeurs.value.length) {
|
||||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!force && searchTerm.value === lastSearchTerm && options.value.length) {
|
const result = await searchConstructeurs('')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.value.length && !force) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await searchConstructeurs(searchTerm.value)
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
applyOptions(extractDataArray(result.data))
|
applyOptions(extractDataArray(result.data))
|
||||||
lastSearchTerm = searchTerm.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSearch = () => {
|
const onSearch = () => {
|
||||||
openDropdown.value = true
|
openDropdown.value = true
|
||||||
if (searchTimeout) {
|
ensureOptionsLoaded()
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(async () => {
|
|
||||||
if (!searchTerm.value && constructeurs.value.length) {
|
|
||||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
|
||||||
lastSearchTerm = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (searchTerm.value === lastSearchTerm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await searchConstructeurs(searchTerm.value)
|
|
||||||
if (result.success) {
|
|
||||||
applyOptions(extractDataArray(result.data))
|
|
||||||
lastSearchTerm = searchTerm.value
|
|
||||||
}
|
|
||||||
}, 250)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleOption = (option: ConstructeurSummary) => {
|
const toggleOption = (option: ConstructeurSummary) => {
|
||||||
@@ -319,9 +282,19 @@ const closeCreateModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
const trimmedName = createForm.value.name.trim()
|
||||||
|
const duplicate = options.value.find(
|
||||||
|
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
emitSelection([...selectedIds.value, duplicate.id])
|
||||||
|
closeCreateModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
creating.value = true
|
creating.value = true
|
||||||
const payload: { name: string; email?: string; phone?: string } = {
|
const payload: { name: string; email?: string; phone?: string } = {
|
||||||
name: createForm.value.name,
|
name: trimmedName,
|
||||||
}
|
}
|
||||||
if (createForm.value.email) {
|
if (createForm.value.email) {
|
||||||
payload.email = createForm.value.email
|
payload.email = createForm.value.email
|
||||||
@@ -383,9 +356,6 @@ watch(
|
|||||||
constructeurs,
|
constructeurs,
|
||||||
(list) => {
|
(list) => {
|
||||||
applyOptions((list as ConstructeurSummary[]) || [])
|
applyOptions((list as ConstructeurSummary[]) || [])
|
||||||
if (!searchTerm.value) {
|
|
||||||
lastSearchTerm = ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -405,9 +375,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('click', clickHandler)
|
window.removeEventListener('click', clickHandler)
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -55,16 +55,16 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Champ de type BOOLEAN -->
|
<!-- Champ de type BOOLEAN -->
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="fieldValues[field.id]"
|
v-model="fieldValues[field.id]"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm"
|
||||||
:checked="fieldValues[field.id] === 'true'"
|
:checked="fieldValues[field.id] === 'true'"
|
||||||
@change="updateCustomFieldValue(field.id)"
|
@change="updateCustomFieldValue(field.id)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<!-- Champ de type DATE -->
|
<!-- Champ de type DATE -->
|
||||||
<input
|
<input
|
||||||
|
|||||||
51
app/components/DetailHeader.vue
Normal file
51
app/components/DetailHeader.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-bold">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="text-sm text-base-content/70">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:class="{ 'btn-outline': isEditMode }"
|
||||||
|
@click="$emit('toggle-edit')"
|
||||||
|
>
|
||||||
|
<IconLucideSquarePen v-if="!isEditMode" class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
|
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
|
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||||
|
Retour au catalogue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||||
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
isEditMode: boolean
|
||||||
|
canEdit: boolean
|
||||||
|
backLink: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'toggle-edit': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
navigateTo(props.backLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
90
app/components/DocumentEditModal.vue
Normal file
90
app/components/DocumentEditModal.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="modal modal-open" @click.self="$emit('close')">
|
||||||
|
<div class="modal-box max-w-sm">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Modifier le document
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<label class="form-control w-full">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">Nom</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md w-full"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control w-full">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">Type</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="form.type"
|
||||||
|
class="select select-bordered select-sm md:select-md w-full"
|
||||||
|
>
|
||||||
|
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||||
|
{{ t.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="$emit('close')">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm md:btn-md"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-xs" />
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch, ref } from 'vue'
|
||||||
|
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||||
|
import type { Document } from '~/composables/useDocuments'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
document: Document | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'updated', data: { name: string; type: string }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({ name: '', type: 'documentation' })
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.document,
|
||||||
|
(doc) => {
|
||||||
|
if (doc) {
|
||||||
|
form.name = doc.name || ''
|
||||||
|
form.type = doc.type || 'documentation'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
emit('updated', { name: form.name.trim(), type: form.type })
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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
|
||||||
@@ -34,6 +34,21 @@
|
|||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<div class="w-full max-w-xs mt-2">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||||
|
Type de document
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-sm w-full mt-1"
|
||||||
|
:value="documentType"
|
||||||
|
@change="emit('update:documentType', $event.target.value)"
|
||||||
|
>
|
||||||
|
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||||
|
{{ t.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
|
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
|
||||||
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
|
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -54,7 +69,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)">
|
||||||
@@ -69,6 +84,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
|
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
|
||||||
|
|
||||||
@@ -96,10 +112,14 @@ const props = defineProps({
|
|||||||
maxFileSizeMb: {
|
maxFileSizeMb: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 200
|
default: 200
|
||||||
|
},
|
||||||
|
documentType: {
|
||||||
|
type: String,
|
||||||
|
default: 'documentation'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'files-added'])
|
const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
|
||||||
|
|
||||||
const dragActive = ref(false)
|
const dragActive = ref(false)
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
@@ -130,7 +150,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,101 @@
|
|||||||
<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"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<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>
|
||||||
|
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||||
|
<template v-if="pieceConstructeursDisplay.length">
|
||||||
|
<span
|
||||||
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="badge badge-outline badge-sm"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
|
||||||
|
({{ supplierReferenceMap.get(constructeur.id) }})
|
||||||
|
</span>
|
||||||
|
</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
|
||||||
@@ -72,6 +110,10 @@
|
|||||||
pieceData.reference || "Non définie"
|
pieceData.reference || "Non définie"
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="pieceData.referenceAuto">
|
||||||
|
<span class="font-medium">Référence auto:</span>
|
||||||
|
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">Fournisseur:</span>
|
<span class="font-medium">Fournisseur:</span>
|
||||||
<div v-if="!isEditMode" class="ml-2">
|
<div v-if="!isEditMode" class="ml-2">
|
||||||
@@ -83,10 +125,13 @@
|
|||||||
>
|
>
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
{{ constructeur.name }}
|
{{ constructeur.name }}
|
||||||
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
|
||||||
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
|
</span>
|
||||||
</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>
|
||||||
@@ -146,7 +191,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="selectedProduct.id"
|
v-if="selectedProduct.id"
|
||||||
:to="`/product/${selectedProduct.id}/edit`"
|
:to="`/product/${selectedProduct.id}`"
|
||||||
class="link link-primary text-xs"
|
class="link link-primary text-xs"
|
||||||
>
|
>
|
||||||
Ouvrir la fiche produit
|
Ouvrir la fiche produit
|
||||||
@@ -169,65 +214,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 +225,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 +249,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,119 +261,39 @@
|
|||||||
@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"
|
:can-edit="isEditMode"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
:delete-disabled="uploadingDocuments"
|
||||||
>
|
empty-text="Aucun document lié à cette pièce."
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@preview="openPreview"
|
||||||
<div
|
@edit="openEditModal"
|
||||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
@delete="removeDocument"
|
||||||
:class="documentThumbnailClass(document)"
|
/>
|
||||||
>
|
</div>
|
||||||
<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 {
|
||||||
formatConstructeurContact as formatConstructeurContactSummary,
|
formatConstructeurContact as formatConstructeurContactSummary,
|
||||||
resolveConstructeurs,
|
resolveConstructeurs,
|
||||||
uniqueConstructeurIds,
|
uniqueConstructeurIds,
|
||||||
|
parseConstructeurLinksFromApi,
|
||||||
} 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,16 +302,25 @@ 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({
|
||||||
name: props.piece.name || '',
|
name: props.piece.name || '',
|
||||||
reference: props.piece.reference || '',
|
reference: props.piece.reference || '',
|
||||||
|
referenceAuto: props.piece.referenceAuto || null,
|
||||||
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 ---
|
||||||
@@ -561,6 +350,7 @@ const {
|
|||||||
refreshDocuments,
|
refreshDocuments,
|
||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
|
editDocument,
|
||||||
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
|
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -575,26 +365,71 @@ const {
|
|||||||
updateCustomField,
|
updateCustomField,
|
||||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||||
|
|
||||||
|
// --- Document edit modal ---
|
||||||
|
const editingDocument = ref(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
|
const openEditModal = (doc) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDocumentUpdated = async (data) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
await editDocument(editingDocument.value.id, data)
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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()
|
||||||
|
|
||||||
const pieceConstructeurIds = computed(() =>
|
const pieceConstructeurLinks = computed(() =>
|
||||||
uniqueConstructeurIds(
|
parseConstructeurLinksFromApi(
|
||||||
props.piece,
|
|
||||||
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||||
props.piece.constructeur ? [props.piece.constructeur] : [],
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const pieceConstructeursDisplay = computed(() =>
|
const supplierReferenceMap = computed(() => {
|
||||||
resolveConstructeurs(
|
const map = new Map()
|
||||||
pieceConstructeurIds.value,
|
pieceConstructeurLinks.value.forEach(l => {
|
||||||
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
|
||||||
props.piece.constructeur ? [props.piece.constructeur] : [],
|
})
|
||||||
constructeurs.value,
|
return map
|
||||||
),
|
})
|
||||||
|
|
||||||
|
const pieceConstructeurIds = computed(() =>
|
||||||
|
pieceConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pieceConstructeursDisplay = computed(() => {
|
||||||
|
// Extract nested constructeur objects from link entries
|
||||||
|
const linkConstructeurs = pieceConstructeurLinks.value
|
||||||
|
.filter(l => l.constructeur && l.constructeur.id)
|
||||||
|
.map(l => l.constructeur)
|
||||||
|
return resolveConstructeurs(
|
||||||
|
pieceConstructeurIds.value,
|
||||||
|
linkConstructeurs,
|
||||||
|
constructeurs.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const formatConstructeurContact = (constructeur) =>
|
const formatConstructeurContact = (constructeur) =>
|
||||||
formatConstructeurContactSummary(constructeur)
|
formatConstructeurContactSummary(constructeur)
|
||||||
|
|
||||||
@@ -648,16 +483,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 +510,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 +557,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 +570,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>
|
||||||
|
|
||||||
@@ -35,7 +29,6 @@
|
|||||||
<select
|
<select
|
||||||
v-model="product.typeProductId"
|
v-model="product.typeProductId"
|
||||||
class="select select-bordered select-xs"
|
class="select select-bordered select-xs"
|
||||||
:disabled="isProductLocked(product)"
|
|
||||||
@change="handleProductTypeSelect(product)"
|
@change="handleProductTypeSelect(product)"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -52,39 +45,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="!isProductLocked(product)"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs btn-square"
|
class="btn btn-error btn-xs btn-square"
|
||||||
@click="removeProduct(index)"
|
@click="removeProduct(index)"
|
||||||
>
|
>
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<button 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,482 +82,105 @@
|
|||||||
@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">
|
||||||
|
<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-xs h-20"
|
||||||
|
placeholder="Option 1 Option 2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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>
|
</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
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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,
|
||||||
|
formatProductTypeOption,
|
||||||
const normalizeLineEndings = (value: string): string =>
|
handleProductTypeSelect,
|
||||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
addProduct,
|
||||||
|
removeProduct,
|
||||||
const safeClone = <T,>(value: T, fallback: T): T => {
|
addField,
|
||||||
try {
|
removeField,
|
||||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
reorderClass,
|
||||||
} catch {
|
onDragStart,
|
||||||
return JSON.parse(JSON.stringify(fallback)) as T
|
onDragEnter,
|
||||||
}
|
onDrop,
|
||||||
}
|
onDragEnd,
|
||||||
|
} = usePieceStructureEditorLogic({ props, emit })
|
||||||
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
|
|
||||||
if (!structure || typeof structure !== 'object') {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
const entries = Object.entries(structure).filter(
|
|
||||||
([key]) => key !== 'customFields' && key !== 'products',
|
|
||||||
)
|
|
||||||
return safeClone(Object.fromEntries(entries), {})
|
|
||||||
}
|
|
||||||
|
|
||||||
let uidCounter = 0
|
|
||||||
const createUid = (scope: 'field' | 'product'): string => {
|
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
||||||
return crypto.randomUUID()
|
|
||||||
}
|
|
||||||
uidCounter += 1
|
|
||||||
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const toEditorField = (
|
|
||||||
input: Partial<PieceModelStructureEditorField> | null | undefined,
|
|
||||||
index: number,
|
|
||||||
): EditorField => {
|
|
||||||
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
|
|
||||||
const optionsText = normalizeLineEndings(
|
|
||||||
typeof input?.optionsText === 'string'
|
|
||||||
? input.optionsText
|
|
||||||
: Array.isArray(input?.options)
|
|
||||||
? input.options.join('\n')
|
|
||||||
: '',
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
uid: createUid('field'),
|
|
||||||
name: typeof input?.name === 'string' ? input.name : '',
|
|
||||||
type: baseType as PieceModelCustomFieldType,
|
|
||||||
required: Boolean(input?.required),
|
|
||||||
optionsText,
|
|
||||||
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
|
|
||||||
const source = ensureArray(structure?.customFields)
|
|
||||||
return source
|
|
||||||
.map((field, index) => toEditorField(field, index))
|
|
||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const toEditorProduct = (
|
|
||||||
input: Partial<PieceModelProduct> | null | undefined,
|
|
||||||
): EditorProduct => ({
|
|
||||||
uid: createUid('product'),
|
|
||||||
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
|
|
||||||
typeProductLabel:
|
|
||||||
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
|
|
||||||
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
|
|
||||||
const source = Array.isArray(structure?.products) ? structure?.products : []
|
|
||||||
return source.map((product) => toEditorProduct(product))
|
|
||||||
}
|
|
||||||
|
|
||||||
const productTypeOptions = computed(() => productTypes.value ?? [])
|
|
||||||
|
|
||||||
const productTypeMap = computed(() => {
|
|
||||||
const map = new Map<string, any>()
|
|
||||||
productTypeOptions.value.forEach((type: any) => {
|
|
||||||
if (type?.id) {
|
|
||||||
map.set(type.id, type)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatProductTypeOption = (type: any) => {
|
|
||||||
if (!type) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const parts: string[] = []
|
|
||||||
if (type.code) {
|
|
||||||
parts.push(type.code)
|
|
||||||
}
|
|
||||||
if (type.name) {
|
|
||||||
parts.push(type.name)
|
|
||||||
}
|
|
||||||
return parts.length ? parts.join(' • ') : type.id || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProductTypeMetadata = (product: EditorProduct) => {
|
|
||||||
const option = product.typeProductId
|
|
||||||
? productTypeMap.value.get(product.typeProductId)
|
|
||||||
: null
|
|
||||||
product.typeProductLabel = option?.name ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleProductTypeSelect = (product: EditorProduct) => {
|
|
||||||
const option = product.typeProductId
|
|
||||||
? productTypeMap.value.get(product.typeProductId)
|
|
||||||
: null
|
|
||||||
product.typeProductLabel = option?.name ?? ''
|
|
||||||
if (option?.code) {
|
|
||||||
product.familyCode = option.code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createEmptyProduct = (): EditorProduct => ({
|
|
||||||
uid: createUid('product'),
|
|
||||||
typeProductId: '',
|
|
||||||
typeProductLabel: '',
|
|
||||||
familyCode: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const addProduct = () => {
|
|
||||||
products.value.push(createEmptyProduct())
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeProduct = (index: number) => {
|
|
||||||
products.value = products.value.filter((_, idx) => idx !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
|
||||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
|
||||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
|
||||||
|
|
||||||
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
|
|
||||||
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
|
|
||||||
|
|
||||||
const isFieldLocked = (field: EditorField): boolean => {
|
|
||||||
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isProductLocked = (product: EditorProduct): boolean => {
|
|
||||||
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
|
||||||
|
|
||||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
|
||||||
list.map((field, index) => ({
|
|
||||||
...field,
|
|
||||||
orderIndex: index,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
|
|
||||||
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
|
|
||||||
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
|
|
||||||
|
|
||||||
if (!typeProductId && !familyCode) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: PieceModelProduct = {}
|
|
||||||
if (typeProductId) {
|
|
||||||
payload.typeProductId = typeProductId
|
|
||||||
}
|
|
||||||
if (familyCode) {
|
|
||||||
payload.familyCode = familyCode
|
|
||||||
}
|
|
||||||
if (product.typeProductLabel) {
|
|
||||||
payload.typeProductLabel = product.typeProductLabel
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPayload = (
|
|
||||||
fieldsSource: EditorField[],
|
|
||||||
productsSource: EditorProduct[],
|
|
||||||
restSource: Record<string, unknown>,
|
|
||||||
): PieceModelStructure => {
|
|
||||||
const normalizedFields = fieldsSource
|
|
||||||
.map<PieceModelCustomField | null>((field, index) => {
|
|
||||||
const name = field.name.trim()
|
|
||||||
if (!name) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = (field.type || 'text') as PieceModelCustomFieldType
|
|
||||||
const required = Boolean(field.required)
|
|
||||||
const payload: PieceModelCustomField = {
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
required,
|
|
||||||
orderIndex: index,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'select') {
|
|
||||||
const options = normalizeLineEndings(field.optionsText)
|
|
||||||
.split('\n')
|
|
||||||
.map((option) => option.trim())
|
|
||||||
.filter((option) => option.length > 0)
|
|
||||||
if (options.length > 0) {
|
|
||||||
payload.options = options
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
})
|
|
||||||
.filter((field): field is PieceModelCustomField => Boolean(field))
|
|
||||||
|
|
||||||
const normalizedProducts = productsSource
|
|
||||||
.map((product) => normalizeProductEntry(product))
|
|
||||||
.filter((product): product is PieceModelProduct => Boolean(product))
|
|
||||||
|
|
||||||
const draft: PieceModelStructure = {
|
|
||||||
...safeClone(restSource, {}),
|
|
||||||
products: normalizedProducts,
|
|
||||||
customFields: normalizedFields,
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizePieceStructureForSave(draft)
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializeStructure = (structure?: PieceModelStructure | null): string => {
|
|
||||||
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastEmitted = serializeStructure(props.modelValue)
|
|
||||||
|
|
||||||
const emitUpdate = () => {
|
|
||||||
const payload = buildPayload(fields.value, products.value, restState.value)
|
|
||||||
const serialized = JSON.stringify(payload)
|
|
||||||
if (serialized !== lastEmitted) {
|
|
||||||
lastEmitted = serialized
|
|
||||||
emit('update:modelValue', payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(fields, emitUpdate, { deep: true })
|
|
||||||
watch(products, emitUpdate, { deep: true })
|
|
||||||
watch(productTypeOptions, () => {
|
|
||||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(value) => {
|
|
||||||
const incomingSerialized = serializeStructure(value)
|
|
||||||
if (incomingSerialized === lastEmitted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
restState.value = extractRest(value)
|
|
||||||
fields.value = hydrateFields(value)
|
|
||||||
products.value = hydrateProducts(value)
|
|
||||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
|
||||||
lastEmitted = incomingSerialized
|
|
||||||
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
|
|
||||||
initialProductUids.value = new Set(products.value.map(p => p.uid))
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!productTypeOptions.value.length) {
|
|
||||||
await loadProductTypes()
|
|
||||||
}
|
|
||||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
|
||||||
})
|
|
||||||
|
|
||||||
const dragState = reactive({
|
|
||||||
draggingIndex: null as number | null,
|
|
||||||
dropTargetIndex: null as number | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetDragState = () => {
|
|
||||||
dragState.draggingIndex = null
|
|
||||||
dragState.dropTargetIndex = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderFields = (from: number, to: number) => {
|
|
||||||
if (from === to) {
|
|
||||||
resetDragState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = fields.value.slice()
|
|
||||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
|
||||||
resetDragState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const [moved] = list.splice(from, 1)
|
|
||||||
if (!moved) {
|
|
||||||
resetDragState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
list.splice(to, 0, moved)
|
|
||||||
fields.value = applyOrderIndex(list)
|
|
||||||
resetDragState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragStart = (index: number, event: DragEvent) => {
|
|
||||||
dragState.draggingIndex = index
|
|
||||||
dragState.dropTargetIndex = index
|
|
||||||
if (event.dataTransfer) {
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragEnter = (index: number) => {
|
|
||||||
if (dragState.draggingIndex === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dragState.dropTargetIndex = index
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDrop = (index: number) => {
|
|
||||||
if (dragState.draggingIndex === null) {
|
|
||||||
resetDragState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reorderFields(dragState.draggingIndex, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragEnd = () => {
|
|
||||||
resetDragState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderClass = (index: number) => {
|
|
||||||
if (dragState.draggingIndex === index) {
|
|
||||||
return 'border-dashed border-primary bg-primary/5'
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
dragState.draggingIndex !== null &&
|
|
||||||
dragState.dropTargetIndex === index &&
|
|
||||||
dragState.draggingIndex !== index
|
|
||||||
) {
|
|
||||||
return 'border-primary border-dashed bg-primary/10'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
|
||||||
uid: createUid('field'),
|
|
||||||
name: '',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
optionsText: '',
|
|
||||||
orderIndex,
|
|
||||||
})
|
|
||||||
|
|
||||||
const addField = () => {
|
|
||||||
const next = fields.value.slice()
|
|
||||||
next.push(createEmptyField(next.length))
|
|
||||||
fields.value = applyOrderIndex(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeField = (index: number) => {
|
|
||||||
const next = fields.value.filter((_, i) => i !== index)
|
|
||||||
fields.value = applyOrderIndex(next)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
130
app/components/PieceSelect.vue
Normal file
130
app/components/PieceSelect.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="modelValue ?? undefined"
|
||||||
|
:options="pieceOptions"
|
||||||
|
:loading="loading"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
size="sm"
|
||||||
|
option-value="id"
|
||||||
|
:option-label="formatLabel"
|
||||||
|
:disabled="disabled"
|
||||||
|
server-search
|
||||||
|
@update:modelValue="updateValue"
|
||||||
|
@search="handleSearch"
|
||||||
|
>
|
||||||
|
<template #option-description="{ option }">
|
||||||
|
<span class="text-xs text-base-content/60">
|
||||||
|
{{ formatDescription(option) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</SearchSelect>
|
||||||
|
<p v-if="helperText" class="text-xs text-base-content/60">
|
||||||
|
{{ helperText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
emptyText?: string
|
||||||
|
helperText?: string
|
||||||
|
disabled?: boolean
|
||||||
|
typePieceId?: string | null
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: 'Sélectionner une pièce…',
|
||||||
|
emptyText: 'Aucune pièce disponible',
|
||||||
|
helperText: '',
|
||||||
|
disabled: false,
|
||||||
|
typePieceId: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { loading: globalLoading, loadPieces } = usePieces()
|
||||||
|
|
||||||
|
const localPieces = ref<any[]>([])
|
||||||
|
const localLoading = ref(false)
|
||||||
|
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||||
|
|
||||||
|
const pieceOptions = computed(() => localPieces.value)
|
||||||
|
|
||||||
|
const loadFilteredPieces = async (search = '') => {
|
||||||
|
if (!props.typePieceId) return
|
||||||
|
localLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loadPieces({ typePieceId: props.typePieceId, search, itemsPerPage: 200, force: true })
|
||||||
|
if (result.success && result.data?.items) {
|
||||||
|
localPieces.value = result.data.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
|
console.error('Erreur lors du chargement des pièces:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
localLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleSearch = (term: string) => {
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce)
|
||||||
|
searchDebounce = setTimeout(() => loadFilteredPieces(term.trim()), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFilteredPieces()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.typePieceId,
|
||||||
|
() => {
|
||||||
|
loadFilteredPieces()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateValue = (value: string | number | null | undefined) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLabel = (option: any) => {
|
||||||
|
if (!option) return ''
|
||||||
|
const name = option.name || 'Pièce'
|
||||||
|
return option.reference ? `${name} — ${option.reference}` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDescription = (option: any) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
const typeName = option?.typePiece?.name
|
||||||
|
if (typeName) {
|
||||||
|
parts.push(typeName)
|
||||||
|
}
|
||||||
|
if (option?.reference) {
|
||||||
|
parts.push(`Ref. ${option.reference}`)
|
||||||
|
}
|
||||||
|
if (option?.prix !== undefined && option.prix !== null) {
|
||||||
|
const price = Number(option.prix)
|
||||||
|
if (!Number.isNaN(price)) {
|
||||||
|
parts.push(`${price.toFixed(2)} €`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' • ') : 'Sans référence'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
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>
|
||||||
@@ -8,9 +8,11 @@
|
|||||||
:empty-text="emptyText"
|
:empty-text="emptyText"
|
||||||
size="sm"
|
size="sm"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
option-label="name"
|
:option-label="formatLabel"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
server-search
|
||||||
@update:modelValue="updateValue"
|
@update:modelValue="updateValue"
|
||||||
|
@search="handleSearch"
|
||||||
>
|
>
|
||||||
<template #option-description="{ option }">
|
<template #option-description="{ option }">
|
||||||
<span class="text-xs text-base-content/60">
|
<span class="text-xs text-base-content/60">
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
|
||||||
@@ -52,43 +54,45 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:modelValue', value: string | null): void
|
(e: 'update:modelValue', value: string | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { products, loading, loadProducts } = useProducts()
|
const { loading: globalLoading, loadProducts } = useProducts()
|
||||||
|
|
||||||
const productOptions = computed(() => {
|
const localProducts = ref<any[]>([])
|
||||||
const baseOptions = Array.isArray(products.value) ? products.value : []
|
const localLoading = ref(false)
|
||||||
if (!props.typeProductId) {
|
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||||
return baseOptions
|
|
||||||
|
const productOptions = computed(() => localProducts.value)
|
||||||
|
|
||||||
|
const loadFilteredProducts = async (search = '') => {
|
||||||
|
if (!props.typeProductId) return
|
||||||
|
localLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loadProducts({ typeProductId: props.typeProductId, search, itemsPerPage: 200, force: true })
|
||||||
|
if (result.success && result.data?.items) {
|
||||||
|
localProducts.value = result.data.items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
|
console.error('Erreur lors du chargement des produits:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
localLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allowedTypeId = String(props.typeProductId)
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
return baseOptions.filter((product) => {
|
const handleSearch = (term: string) => {
|
||||||
const typeId =
|
if (searchDebounce) clearTimeout(searchDebounce)
|
||||||
product?.typeProductId ||
|
searchDebounce = setTimeout(() => loadFilteredProducts(term.trim()), 300)
|
||||||
product?.typeProduct?.id ||
|
}
|
||||||
null
|
|
||||||
return typeId ? String(typeId) === allowedTypeId : false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (productOptions.value.length === 0) {
|
loadFilteredProducts()
|
||||||
loadProducts().catch((error) => {
|
|
||||||
console.error('Erreur lors du chargement des produits:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.typeProductId,
|
||||||
(value) => {
|
() => {
|
||||||
if (typeof value === 'string') {
|
loadFilteredProducts()
|
||||||
const exists = productOptions.value.some((product) => product.id === value)
|
|
||||||
if (!exists && productOptions.value.length === 0 && !loading.value) {
|
|
||||||
loadProducts().catch((error) => {
|
|
||||||
console.error('Erreur lors du chargement des produits:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
|
|||||||
emit('update:modelValue', String(value))
|
emit('update:modelValue', String(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatLabel = (option: any) => {
|
||||||
|
if (!option) return ''
|
||||||
|
const name = option.name || 'Produit'
|
||||||
|
return option.reference ? `${name} — ${option.reference}` : name
|
||||||
|
}
|
||||||
|
|
||||||
const formatDescription = (option: any) => {
|
const formatDescription = (option: any) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
const typeName = option?.typeProduct?.name
|
||||||
|
if (typeName) {
|
||||||
|
parts.push(typeName)
|
||||||
|
}
|
||||||
if (option?.reference) {
|
if (option?.reference) {
|
||||||
parts.push(option.reference)
|
parts.push(`Ref. ${option.reference}`)
|
||||||
}
|
}
|
||||||
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
||||||
const price = Number(option.supplierPrice)
|
const price = Number(option.supplierPrice)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
112
app/components/SyncConfirmationModal.vue
Normal file
112
app/components/SyncConfirmationModal.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SyncPreviewResult } from '~/services/modelTypes';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
preview: SyncPreviewResult | null;
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
confirm: [];
|
||||||
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLDialogElement>();
|
||||||
|
|
||||||
|
watch(() => props.open, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
dialogRef.value?.showModal();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dialogRef.value?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasDeletions = computed(() => {
|
||||||
|
if (!props.preview) return false;
|
||||||
|
return Object.values(props.preview.deletions).some(v => v > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasModifications = computed(() => {
|
||||||
|
if (!props.preview) return false;
|
||||||
|
return Object.values(props.preview.modifications).some(v => v > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAdditions = computed(() => {
|
||||||
|
if (!props.preview) return 0;
|
||||||
|
return Object.values(props.preview.additions).reduce((sum, v) => sum + v, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDeletions = computed(() => {
|
||||||
|
if (!props.preview) return 0;
|
||||||
|
return Object.values(props.preview.deletions).reduce((sum, v) => sum + v, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalModifications = computed(() => {
|
||||||
|
if (!props.preview) return 0;
|
||||||
|
return Object.values(props.preview.modifications).reduce((sum, v) => sum + v, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('confirm');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog ref="dialogRef" class="modal" @close="handleCancel">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
Synchronisation des éléments liés
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="preview" class="py-4 space-y-3">
|
||||||
|
<p>
|
||||||
|
Cette modification impactera
|
||||||
|
<strong>{{ preview.itemCount }}</strong>
|
||||||
|
élément(s) lié(s).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<li v-if="totalAdditions > 0" class="text-success">
|
||||||
|
{{ totalAdditions }} ajout(s)
|
||||||
|
</li>
|
||||||
|
<li v-if="totalDeletions > 0" class="text-error">
|
||||||
|
{{ totalDeletions }} suppression(s)
|
||||||
|
</li>
|
||||||
|
<li v-if="totalModifications > 0" class="text-warning">
|
||||||
|
{{ totalModifications }} modification(s)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="hasDeletions" role="alert" class="alert alert-warning">
|
||||||
|
<span>Des éléments seront supprimés. Cette action est irréversible.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasModifications" role="alert" class="alert alert-info">
|
||||||
|
<span>Des valeurs de champs personnalisés seront réinitialisées.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost" :disabled="loading" @click="handleCancel">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" :disabled="loading" @click="handleConfirm">
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm" />
|
||||||
|
Confirmer la synchronisation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button @click="handleCancel">
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
118
app/components/common/DocumentListInline.vue
Normal file
118
app/components/common/DocumentListInline.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<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 flex items-center gap-2">
|
||||||
|
{{ document.name }}
|
||||||
|
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
|
||||||
|
</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
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
title="Modifier"
|
||||||
|
@click="$emit('edit', document)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<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 { getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||||
|
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
|
||||||
|
canEdit?: boolean
|
||||||
|
deleteDisabled?: boolean
|
||||||
|
emptyText?: string
|
||||||
|
}>(), {
|
||||||
|
canDelete: false,
|
||||||
|
canEdit: false,
|
||||||
|
deleteDisabled: false,
|
||||||
|
emptyText: 'Aucun document.',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'preview', document: Document): void
|
||||||
|
(e: 'delete', documentId: string): void
|
||||||
|
(e: 'edit', document: Document): 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>
|
||||||
170
app/components/common/EntityVersionList.vue
Normal file
170
app/components/common/EntityVersionList.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<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">Versions</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Historique des versions avec possibilite de restauration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="versions.length" class="badge badge-outline">
|
||||||
|
{{ versions.length }} version{{ versions.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 des versions...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="alert alert-warning">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
|
||||||
|
Aucune version enregistree.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||||
|
<li
|
||||||
|
v-for="entry in versions"
|
||||||
|
:key="entry.version"
|
||||||
|
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
|
||||||
|
<span
|
||||||
|
v-if="entry.version === currentVersion"
|
||||||
|
class="badge badge-primary badge-sm"
|
||||||
|
>
|
||||||
|
actuelle
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="entry.action === 'restore'"
|
||||||
|
class="badge badge-warning badge-sm"
|
||||||
|
>
|
||||||
|
restauration
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||||
|
<span>{{ actionLabel(entry.action) }}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{{ formatDate(entry.createdAt) }}</span>
|
||||||
|
<span v-if="entry.actor">· {{ entry.actor.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.diff && Object.keys(entry.diff).length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="(change, field) in entry.diff"
|
||||||
|
:key="field"
|
||||||
|
class="badge badge-ghost badge-xs"
|
||||||
|
>
|
||||||
|
{{ formatDiffEntry(String(field), change) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="canRestore && entry.version !== currentVersion"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="restoring"
|
||||||
|
@click="handleRestore(entry.version)"
|
||||||
|
>
|
||||||
|
Restaurer
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<VersionRestoreModal
|
||||||
|
:visible="modalVisible"
|
||||||
|
:preview="previewData"
|
||||||
|
:restoring="restoring"
|
||||||
|
:field-labels="fieldLabels"
|
||||||
|
:entity-type="entityType"
|
||||||
|
@close="modalVisible = false"
|
||||||
|
@confirm="confirmRestore"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch, toRef } from 'vue'
|
||||||
|
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
|
||||||
|
import VersionRestoreModal from './VersionRestoreModal.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||||
|
entityId: string
|
||||||
|
fieldLabels: Record<string, string>
|
||||||
|
/** Increment this value to force a refresh of the versions list */
|
||||||
|
refreshKey?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
restored: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const canRestore = computed(() => canEdit.value)
|
||||||
|
|
||||||
|
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
|
||||||
|
entityType: props.entityType,
|
||||||
|
entityId: props.entityId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentVersion = computed(() => {
|
||||||
|
if (versions.value.length === 0) return null
|
||||||
|
return versions.value[0]?.version ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const previewData = ref<RestorePreview | null>(null)
|
||||||
|
const restoring = ref(false)
|
||||||
|
const targetVersion = ref<number | null>(null)
|
||||||
|
|
||||||
|
const actionLabel = (action: string) => historyActionLabel(action)
|
||||||
|
const formatDate = (date: string) => formatHistoryDate(date)
|
||||||
|
|
||||||
|
const formatDiffEntry = (field: string, change: { from: unknown; to: unknown }): string => {
|
||||||
|
const label = props.fieldLabels[field] || field
|
||||||
|
// Link changes (addedComponent, removedPiece, etc.) have {id, name} as value
|
||||||
|
const val = change.to ?? change.from
|
||||||
|
if (val && typeof val === 'object' && 'name' in (val as Record<string, unknown>)) {
|
||||||
|
return `${label}: ${(val as Record<string, unknown>).name}`
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (version: number) => {
|
||||||
|
targetVersion.value = version
|
||||||
|
previewData.value = null
|
||||||
|
modalVisible.value = true
|
||||||
|
previewData.value = await fetchPreview(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRestore = async () => {
|
||||||
|
if (!targetVersion.value) return
|
||||||
|
restoring.value = true
|
||||||
|
const result = await restore(targetVersion.value)
|
||||||
|
restoring.value = false
|
||||||
|
if (result?.success) {
|
||||||
|
modalVisible.value = false
|
||||||
|
await fetchVersions()
|
||||||
|
emit('restored')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
error.value = 'La restauration a echoue.'
|
||||||
|
modalVisible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchVersions()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-refresh when parent signals a data change
|
||||||
|
watch(toRef(props, 'refreshKey'), () => {
|
||||||
|
fetchVersions()
|
||||||
|
})
|
||||||
|
</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="$slots['option-description'] || 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',
|
||||||
@@ -119,6 +133,10 @@ const props = defineProps({
|
|||||||
maxVisible: {
|
maxVisible: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 50
|
default: 50
|
||||||
|
},
|
||||||
|
serverSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,11 +154,11 @@ const selectedOption = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const displayedOptions = computed(() => {
|
const displayedOptions = computed(() => {
|
||||||
const term = searchTerm.value.trim().toLowerCase()
|
|
||||||
const items = baseOptions.value.slice()
|
const items = baseOptions.value.slice()
|
||||||
|
|
||||||
const filtered = term
|
const filtered = (!props.serverSearch && searchTerm.value.trim())
|
||||||
? items.filter((option) => {
|
? items.filter((option) => {
|
||||||
|
const term = searchTerm.value.trim().toLowerCase()
|
||||||
const label = resolveLabel(option).toLowerCase()
|
const label = resolveLabel(option).toLowerCase()
|
||||||
const description = resolveDescription(option)?.toLowerCase() || ''
|
const description = resolveDescription(option)?.toLowerCase() || ''
|
||||||
return label.includes(term) || description.includes(term)
|
return label.includes(term) || description.includes(term)
|
||||||
@@ -155,7 +173,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 +288,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>
|
||||||
198
app/components/common/VersionRestoreModal.vue
Normal file
198
app/components/common/VersionRestoreModal.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
|
||||||
|
<div class="modal-box max-w-lg">
|
||||||
|
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
|
||||||
|
|
||||||
|
<div v-if="!preview" class="flex justify-center py-8">
|
||||||
|
<span class="loading loading-spinner loading-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<!-- Restore mode explanation -->
|
||||||
|
<div
|
||||||
|
class="alert text-sm"
|
||||||
|
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- FULL MODE -->
|
||||||
|
<template v-if="preview.restoreMode === 'full'">
|
||||||
|
<span class="font-semibold">Restauration complete</span>
|
||||||
|
|
||||||
|
<!-- Machine: always full, no category -->
|
||||||
|
<template v-if="entityType === 'machine'">
|
||||||
|
<span>Tous les elements de la machine seront restaures :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs">
|
||||||
|
<li>Nom, reference, prix</li>
|
||||||
|
<li>Site</li>
|
||||||
|
<li>Fournisseurs</li>
|
||||||
|
<li>Composants, pieces et produits lies</li>
|
||||||
|
<li>Champs personnalises</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Composant -->
|
||||||
|
<template v-else-if="entityType === 'composant'">
|
||||||
|
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs">
|
||||||
|
<li>Nom, reference, description, prix</li>
|
||||||
|
<li>Fournisseurs</li>
|
||||||
|
<li>Structure : pieces, sous-composants et produits lies</li>
|
||||||
|
<li>Champs personnalises</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Piece -->
|
||||||
|
<template v-else-if="entityType === 'piece'">
|
||||||
|
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs">
|
||||||
|
<li>Nom, reference, description, prix</li>
|
||||||
|
<li>Fournisseurs</li>
|
||||||
|
<li>Produits lies</li>
|
||||||
|
<li>Champs personnalises</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Product -->
|
||||||
|
<template v-else-if="entityType === 'product'">
|
||||||
|
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs">
|
||||||
|
<li>Nom, reference, prix fournisseur</li>
|
||||||
|
<li>Fournisseurs</li>
|
||||||
|
<li>Champs personnalises</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- PARTIAL MODE (never for machines) -->
|
||||||
|
<template v-else>
|
||||||
|
<span class="font-semibold">Restauration partielle</span>
|
||||||
|
|
||||||
|
<!-- Composant -->
|
||||||
|
<template v-if="entityType === 'composant'">
|
||||||
|
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs">
|
||||||
|
<li>Nom, reference, description, prix</li>
|
||||||
|
<li>Fournisseurs</li>
|
||||||
|
</ul>
|
||||||
|
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||||
|
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
|
||||||
|
<li>Champs personnalises actuels</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Piece -->
|
||||||
|
<template v-else-if="entityType === 'piece'">
|
||||||
|
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs">
|
||||||
|
<li>Nom, reference, description, prix</li>
|
||||||
|
<li>Fournisseurs</li>
|
||||||
|
</ul>
|
||||||
|
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||||
|
<li>Produits lies actuels</li>
|
||||||
|
<li>Champs personnalises actuels</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Product -->
|
||||||
|
<template v-else-if="entityType === 'product'">
|
||||||
|
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs">
|
||||||
|
<li>Nom, reference, prix fournisseur</li>
|
||||||
|
<li>Fournisseurs</li>
|
||||||
|
</ul>
|
||||||
|
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||||
|
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||||
|
<li>Champs personnalises actuels</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Diff -->
|
||||||
|
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
|
||||||
|
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
|
||||||
|
<ul class="space-y-1 text-sm">
|
||||||
|
<li
|
||||||
|
v-for="(change, field) in preview.diff"
|
||||||
|
:key="field"
|
||||||
|
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
|
||||||
|
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
|
||||||
|
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-sm text-base-content/60">
|
||||||
|
Aucune difference detectee — l'entite est deja dans l'etat de cette version.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warnings -->
|
||||||
|
<div v-if="preview.warnings.length" class="space-y-1">
|
||||||
|
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(warning, i) in preview.warnings"
|
||||||
|
:key="i"
|
||||||
|
class="alert alert-warning py-2 text-xs"
|
||||||
|
>
|
||||||
|
{{ warning.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
|
||||||
|
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
|
||||||
|
Confirmer la restauration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
|
||||||
|
<button type="button">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RestorePreview } from '~/composables/useEntityVersions'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
preview: RestorePreview | null
|
||||||
|
restoring: boolean
|
||||||
|
fieldLabels: Record<string, string>
|
||||||
|
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formatFieldLabel = (field: string): string => {
|
||||||
|
if (field.startsWith('customField:')) {
|
||||||
|
return `Champ perso : ${field.replace('customField:', '')}`
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValue = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) return '—'
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
</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,15 +70,18 @@
|
|||||||
<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 }}
|
||||||
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -74,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>
|
||||||
@@ -116,32 +132,36 @@
|
|||||||
>
|
>
|
||||||
<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 }}
|
||||||
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -152,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>
|
||||||
@@ -166,40 +194,58 @@
|
|||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
class="btn btn-ghost btn-circle avatar placeholder"
|
class="indicator cursor-pointer"
|
||||||
>
|
>
|
||||||
<div
|
<span
|
||||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
v-if="unresolvedCount > 0"
|
||||||
|
class="indicator-item badge badge-warning badge-xs"
|
||||||
>
|
>
|
||||||
<span
|
{{ unresolvedCount }}
|
||||||
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
|
</span>
|
||||||
>
|
<div
|
||||||
|
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-semibold">
|
||||||
{{ 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="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
|
||||||
|
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<div class="divider my-0.5 px-2" />
|
||||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
<li v-if="isAdmin">
|
||||||
Gestion des profils
|
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
Administration
|
||||||
|
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
|
||||||
|
Commentaires
|
||||||
|
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
|
<IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
||||||
|
</NuxtLink>
|
||||||
|
</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>
|
||||||
@@ -211,14 +257,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } 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 { useProfileSession } from '~/composables/useProfileSession'
|
import { useProfileSession } from '~/composables/useProfileSession'
|
||||||
|
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<{
|
||||||
@@ -229,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' },
|
||||||
@@ -257,29 +327,23 @@ 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',
|
||||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
|
icon: IconLucideLink,
|
||||||
|
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/sites', label: 'Sites' },
|
{ to: '/sites', label: 'Sites' },
|
||||||
{ to: '/documents', label: 'Documents' },
|
{ to: '/documents', label: 'Documents' },
|
||||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||||
|
{ to: '/comments', label: 'Commentaires' },
|
||||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -288,6 +352,27 @@ const navGroups: NavGroup[] = [
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||||
const { activeProfile } = useProfileSession()
|
const { activeProfile } = useProfileSession()
|
||||||
|
const { isAdmin, canEdit } = usePermissions()
|
||||||
|
const { fetchUnresolvedCount } = useComments()
|
||||||
|
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
||||||
|
|
||||||
|
const unresolvedCount = ref(0)
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const refreshUnresolvedCount = async () => {
|
||||||
|
if (!activeProfile.value) return
|
||||||
|
unresolvedCount.value = await fetchUnresolvedCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initDarkMode()
|
||||||
|
refreshUnresolvedCount()
|
||||||
|
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
|
})
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
@@ -303,21 +388,33 @@ 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(() => {
|
||||||
|
if (isAdmin.value) return 'Admin'
|
||||||
|
if (canEdit.value) return 'Gestionnaire'
|
||||||
|
return 'Lecteur'
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleBadgeClass = computed(() => {
|
||||||
|
if (isAdmin.value) return 'badge-error'
|
||||||
|
if (canEdit.value) return 'badge-warning'
|
||||||
|
return 'badge-info'
|
||||||
|
})
|
||||||
|
|
||||||
const activeProfileLabel = computed(() => {
|
const activeProfileLabel = computed(() => {
|
||||||
if (!activeProfile.value) {
|
if (!activeProfile.value) {
|
||||||
return 'Profil inconnu'
|
return 'Profil inconnu'
|
||||||
@@ -344,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,
|
||||||
|
|||||||
236
app/components/machine/AddEntityToMachineModal.vue
Normal file
236
app/components/machine/AddEntityToMachineModal.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<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"
|
||||||
|
server-search
|
||||||
|
@search="handleEntitySearch"
|
||||||
|
/>
|
||||||
|
</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) => {
|
||||||
|
const name = e.name || '(sans nom)'
|
||||||
|
return e.reference ? `${name} — ${e.reference}` : name
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleEntitySearch = (term: string) => {
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce)
|
||||||
|
searchDebounce = setTimeout(async () => {
|
||||||
|
if (!selectedTypeName.value) return
|
||||||
|
loadingEntities.value = true
|
||||||
|
try {
|
||||||
|
if (props.entityKind === 'component') {
|
||||||
|
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
} else if (props.entityKind === 'piece') {
|
||||||
|
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
} else {
|
||||||
|
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingEntities.value = false
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
112
app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
112
app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
Définitions des champs personnalisés
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<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<{
|
||||||
|
'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"
|
||||||
@@ -51,6 +32,9 @@
|
|||||||
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
Imprimer
|
Imprimer
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||||
|
Retour aux machines
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,17 +44,24 @@ 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 router = useRouter()
|
||||||
|
|
||||||
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': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
navigateTo('/machines')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -14,12 +14,34 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||||
@blur="$emit('blur-field')"
|
|
||||||
/>
|
/>
|
||||||
<div v-else class="input input-bordered bg-base-200">
|
<div v-else class="input input-bordered bg-base-200">
|
||||||
{{ 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)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
@@ -31,13 +53,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||||
@blur="$emit('blur-field')"
|
|
||||||
/>
|
/>
|
||||||
<div v-else class="input input-bordered bg-base-200">
|
<div v-else class="input input-bordered bg-base-200">
|
||||||
{{ machineReference }}
|
{{ machineReference }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control">
|
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Fournisseur</span>
|
<span class="label-text">Fournisseur</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -49,30 +70,22 @@
|
|||||||
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">
|
<ConstructeurLinksTable
|
||||||
<div v-if="machineConstructeursDisplay.length" class="space-y-1">
|
v-if="constructeurLinks.length"
|
||||||
<div
|
:model-value="constructeurLinks"
|
||||||
v-for="constructeur in machineConstructeursDisplay"
|
:readonly="!isEditMode"
|
||||||
:key="constructeur.id"
|
@update:model-value="$emit('update:constructeur-links', $event)"
|
||||||
class="flex flex-col"
|
@remove="$emit('remove-constructeur-link', $event)"
|
||||||
>
|
/>
|
||||||
<span class="font-medium">{{ constructeur.name }}</span>
|
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||||
<span
|
<span class="text-base-content/50">Non défini</span>
|
||||||
v-if="formatConstructeurContactSummary(constructeur)"
|
|
||||||
class="text-xs text-gray-500"
|
|
||||||
>
|
|
||||||
{{ formatConstructeurContactSummary(constructeur) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span v-else class="font-medium">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"
|
||||||
@@ -92,7 +105,6 @@
|
|||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||||
@blur="$emit('update-custom-field', field)"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -101,7 +113,6 @@
|
|||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||||
@blur="$emit('update-custom-field', field)"
|
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
@@ -109,7 +120,6 @@
|
|||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
|
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
|
||||||
@blur="$emit('update-custom-field', field)"
|
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -120,17 +130,15 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
:value="field.value ?? ''"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm"
|
||||||
:checked="String(field.value).toLowerCase() === 'true'"
|
:checked="String(field.value).toLowerCase() === 'true'"
|
||||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||||
@blur="$emit('update-custom-field', field)"
|
>
|
||||||
/>
|
<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>
|
||||||
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
</label>
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
@@ -138,7 +146,6 @@
|
|||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||||
@blur="$emit('update-custom-field', field)"
|
|
||||||
/>
|
/>
|
||||||
<div v-else class="text-xs text-error">
|
<div v-else class="text-xs text-error">
|
||||||
Type de champ non pris en charge
|
Type de champ non pris en charge
|
||||||
@@ -152,34 +159,72 @@
|
|||||||
</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"
|
||||||
|
@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 {
|
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||||
formatConstructeurContact as formatConstructeurContactSummary,
|
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||||
} from '~/shared/constructeurUtils'
|
|
||||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||||
|
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||||
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||||
|
|
||||||
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
|
||||||
|
constructeurLinks: ConstructeurLinkEntry[]
|
||||||
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': []
|
'update:constructeur-links': [links: ConstructeurLinkEntry[]]
|
||||||
|
'remove-constructeur-link': [constructeurId: string]
|
||||||
'set-custom-field-value': [field: any, value: unknown]
|
'set-custom-field-value': [field: any, value: unknown]
|
||||||
'update-custom-field': [field: any]
|
'custom-fields-saved': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const fieldDefs = useMachineCustomFieldDefs({
|
||||||
|
machineId: props.machineId,
|
||||||
|
initialDefs: props.machineCustomFieldDefs,
|
||||||
|
onSaved: () => emit('custom-fields-saved'),
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.machineCustomFieldDefs, (newDefs) => {
|
||||||
|
fieldDefs.reinit(newDefs)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
|
||||||
|
})
|
||||||
</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>
|
|
||||||
172
app/components/model-types/ConversionModal.vue
Normal file
172
app/components/model-types/ConversionModal.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
|
Convertir la catégorie
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Vérification de la conversion…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="checkError" class="mt-4 text-sm text-error">
|
||||||
|
{{ checkError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocked state -->
|
||||||
|
<template v-else-if="checkResult && !checkResult.canConvert">
|
||||||
|
<p class="mt-3 text-sm text-base-content/70">
|
||||||
|
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
|
||||||
|
</p>
|
||||||
|
<ul class="mt-3 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(blocker, i) in checkResult.blockers"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
|
||||||
|
>
|
||||||
|
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
{{ blocker }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Eligible state -->
|
||||||
|
<template v-else-if="checkResult && checkResult.canConvert">
|
||||||
|
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
|
||||||
|
<p class="text-sm font-medium text-warning">
|
||||||
|
{{ directionLabel }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70">
|
||||||
|
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="checkResult.names.length > 0"
|
||||||
|
class="mt-3 rounded-xl border border-base-200 bg-base-100"
|
||||||
|
>
|
||||||
|
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
|
||||||
|
Éléments concernés :
|
||||||
|
</p>
|
||||||
|
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
|
||||||
|
<li
|
||||||
|
v-for="(name, i) in checkResult.names"
|
||||||
|
:key="i"
|
||||||
|
class="py-1.5 text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="convertError" class="mt-3 text-sm text-error">
|
||||||
|
{{ convertError }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="checkResult?.canConvert"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="doConvert"
|
||||||
|
>
|
||||||
|
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x';
|
||||||
|
import {
|
||||||
|
checkConversion,
|
||||||
|
convertCategory,
|
||||||
|
type ConversionCheck,
|
||||||
|
type ModelType,
|
||||||
|
} from '~/services/modelTypes';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
modelType: ModelType | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'converted'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const checking = ref(false);
|
||||||
|
const checkError = ref<string | null>(null);
|
||||||
|
const checkResult = ref<ConversionCheck | null>(null);
|
||||||
|
const converting = ref(false);
|
||||||
|
const convertError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const directionLabel = computed(() => {
|
||||||
|
if (!checkResult.value) return '';
|
||||||
|
return checkResult.value.direction === 'piece_to_component'
|
||||||
|
? 'Conversion : Catégorie de pièce → Catégorie de composant'
|
||||||
|
: 'Conversion : Catégorie de composant → Catégorie de pièce';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (isOpen) => {
|
||||||
|
if (!isOpen || !props.modelType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checking.value = true;
|
||||||
|
checkError.value = null;
|
||||||
|
checkResult.value = null;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
checkResult.value = await checkConversion(props.modelType.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
checkError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const doConvert = async () => {
|
||||||
|
if (!props.modelType) return;
|
||||||
|
|
||||||
|
converting.value = true;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await convertCategory(props.modelType.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
convertError.value = result.error || 'La conversion a échoué.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('converted');
|
||||||
|
} catch (err: any) {
|
||||||
|
convertError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
|
||||||
|
} finally {
|
||||||
|
converting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -9,189 +9,278 @@
|
|||||||
</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"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="option in categories"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': option.value === selectedCategory }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="option.value === selectedCategory"
|
||||||
|
@click="onCategoryChange(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="items"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:show-category-tabs="allowCategorySwitch"
|
:sort="currentSort"
|
||||||
@update:category="onCategoryChange"
|
:pagination="paginationState"
|
||||||
@update:search="onSearchInput"
|
:show-per-page="true"
|
||||||
@update:sort="onSortChange"
|
row-key="id"
|
||||||
@update:dir="onDirChange"
|
empty-message="Aucune catégorie trouvée."
|
||||||
@create="openCreatePage"
|
no-results-message="Aucune catégorie ne correspond à votre recherche."
|
||||||
/>
|
@sort="handleSort"
|
||||||
|
@update:current-page="handlePageChange"
|
||||||
|
@update:per-page="handlePerPageChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||||
|
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
v-model="searchInput"
|
||||||
|
type="search"
|
||||||
|
class="grow min-w-0"
|
||||||
|
placeholder="Rechercher par nom…"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<ModelTypesTable
|
<button
|
||||||
:items="items"
|
v-if="canEdit"
|
||||||
:loading="loading"
|
type="button"
|
||||||
:total="total"
|
class="btn btn-primary btn-sm"
|
||||||
:limit="limit"
|
:disabled="loading"
|
||||||
:offset="offset"
|
@click="openCreatePage"
|
||||||
@related="openRelatedModal"
|
>
|
||||||
@edit="openEditPage"
|
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
||||||
@delete="confirmDelete"
|
Créer
|
||||||
@update:offset="onOffsetChange"
|
</button>
|
||||||
/>
|
</template>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
<template #cell-name="{ row }">
|
||||||
<div class="modal-box max-w-3xl">
|
<span class="font-medium">{{ row.name }}</span>
|
||||||
<h3 class="text-lg font-bold text-base-content">
|
</template>
|
||||||
{{ relatedModalTitle }}
|
|
||||||
</h3>
|
|
||||||
<p class="mt-1 text-sm text-base-content/70">
|
|
||||||
{{ relatedModalSubtitle }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
<template #cell-notes="{ row }">
|
||||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
<span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
|
||||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
Chargement des éléments liés…
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
|
<template #cell-actions="{ row }">
|
||||||
{{ relatedError }}
|
<div class="flex justify-end gap-2">
|
||||||
</div>
|
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||||
|
Liés
|
||||||
<div
|
</button>
|
||||||
v-else-if="relatedItems.length === 0"
|
<button
|
||||||
class="px-4 py-6 text-sm text-base-content/60"
|
v-if="canEdit && showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-warning"
|
||||||
|
@click="openConversionModal(row)"
|
||||||
>
|
>
|
||||||
Aucun élément lié à cette catégorie.
|
Convertir
|
||||||
</div>
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
Éditer
|
||||||
<li
|
</button>
|
||||||
v-for="entry in relatedItems"
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
|
||||||
:key="entry.id"
|
Supprimer
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</dialog>
|
</DataTable>
|
||||||
|
|
||||||
|
<ConversionModal
|
||||||
|
:open="conversionModalOpen"
|
||||||
|
:model-type="conversionTarget"
|
||||||
|
@close="closeConversionModal"
|
||||||
|
@converted="onConverted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RelatedItemsModal
|
||||||
|
:open="relatedModalOpen"
|
||||||
|
:model-type="relatedType"
|
||||||
|
@close="relatedModalOpen = false"
|
||||||
|
@open-edit="openRelatedEdit"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } 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 { useApi } from "~/composables/useApi";
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
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('')
|
||||||
const searchTerm = ref("");
|
|
||||||
const sort = ref<"name" | "createdAt">("name");
|
|
||||||
const dir = ref<"asc" | "desc">("asc");
|
|
||||||
const limit = ref(20);
|
|
||||||
const offset = ref(0);
|
|
||||||
|
|
||||||
const items = ref<ModelType[]>([]);
|
// State synced with URL query params
|
||||||
const total = ref(0);
|
const urlState = useUrlState({
|
||||||
const loading = ref(false);
|
q: { default: '' },
|
||||||
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
limit: { default: 20, type: 'number' },
|
||||||
|
offset: { default: 0, type: 'number' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => {
|
||||||
|
searchInput.value = urlState.q.value
|
||||||
|
doRefresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const searchTerm = urlState.q
|
||||||
|
const sort = urlState.sort as Ref<'name' | 'createdAt'>
|
||||||
|
const dir = urlState.dir as Ref<'asc' | 'desc'>
|
||||||
|
const limit = urlState.limit
|
||||||
|
const offset = urlState.offset
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
// Initialize searchInput from URL
|
||||||
let activeController: AbortController | null = null;
|
searchInput.value = searchTerm.value
|
||||||
|
|
||||||
const router = useRouter();
|
const items = ref<ModelType[]>([])
|
||||||
const { showError, showSuccess } = useToast();
|
const total = ref(0)
|
||||||
const { get } = useApi();
|
const loading = ref(false)
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const descriptionText = computed(
|
let activeController: AbortController | null = null
|
||||||
() => props.description ?? DEFAULT_DESCRIPTION
|
|
||||||
);
|
|
||||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
|
|
||||||
|
|
||||||
useHead(() => ({
|
const router = useRouter()
|
||||||
title: headingText.value,
|
const { showError, showSuccess } = useToast()
|
||||||
}));
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown) => {
|
const headingText = computed(() => props.heading)
|
||||||
if (error && typeof error === "object") {
|
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
|
||||||
|
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
|
||||||
|
|
||||||
|
useHead(() => ({ title: headingText.value }))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const showConvertButton = computed(() =>
|
||||||
|
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||||
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
|
{ label: 'Pièces', value: 'PIECE' },
|
||||||
|
{ label: 'Produits', value: 'PRODUCT' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Sort state for DataTable
|
||||||
|
const currentSort = computed<DataTableSort>(() => ({
|
||||||
|
field: sort.value,
|
||||||
|
direction: dir.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSort = (newSort: DataTableSort) => {
|
||||||
|
sort.value = newSort.field as 'name' | 'createdAt'
|
||||||
|
dir.value = newSort.direction as 'asc' | 'desc'
|
||||||
|
offset.value = 0
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination: convert offset/limit to page-based for DataTable
|
||||||
|
const currentPage = computed(() => {
|
||||||
|
if (limit.value <= 0) return 1
|
||||||
|
return Math.floor(offset.value / limit.value) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (limit.value <= 0) return 1
|
||||||
|
return Math.max(1, Math.ceil(total.value / limit.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginationState = computed(() => ({
|
||||||
|
currentPage: currentPage.value,
|
||||||
|
totalPages: totalPages.value,
|
||||||
|
totalItems: total.value,
|
||||||
|
pageItems: items.value.length,
|
||||||
|
perPageOptions: [20, 50, 100],
|
||||||
|
perPage: limit.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
offset.value = (page - 1) * limit.value
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerPageChange = (perPage: number) => {
|
||||||
|
limit.value = perPage
|
||||||
|
offset.value = 0
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractErrorMessage = (error: unknown): string => {
|
||||||
|
let raw: string | null = null
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
const maybeFetchError = error as {
|
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(
|
||||||
@@ -203,292 +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 openConversionModal = (item: ModelType) => {
|
||||||
|
conversionTarget.value = item
|
||||||
|
conversionModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConversionModal = () => {
|
||||||
|
conversionModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConverted = () => {
|
||||||
|
conversionModalOpen.value = false
|
||||||
|
invalidateEntityTypeCache('PIECE')
|
||||||
|
invalidateEntityTypeCache('COMPONENT')
|
||||||
|
showSuccess('Catégorie convertie avec succès.')
|
||||||
|
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>
|
||||||
|
|||||||
@@ -15,8 +15,7 @@
|
|||||||
minlength="2"
|
minlength="2"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
required
|
required
|
||||||
:disabled="restrictedMode"
|
/>
|
||||||
/>
|
|
||||||
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -29,7 +28,7 @@
|
|||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
name="category"
|
name="category"
|
||||||
required
|
required
|
||||||
:disabled="lockCategory"
|
:disabled="lockCategory || isReadonly"
|
||||||
>
|
>
|
||||||
<option value="COMPONENT">Composants</option>
|
<option value="COMPONENT">Composants</option>
|
||||||
<option value="PIECE">Pièces</option>
|
<option value="PIECE">Pièces</option>
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
rows="4"
|
rows="4"
|
||||||
name="notes"
|
name="notes"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
:disabled="restrictedMode"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +81,6 @@
|
|||||||
v-model="componentStructure"
|
v-model="componentStructure"
|
||||||
:allow-subcomponents="allowComponentSubcomponents"
|
:allow-subcomponents="allowComponentSubcomponents"
|
||||||
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
||||||
:restricted-mode="restrictedMode"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,7 +92,7 @@
|
|||||||
Aperçu :
|
Aperçu :
|
||||||
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
||||||
</p>
|
</p>
|
||||||
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
|
<PieceModelStructureEditor v-model="pieceStructure" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -106,35 +103,23 @@
|
|||||||
Aperçu :
|
Aperçu :
|
||||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||||
</p>
|
</p>
|
||||||
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
|
<PieceModelStructureEditor v-model="productStructure" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div
|
<ReferenceFormulaBuilder
|
||||||
v-if="restrictedMode && restrictedModeMessage"
|
v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
|
||||||
class="alert alert-info"
|
v-model="form.referenceFormula"
|
||||||
role="status"
|
:custom-fields="formulaBuilderCustomFields"
|
||||||
aria-live="polite"
|
:disabled="isReadonly"
|
||||||
>
|
/>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
|
||||||
<span>{{ restrictedModeMessage }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="disableSubmit"
|
|
||||||
class="alert alert-warning"
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span>{{ disableSubmitMessage }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
<button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
||||||
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
{{ submitLabel }}
|
{{ submitLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -172,10 +157,7 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading?: boolean
|
structureLoading?: boolean
|
||||||
allowComponentSubcomponents?: boolean
|
allowComponentSubcomponents?: boolean
|
||||||
componentSubcomponentMaxDepth?: number
|
componentSubcomponentMaxDepth?: number
|
||||||
disableSubmit?: boolean
|
readonly?: boolean
|
||||||
disableSubmitMessage?: string
|
|
||||||
restrictedMode?: boolean
|
|
||||||
restrictedModeMessage?: string
|
|
||||||
}>(), {
|
}>(), {
|
||||||
initialData: null,
|
initialData: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -183,10 +165,7 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading: false,
|
structureLoading: false,
|
||||||
allowComponentSubcomponents: true,
|
allowComponentSubcomponents: true,
|
||||||
componentSubcomponentMaxDepth: 1,
|
componentSubcomponentMaxDepth: 1,
|
||||||
disableSubmit: false,
|
readonly: false,
|
||||||
disableSubmitMessage: '',
|
|
||||||
restrictedMode: false,
|
|
||||||
restrictedModeMessage: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -203,27 +182,35 @@ const componentSubcomponentMaxDepth = computed(() =>
|
|||||||
? props.componentSubcomponentMaxDepth
|
? props.componentSubcomponentMaxDepth
|
||||||
: 1,
|
: 1,
|
||||||
)
|
)
|
||||||
const disableSubmit = computed(() => props.disableSubmit === true)
|
const isReadonly = computed(() => props.readonly === true)
|
||||||
const disableSubmitMessage = computed(() =>
|
|
||||||
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
|
|
||||||
? props.disableSubmitMessage
|
|
||||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
|
||||||
)
|
|
||||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
|
||||||
const restrictedModeMessage = computed(() =>
|
|
||||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
|
||||||
? props.restrictedModeMessage
|
|
||||||
: '',
|
|
||||||
)
|
|
||||||
|
|
||||||
const form = reactive<ModelTypePayload>({
|
const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
||||||
name: '',
|
name: '',
|
||||||
code: '',
|
code: '',
|
||||||
category: props.initialCategory,
|
category: props.initialCategory,
|
||||||
notes: '',
|
notes: '',
|
||||||
structure: undefined,
|
structure: undefined,
|
||||||
|
referenceFormula: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const formulaBuilderCustomFields = computed(() => {
|
||||||
|
if (form.category === 'PIECE') {
|
||||||
|
const fields = pieceStructure.value?.customFields
|
||||||
|
return Array.isArray(fields) ? fields : []
|
||||||
|
}
|
||||||
|
if (form.category === 'COMPONENT') {
|
||||||
|
const fields = componentStructure.value?.customFields
|
||||||
|
return Array.isArray(fields) ? fields : []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||||
|
if (!formula) return []
|
||||||
|
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
|
||||||
|
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
|
||||||
|
}
|
||||||
|
|
||||||
const errors = reactive<{ name?: string }>({})
|
const errors = reactive<{ name?: string }>({})
|
||||||
const nameInput = ref<HTMLInputElement | null>(null)
|
const nameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
@@ -287,11 +274,14 @@ const resetForm = () => {
|
|||||||
|
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
|
|
||||||
|
const incomingAny = incoming as Record<string, unknown>
|
||||||
|
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
|
||||||
|
|
||||||
resetStructures(incoming.structure, form.category)
|
resetStructures(incoming.structure, form.category)
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || isReadonly.value)
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
@@ -308,6 +298,7 @@ const validate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
if (isReadonly.value) return
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -324,20 +315,28 @@ const handleSubmit = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (form.category === 'COMPONENT') {
|
if (form.category === 'COMPONENT') {
|
||||||
|
const formula = form.referenceFormula || null
|
||||||
|
const requiredFields = extractFormulaFields(formula)
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
...common,
|
...common,
|
||||||
category: 'COMPONENT',
|
category: 'COMPONENT',
|
||||||
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
|
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
|
||||||
})
|
referenceFormula: formula,
|
||||||
|
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||||
|
} as ModelTypePayload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.category === 'PIECE') {
|
if (form.category === 'PIECE') {
|
||||||
|
const formula = form.referenceFormula || null
|
||||||
|
const requiredFields = extractFormulaFields(formula)
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
...common,
|
...common,
|
||||||
category: 'PIECE',
|
category: 'PIECE',
|
||||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||||
})
|
referenceFormula: formula,
|
||||||
|
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||||
|
} as ModelTypePayload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<section class="space-y-4">
|
||||||
|
<header>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content">Génération de référence automatique</h3>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70">
|
||||||
|
Cliquez sur un champ pour l'insérer dans la formule. Vous pouvez aussi taper du texte libre (séparateurs, préfixes…).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-base-300 p-4 space-y-4">
|
||||||
|
<div v-if="fieldNames.length" class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="name in fieldNames"
|
||||||
|
:key="name"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-xs btn-outline btn-primary font-mono"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="insertField(name)"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-base-content/50 italic">
|
||||||
|
Aucun champ personnalisé défini dans la structure.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label" for="reference-formula">
|
||||||
|
<span class="label-text">Formule</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reference-formula"
|
||||||
|
ref="inputRef"
|
||||||
|
:value="modelValue"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full font-mono"
|
||||||
|
placeholder="Ex: SNU {serie}-{diametre}/{type}"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
Laissez vide si ce type n'utilise pas de référence automatique.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="modelValue" class="rounded bg-base-200 px-3 py-2 text-sm">
|
||||||
|
<span class="text-base-content/70">Aperçu :</span>
|
||||||
|
<span class="ml-1 font-mono font-semibold">{{ preview }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
interface CustomField {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string | null | undefined
|
||||||
|
customFields: CustomField[]
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const fieldNames = computed(() =>
|
||||||
|
props.customFields.map(f => f.name).filter((n): n is string => Boolean(n)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const previewExamples: Record<string, string> = {
|
||||||
|
text: 'VALEUR',
|
||||||
|
number: '123',
|
||||||
|
select: 'OPTION',
|
||||||
|
boolean: 'OUI',
|
||||||
|
date: '2026-01-01',
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = computed(() => {
|
||||||
|
if (!props.modelValue) return ''
|
||||||
|
const fieldMap = new Map<string, string>()
|
||||||
|
for (const f of props.customFields) {
|
||||||
|
if (f.name) {
|
||||||
|
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
|
||||||
|
})
|
||||||
|
|
||||||
|
const insertField = (fieldName: string) => {
|
||||||
|
const placeholder = `{${fieldName}}`
|
||||||
|
const input = inputRef.value
|
||||||
|
const current = props.modelValue ?? ''
|
||||||
|
if (!input) {
|
||||||
|
emit('update:modelValue', current + placeholder)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = input.selectionStart ?? current.length
|
||||||
|
const end = input.selectionEnd ?? start
|
||||||
|
const updated = current.slice(0, start) + placeholder + current.slice(end)
|
||||||
|
emit('update:modelValue', updated)
|
||||||
|
nextTick(() => {
|
||||||
|
const newPos = start + placeholder.length
|
||||||
|
input.focus()
|
||||||
|
input.setSelectionRange(newPos, newPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</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>
|
||||||
@@ -48,10 +48,19 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit && showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -78,10 +87,19 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit && showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -118,6 +136,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import IconLucideInbox from '~icons/lucide/inbox';
|
import IconLucideInbox from '~icons/lucide/inbox';
|
||||||
|
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -126,15 +145,22 @@ const props = defineProps<{
|
|||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
category?: ModelCategory;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'related', item: ModelType): void;
|
(e: 'related', item: ModelType): void;
|
||||||
(e: 'edit', item: ModelType): void;
|
(e: 'edit', item: ModelType): void;
|
||||||
(e: 'delete', item: ModelType): void;
|
(e: 'delete', item: ModelType): void;
|
||||||
|
(e: 'convert', item: ModelType): void;
|
||||||
(e: 'update:offset', offset: number): void;
|
(e: 'update:offset', offset: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showConvertButton = computed(() =>
|
||||||
|
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||||
|
);
|
||||||
|
|
||||||
const categoryDictionary: Record<ModelCategory, string> = {
|
const categoryDictionary: Record<ModelCategory, string> = {
|
||||||
COMPONENT: 'Composants',
|
COMPONENT: 'Composants',
|
||||||
PIECE: 'Pièces',
|
PIECE: 'Pièces',
|
||||||
|
|||||||
@@ -83,13 +83,14 @@ import type { ModelCategory } from '~/services/modelTypes';
|
|||||||
type SortField = 'name' | 'createdAt';
|
type SortField = 'name' | 'createdAt';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
category: ModelCategory;
|
category: ModelCategory;
|
||||||
search: string;
|
search: string;
|
||||||
sort: SortField;
|
sort: SortField;
|
||||||
dir: SortDirection;
|
dir: SortDirection;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
showCategoryTabs?: boolean;
|
showCategoryTabs?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -37,9 +47,9 @@
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
||||||
Modifier
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error" @click="emit('delete', site)">
|
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +65,8 @@ import IconLucidePhone from '~icons/lucide/phone'
|
|||||||
import IconLucideUser from '~icons/lucide/user'
|
import IconLucideUser from '~icons/lucide/user'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
site: {
|
site: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom et prénom"
|
placeholder="Nom et prénom"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldPhone v-model="contactPhone" required />
|
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Adresse complète"
|
placeholder="Adresse complète"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Code postal"
|
placeholder="Code postal"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ville"
|
placeholder="Ville"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +81,10 @@ const props = defineProps({
|
|||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = toRef(props, 'form')
|
const form = toRef(props, 'form')
|
||||||
|
|||||||
@@ -12,17 +12,58 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Usine principale"
|
placeholder="Ex: Usine principale"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="siteRef" />
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Couleur</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="siteRef.color" class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
:value="siteRef.color"
|
||||||
|
type="color"
|
||||||
|
class="w-10 h-10 rounded cursor-pointer border border-base-300"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="(e: Event) => { siteRef.color = (e.target as HTMLInputElement).value }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="siteRef.color"
|
||||||
|
type="text"
|
||||||
|
placeholder="#000000"
|
||||||
|
class="input input-bordered input-sm flex-1"
|
||||||
|
:disabled="disabled"
|
||||||
|
maxlength="7"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="siteRef.color = ''"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm w-fit"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="siteRef.color = '#3b82f6'"
|
||||||
|
>
|
||||||
|
Choisir une couleur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||||
Créer le site
|
Créer le site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,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
|
||||||
@@ -53,6 +95,10 @@ const props = defineProps({
|
|||||||
site: {
|
site: {
|
||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<div v-if="visible" class="modal modal-open">
|
<div v-if="visible" class="modal modal-open">
|
||||||
<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">
|
||||||
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">
|
||||||
@@ -15,11 +15,52 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom du site"
|
placeholder="Nom du site"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="props.form" />
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Couleur</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="form.color" class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
:value="form.color"
|
||||||
|
type="color"
|
||||||
|
class="w-10 h-10 rounded cursor-pointer border border-base-300"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="form.color = $event.target.value"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.color"
|
||||||
|
type="text"
|
||||||
|
placeholder="#000000"
|
||||||
|
class="input input-bordered input-sm flex-1"
|
||||||
|
:disabled="disabled"
|
||||||
|
maxlength="7"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="form.color = ''"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm w-fit"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="form.color = '#3b82f6'"
|
||||||
|
>
|
||||||
|
Choisir une couleur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -27,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>
|
||||||
@@ -37,6 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
|
v-if="!disabled"
|
||||||
v-model="selectedFilesModel"
|
v-model="selectedFilesModel"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
||||||
@@ -55,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}`"
|
||||||
>
|
>
|
||||||
@@ -72,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>
|
||||||
@@ -90,7 +132,7 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
<button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +145,7 @@
|
|||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments">
|
<button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
|
||||||
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -114,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'
|
||||||
@@ -155,6 +197,10 @@ const props = defineProps({
|
|||||||
formatSize: {
|
formatSize: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,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)
|
||||||
|
|||||||
80
app/composables/useAdminProfiles.ts
Normal file
80
app/composables/useAdminProfiles.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
|
||||||
|
export interface AdminProfile {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string | null
|
||||||
|
isActive: boolean
|
||||||
|
hasPassword: boolean
|
||||||
|
roles: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminProfiles() {
|
||||||
|
const { get, post, put } = useApi()
|
||||||
|
const profiles = ref<AdminProfile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await get<AdminProfile[]>('/admin/profiles')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
profiles.value = result.data
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProfile = async (data: {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
role?: string
|
||||||
|
}) => {
|
||||||
|
const result = await post<AdminProfile>('/admin/profiles', data)
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRole = async (id: string, role: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPassword = async (id: string, password: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateProfile = async (id: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles,
|
||||||
|
loading,
|
||||||
|
fetchAll,
|
||||||
|
createProfile,
|
||||||
|
updateRole,
|
||||||
|
setPassword,
|
||||||
|
deactivateProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,21 +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 = (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
const rawMessage = response.status === 403
|
||||||
|
? 'Permissions insuffisantes pour cette action.'
|
||||||
|
: 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,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' })
|
||||||
}
|
}
|
||||||
@@ -121,6 +133,7 @@ export function useApi() {
|
|||||||
apiCall,
|
apiCall,
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
|
postFormData,
|
||||||
patch,
|
patch,
|
||||||
put,
|
put,
|
||||||
delete: del,
|
delete: del,
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useApi } from '~/composables/useApi'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
|
|
||||||
type GuardLabels = {
|
|
||||||
singular: string
|
|
||||||
plural: string
|
|
||||||
verifying: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type GuardConfig = {
|
|
||||||
endpoint: string
|
|
||||||
filterKey: string
|
|
||||||
labels: GuardLabels
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractTotal = (payload: any, fallbackLength: number) => {
|
|
||||||
if (typeof payload?.totalItems === 'number') {
|
|
||||||
return payload.totalItems
|
|
||||||
}
|
|
||||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
|
||||||
return payload['hydra:totalItems']
|
|
||||||
}
|
|
||||||
if (Array.isArray(payload?.member)) {
|
|
||||||
return payload.member.length
|
|
||||||
}
|
|
||||||
if (Array.isArray(payload?.['hydra:member'])) {
|
|
||||||
return payload['hydra:member'].length
|
|
||||||
}
|
|
||||||
return fallbackLength
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCategoryEditGuard (config: GuardConfig) {
|
|
||||||
const { get } = useApi()
|
|
||||||
const { showInfo } = useToast()
|
|
||||||
|
|
||||||
const linkedCount = ref(0)
|
|
||||||
const linkedLoading = ref(false)
|
|
||||||
|
|
||||||
const loadLinkedCount = async (modelTypeId: string) => {
|
|
||||||
linkedLoading.value = true
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('itemsPerPage', '1')
|
|
||||||
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
|
|
||||||
|
|
||||||
const result = await get(`${config.endpoint}?${params.toString()}`)
|
|
||||||
if (!result.success) {
|
|
||||||
linkedCount.value = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackLength = Array.isArray(result.data?.member)
|
|
||||||
? result.data.member.length
|
|
||||||
: Array.isArray(result.data?.['hydra:member'])
|
|
||||||
? result.data['hydra:member'].length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
linkedCount.value = extractTotal(result.data, fallbackLength)
|
|
||||||
} catch (_error) {
|
|
||||||
linkedCount.value = 0
|
|
||||||
} finally {
|
|
||||||
linkedLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRestrictedMode = computed(
|
|
||||||
() => !linkedLoading.value && linkedCount.value > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
const isSubmitBlocked = computed(
|
|
||||||
() => linkedLoading.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
const restrictedModeMessage = computed(() => {
|
|
||||||
if (linkedLoading.value) {
|
|
||||||
return config.labels.verifying
|
|
||||||
}
|
|
||||||
if (linkedCount.value <= 0) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (linkedCount.value === 1) {
|
|
||||||
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
|
|
||||||
}
|
|
||||||
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitBlockMessage = computed(() => {
|
|
||||||
if (linkedLoading.value) {
|
|
||||||
return config.labels.verifying
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const guardSubmitOrNotify = () => {
|
|
||||||
if (!isSubmitBlocked.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
linkedCount,
|
|
||||||
linkedLoading,
|
|
||||||
isRestrictedMode,
|
|
||||||
isSubmitBlocked,
|
|
||||||
restrictedModeMessage,
|
|
||||||
submitBlockMessage,
|
|
||||||
loadLinkedCount,
|
|
||||||
guardSubmitOrNotify,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
207
app/composables/useComments.ts
Normal file
207
app/composables/useComments.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
|
export interface CommentDocument {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
fileUrl: string
|
||||||
|
downloadUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string | null
|
||||||
|
authorId: string
|
||||||
|
authorName: string
|
||||||
|
status: 'open' | 'resolved'
|
||||||
|
resolvedById?: string | null
|
||||||
|
resolvedByName?: string | null
|
||||||
|
resolvedAt?: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
documents?: CommentDocument[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment | Comment[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentListResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment[]
|
||||||
|
total?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComments() {
|
||||||
|
const { get, post, patch, postFormData, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchComments = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
status: string = 'open',
|
||||||
|
): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await get<Comment[]>(`/comments/by-entity/${entityType}/${entityId}?status=${status}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = (result.data ?? []) as Comment[]
|
||||||
|
return { success: true, data: items }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllComments = async (options: {
|
||||||
|
status?: string
|
||||||
|
entityType?: string
|
||||||
|
entityName?: string
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
orderBy?: string
|
||||||
|
orderDir?: string
|
||||||
|
} = {}): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options.status) params.set('status', options.status)
|
||||||
|
if (options.entityType) params.set('entityType', options.entityType)
|
||||||
|
if (options.entityName) params.set('entityName', options.entityName)
|
||||||
|
params.set('sort', options.orderBy || 'createdAt')
|
||||||
|
params.set('direction', options.orderDir || 'desc')
|
||||||
|
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||||
|
params.set('page', String(options.page || 1))
|
||||||
|
|
||||||
|
const result = await get<{ items: Comment[]; total: number }>(`/comments/search/list?${params.toString()}`)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const data = result.data as { items: Comment[]; total: number }
|
||||||
|
return { success: true, data: data.items, total: data.total }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createComment = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
content: string,
|
||||||
|
entityName?: string,
|
||||||
|
files?: File[],
|
||||||
|
): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('content', content)
|
||||||
|
formData.append('entityType', entityType)
|
||||||
|
formData.append('entityId', entityId)
|
||||||
|
if (entityName) formData.append('entityName', entityName)
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files[]', file)
|
||||||
|
}
|
||||||
|
result = await postFormData('/comments', formData)
|
||||||
|
} else {
|
||||||
|
const payload: Record<string, string> = { entityType, entityId, content }
|
||||||
|
if (entityName) payload.entityName = entityName
|
||||||
|
result = await post('/comments', payload)
|
||||||
|
}
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire ajouté')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible d\'ajouter le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/comments/${commentId}/resolve`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire résolu')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de résoudre le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/comments/${commentId}`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire supprimé')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de supprimer le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUnresolvedCount = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data.count
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
fetchAllComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
fetchUnresolvedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
426
app/composables/useComponentCreate.ts
Normal file
426
app/composables/useComponentCreate.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
|
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||||
|
import type { ConstructeurLinkEntry } 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 { syncLinks } = useConstructeurLinks()
|
||||||
|
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 constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||||
|
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||||
|
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(
|
||||||
|
() => !submitting.value && (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
|
||||||
|
}
|
||||||
|
|
||||||
|
// constructeurIds are handled via link entities, not in the main payload
|
||||||
|
|
||||||
|
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?.structure?.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 = []
|
||||||
|
}
|
||||||
|
// Sync constructeur links after creation
|
||||||
|
if (constructeurLinks.value.length) {
|
||||||
|
await syncLinks('composant', createdComponent.id, [], constructeurLinks.value)
|
||||||
|
}
|
||||||
|
toast.showSuccess('Composant créé avec succès')
|
||||||
|
await router.replace(`/component/${createdComponent.id}?edit=true`)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
constructeurLinks,
|
||||||
|
constructeurIdsFromForm,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
597
app/composables/useComponentEdit.ts
Normal file
597
app/composables/useComponentEdit.ts
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from '#imports'
|
||||||
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
|
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||||
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
|
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||||
|
import type { ConstructeurLinkEntry } 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, composants: componentCatalogRef } = useComposants()
|
||||||
|
const { pieces } = usePieces()
|
||||||
|
const { products } = useProducts()
|
||||||
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||||
|
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 constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||||
|
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||||
|
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Slot local edits (saved on submit, not auto-saved) ---
|
||||||
|
|
||||||
|
const slotEdits = reactive<{
|
||||||
|
pieces: Record<string, { selectedPieceId?: string | null, quantity?: number }>
|
||||||
|
products: Record<string, { selectedProductId?: string | null }>
|
||||||
|
subcomponents: Record<string, { selectedComposantId?: string | null }>
|
||||||
|
}>({ pieces: {}, products: {}, subcomponents: {} })
|
||||||
|
|
||||||
|
const pieceSlotEntries = computed(() => {
|
||||||
|
const structure = component.value?.structure
|
||||||
|
if (!structure?.pieces) return []
|
||||||
|
return (structure.pieces as any[]).map((slot: any, i: number) => {
|
||||||
|
const edits = slotEdits.pieces[slot.slotId]
|
||||||
|
return {
|
||||||
|
slotId: slot.slotId,
|
||||||
|
typePieceId: slot.typePieceId,
|
||||||
|
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
|
||||||
|
selectedPieceName: slot.selectedPieceName ?? null,
|
||||||
|
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
|
||||||
|
position: slot.position ?? i,
|
||||||
|
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const productSlotEntries = computed(() => {
|
||||||
|
const structure = component.value?.structure
|
||||||
|
if (!structure?.products) return []
|
||||||
|
return (structure.products as any[]).map((slot: any, i: number) => {
|
||||||
|
const edits = slotEdits.products[slot.slotId]
|
||||||
|
return {
|
||||||
|
slotId: slot.slotId,
|
||||||
|
typeProductId: slot.typeProductId,
|
||||||
|
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
|
||||||
|
selectedProductName: slot.selectedProductName ?? null,
|
||||||
|
familyCode: slot.familyCode,
|
||||||
|
position: slot.position ?? i,
|
||||||
|
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const subcomponentSlotEntries = computed(() => {
|
||||||
|
const structure = component.value?.structure
|
||||||
|
if (!structure?.subcomponents) return []
|
||||||
|
return (structure.subcomponents as any[]).map((slot: any, i: number) => {
|
||||||
|
const edits = slotEdits.subcomponents[slot.slotId]
|
||||||
|
return {
|
||||||
|
slotId: slot.slotId,
|
||||||
|
typeComposantId: slot.typeComposantId,
|
||||||
|
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
|
||||||
|
selectedComponentName: slot.selectedComponentName ?? null,
|
||||||
|
alias: slot.alias,
|
||||||
|
familyCode: slot.familyCode,
|
||||||
|
position: slot.position ?? i,
|
||||||
|
label: slot.alias || `Sous-composant #${i + 1}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const setPieceSlotSelection = (slotId: string, selectedPieceId: string | null) => {
|
||||||
|
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], selectedPieceId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProductSlotSelection = (slotId: string, selectedProductId: string | null) => {
|
||||||
|
slotEdits.products[slotId] = { ...slotEdits.products[slotId], selectedProductId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSubcomponentSlotSelection = (slotId: string, selectedComposantId: string | null) => {
|
||||||
|
slotEdits.subcomponents[slotId] = { ...slotEdits.subcomponents[slotId], selectedComposantId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSlotQuantity = (slotId: string, quantity: number) => {
|
||||||
|
if (!slotId || quantity < 1) return
|
||||||
|
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], quantity: Math.max(1, quantity) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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?.structure?.customFields,
|
||||||
|
],
|
||||||
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save slot edits
|
||||||
|
const slotPromises: Promise<any>[] = []
|
||||||
|
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
||||||
|
if (Object.keys(edits).length) {
|
||||||
|
slotPromises.push(patch(`/composant-piece-slots/${slotId}`, {
|
||||||
|
...'selectedPieceId' in edits ? { selectedPieceId: edits.selectedPieceId } : {},
|
||||||
|
...'quantity' in edits ? { quantity: edits.quantity } : {},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
||||||
|
if ('selectedProductId' in edits) {
|
||||||
|
slotPromises.push(patch(`/composant-product-slots/${slotId}`, { selectedProductId: edits.selectedProductId }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
||||||
|
if ('selectedComposantId' in edits) {
|
||||||
|
slotPromises.push(patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId: edits.selectedComposantId }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(slotPromises)
|
||||||
|
|
||||||
|
// Apply slot edits to local structure so UI reflects saved values
|
||||||
|
const structure = component.value?.structure
|
||||||
|
if (structure) {
|
||||||
|
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
||||||
|
const slot = (structure.pieces as any[])?.find((s: any) => s.slotId === slotId)
|
||||||
|
if (slot) {
|
||||||
|
if ('selectedPieceId' in edits) slot.selectedPieceId = edits.selectedPieceId
|
||||||
|
if ('quantity' in edits) slot.quantity = edits.quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
||||||
|
const slot = (structure.products as any[])?.find((s: any) => s.slotId === slotId)
|
||||||
|
if (slot && 'selectedProductId' in edits) slot.selectedProductId = edits.selectedProductId
|
||||||
|
}
|
||||||
|
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
||||||
|
const slot = (structure.subcomponents as any[])?.find((s: any) => s.slotId === slotId)
|
||||||
|
if (slot && 'selectedComposantId' in edits) slot.selectedComponentId = edits.selectedComposantId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset local slot edits
|
||||||
|
slotEdits.pieces = {}
|
||||||
|
slotEdits.products = {}
|
||||||
|
slotEdits.subcomponents = {}
|
||||||
|
|
||||||
|
await syncLinks('composant', component.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||||
|
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||||
|
|
||||||
|
toast.showSuccess('Composant mis à jour avec succès.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 || ''
|
||||||
|
// Load constructeur links
|
||||||
|
fetchLinks('composant', componentId).then((links) => {
|
||||||
|
constructeurLinks.value = links
|
||||||
|
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||||
|
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||||
|
if (editionForm.constructeurIds.length) {
|
||||||
|
void ensureConstructeurs(editionForm.constructeurIds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
component,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
selectedFiles,
|
||||||
|
uploadingDocuments,
|
||||||
|
loadingDocuments,
|
||||||
|
componentDocuments,
|
||||||
|
previewDocument,
|
||||||
|
previewVisible,
|
||||||
|
selectedTypeId,
|
||||||
|
editionForm,
|
||||||
|
constructeurLinks,
|
||||||
|
originalConstructeurLinks,
|
||||||
|
constructeurIdsFromForm,
|
||||||
|
customFieldInputs,
|
||||||
|
historyFieldLabels,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
canEdit,
|
||||||
|
canSubmit,
|
||||||
|
componentTypeList,
|
||||||
|
selectedType,
|
||||||
|
selectedTypeStructure,
|
||||||
|
structureSelections,
|
||||||
|
pieceSlotEntries,
|
||||||
|
productSlotEntries,
|
||||||
|
subcomponentSlotEntries,
|
||||||
|
|
||||||
|
// History
|
||||||
|
history,
|
||||||
|
historyLoading,
|
||||||
|
historyError,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
openPreview,
|
||||||
|
closePreview,
|
||||||
|
removeDocument,
|
||||||
|
handleFilesAdded,
|
||||||
|
refreshDocuments,
|
||||||
|
submitEdition,
|
||||||
|
fetchComponent,
|
||||||
|
setSlotQuantity,
|
||||||
|
setPieceSlotSelection,
|
||||||
|
setProductSlotSelection,
|
||||||
|
setSubcomponentSlotSelection,
|
||||||
|
resolvePieceLabel,
|
||||||
|
resolveProductLabel,
|
||||||
|
resolveSubcomponentLabel,
|
||||||
|
formatStructurePreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +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 { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { 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'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
@@ -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,8 @@ interface LoadComposantsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
typeName?: string
|
||||||
|
typeComposantId?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,17 +109,19 @@ export function useComposants() {
|
|||||||
itemsPerPage = 30,
|
itemsPerPage = 30,
|
||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
|
typeName,
|
||||||
|
typeComposantId,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1) {
|
if (!force && loaded.value && !search && !typeName && !typeComposantId && 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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading.value) {
|
if (!typeComposantId && loading.value) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -125,13 +130,20 @@ export function useComposants() {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
params.set('page', String(page))
|
params.set('page', String(page))
|
||||||
|
|
||||||
if (search && search.trim()) {
|
if (search && search.trim()) {
|
||||||
params.set('name', search.trim())
|
params.set('search', search.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName && typeName.trim()) {
|
||||||
|
params.set('typeComposant.name', typeName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeComposantId) {
|
||||||
|
params.set('typeComposant', typeComposantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
@@ -140,14 +152,19 @@ export function useComposants() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const items = extractCollection(result.data)
|
const items = extractCollection(result.data)
|
||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
composants.value = enrichedItems
|
const resultTotal = extractTotal(result.data, items.length)
|
||||||
total.value = extractTotal(result.data, items.length)
|
|
||||||
loaded.value = true
|
if (!typeComposantId) {
|
||||||
|
composants.value = enrichedItems
|
||||||
|
total.value = resultTotal
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: enrichedItems,
|
items: enrichedItems,
|
||||||
total: total.value,
|
total: resultTotal,
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
},
|
},
|
||||||
@@ -165,7 +182,8 @@ export function useComposants() {
|
|||||||
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
|
||||||
|
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||||
const result = await post('/composants', normalizedPayload)
|
const result = await post('/composants', normalizedPayload)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const enriched = await withResolvedConstructeurs(result.data as Composant)
|
const enriched = await withResolvedConstructeurs(result.data as Composant)
|
||||||
@@ -192,7 +210,8 @@ export function useComposants() {
|
|||||||
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
|
||||||
|
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||||
const result = await patch(`/composants/${id}`, normalizedPayload)
|
const result = await patch(`/composants/${id}`, normalizedPayload)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const updated = await withResolvedConstructeurs(result.data as Composant)
|
const updated = await withResolvedConstructeurs(result.data as Composant)
|
||||||
|
|||||||
103
app/composables/useConstructeurLinks.ts
Normal file
103
app/composables/useConstructeurLinks.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
|
type EntityType = 'machine' | 'piece' | 'composant' | 'product'
|
||||||
|
|
||||||
|
const ENDPOINTS: Record<EntityType, string> = {
|
||||||
|
machine: '/machine_constructeur_links',
|
||||||
|
piece: '/piece_constructeur_links',
|
||||||
|
composant: '/composant_constructeur_links',
|
||||||
|
product: '/product_constructeur_links',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_KEYS: Record<EntityType, string> = {
|
||||||
|
machine: 'machine',
|
||||||
|
piece: 'piece',
|
||||||
|
composant: 'composant',
|
||||||
|
product: 'product',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_PLURALS: Record<EntityType, string> = {
|
||||||
|
machine: 'machines',
|
||||||
|
piece: 'pieces',
|
||||||
|
composant: 'composants',
|
||||||
|
product: 'products',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConstructeurLinks() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
|
||||||
|
const fetchLinks = async (
|
||||||
|
entityType: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
): Promise<ConstructeurLinkEntry[]> => {
|
||||||
|
const endpoint = ENDPOINTS[entityType]
|
||||||
|
const key = ENTITY_KEYS[entityType]
|
||||||
|
const plural = ENTITY_PLURALS[entityType]
|
||||||
|
const url = `${endpoint}?${key}=/api/${plural}/${entityId}`
|
||||||
|
const result = await get(url)
|
||||||
|
if (!result.success || !result.data) return []
|
||||||
|
|
||||||
|
const members = extractCollection(result.data)
|
||||||
|
if (!Array.isArray(members)) return []
|
||||||
|
|
||||||
|
return members.map((link: any) => ({
|
||||||
|
linkId: link.id ?? (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : undefined),
|
||||||
|
constructeurId: typeof link.constructeur === 'string'
|
||||||
|
? link.constructeur.split('/').pop()!
|
||||||
|
: link.constructeur?.id ?? '',
|
||||||
|
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
|
||||||
|
supplierReference: link.supplierReference ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncLinks = async (
|
||||||
|
entityType: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
originalLinks: ConstructeurLinkEntry[],
|
||||||
|
formLinks: ConstructeurLinkEntry[],
|
||||||
|
): Promise<void> => {
|
||||||
|
const endpoint = ENDPOINTS[entityType]
|
||||||
|
const key = ENTITY_KEYS[entityType]
|
||||||
|
const plural = ENTITY_PLURALS[entityType]
|
||||||
|
const entityIri = `/api/${plural}/${entityId}`
|
||||||
|
|
||||||
|
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
|
||||||
|
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = []
|
||||||
|
|
||||||
|
// Delete removed links
|
||||||
|
for (const [cId, orig] of originalMap) {
|
||||||
|
if (!formMap.has(cId) && orig.linkId) {
|
||||||
|
promises.push(del(`${endpoint}/${orig.linkId}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new links
|
||||||
|
for (const [cId, form] of formMap) {
|
||||||
|
if (!originalMap.has(cId)) {
|
||||||
|
promises.push(post(endpoint, {
|
||||||
|
[key]: entityIri,
|
||||||
|
constructeur: `/api/constructeurs/${cId}`,
|
||||||
|
supplierReference: form.supplierReference || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch modified supplierReference
|
||||||
|
for (const [cId, form] of formMap) {
|
||||||
|
const orig = originalMap.get(cId)
|
||||||
|
if (orig?.linkId && (orig.supplierReference ?? null) !== (form.supplierReference ?? null)) {
|
||||||
|
promises.push(patch(`${endpoint}/${orig.linkId}`, {
|
||||||
|
supplierReference: form.supplierReference || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetchLinks, syncLinks }
|
||||||
|
}
|
||||||
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 }
|
||||||
|
}
|
||||||
221
app/composables/useDataTable.ts
Normal file
221
app/composables/useDataTable.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { ref, computed, watch, 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' }>
|
||||||
|
/** Column filter keys to persist in URL (prefixed with `f.` in query string) */
|
||||||
|
columnFilterKeys?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {},
|
||||||
|
columnFilterKeys = [],
|
||||||
|
} = 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>> = {}
|
||||||
|
const columnFilterRefs: Record<string, Ref<string>> = {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of columnFilterKeys) {
|
||||||
|
paramDefs[`f.${key}`] = { default: '', debounce: 300 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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]!
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of columnFilterKeys) {
|
||||||
|
columnFilterRefs[key] = (state as Record<string, Ref<string>>)[`f.${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 — seed from URL-persisted refs
|
||||||
|
const initialColumnFilters: DataTableColumnFilters = {}
|
||||||
|
for (const [key, r] of Object.entries(columnFilterRefs)) {
|
||||||
|
if (r.value) initialColumnFilters[key] = r.value
|
||||||
|
}
|
||||||
|
const columnFilters = ref<DataTableColumnFilters>(initialColumnFilters)
|
||||||
|
|
||||||
|
// Sync columnFilters → URL refs
|
||||||
|
if (persistToUrl && columnFilterKeys.length > 0) {
|
||||||
|
watch(columnFilters, (val) => {
|
||||||
|
for (const key of columnFilterKeys) {
|
||||||
|
columnFilterRefs[key]!.value = val[key] || ''
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Sync URL refs → columnFilters (back/forward navigation)
|
||||||
|
for (const key of columnFilterKeys) {
|
||||||
|
watch(columnFilterRefs[key]!, (urlVal) => {
|
||||||
|
const current = columnFilters.value[key] || ''
|
||||||
|
if (current !== urlVal) {
|
||||||
|
columnFilters.value = { ...columnFilters.value, [key]: urlVal }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,22 @@ export interface Document {
|
|||||||
filename: string
|
filename: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
size: number
|
size: number
|
||||||
path: string
|
fileUrl: string
|
||||||
|
downloadUrl: string
|
||||||
|
type?: 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 {
|
||||||
@@ -24,6 +33,7 @@ export interface UploadContext {
|
|||||||
composantId?: string
|
composantId?: string
|
||||||
productId?: string
|
productId?: string
|
||||||
pieceId?: string
|
pieceId?: string
|
||||||
|
type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentResult {
|
export interface DocumentResult {
|
||||||
@@ -32,28 +42,41 @@ 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
|
||||||
|
type?: 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, patch, postFormData, delete: del } = useApi()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
const loadFromEndpoint = async (
|
const loadFromEndpoint = async (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get(endpoint)
|
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||||
|
const result = await get(url)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = extractCollection(result.data)
|
const data = extractCollection(result.data)
|
||||||
if (updateStore) {
|
if (updateStore) {
|
||||||
@@ -75,10 +98,66 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocuments = async (
|
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
|
||||||
options: { updateStore?: boolean } = {},
|
const {
|
||||||
): Promise<DocumentResult> => {
|
search = '',
|
||||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'createdAt',
|
||||||
|
orderDir = 'desc',
|
||||||
|
attachmentFilter = 'all',
|
||||||
|
type = 'all',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all' && type === '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')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
params.set('type', type)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
@@ -144,18 +223,18 @@ 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)
|
||||||
|
if (context.type) formData.append('type', context.type)
|
||||||
|
|
||||||
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é`)
|
||||||
@@ -210,9 +289,38 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateDocument = async (
|
||||||
|
id: string,
|
||||||
|
data: { name?: string; type?: string },
|
||||||
|
): Promise<DocumentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/documents/${id}`, data)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const updated = result.data as Document
|
||||||
|
const index = documents.value.findIndex((doc) => doc.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
documents.value[index] = { ...documents.value[index], ...updated }
|
||||||
|
}
|
||||||
|
showSuccess('Document mis à jour')
|
||||||
|
return { success: true, data: updated }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return result as DocumentResult
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de mettre à jour le document')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadDocuments,
|
loadDocuments,
|
||||||
loadDocumentsBySite,
|
loadDocumentsBySite,
|
||||||
loadDocumentsByMachine,
|
loadDocumentsByMachine,
|
||||||
@@ -220,6 +328,7 @@ export function useDocuments() {
|
|||||||
loadDocumentsByPiece,
|
loadDocumentsByPiece,
|
||||||
loadDocumentsByProduct,
|
loadDocumentsByProduct,
|
||||||
uploadDocuments,
|
uploadDocuments,
|
||||||
|
updateDocument,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export interface EntityDocumentsDeps {
|
|||||||
|
|
||||||
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||||
const { entity, entityType } = deps
|
const { entity, entityType } = deps
|
||||||
const { uploadDocuments, deleteDocument } = useDocuments()
|
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
|
||||||
|
|
||||||
const loadDocumentsFn = entityType === 'composant'
|
const loadDocumentsFn = entityType === 'composant'
|
||||||
? useDocuments().loadDocumentsByComponent
|
? useDocuments().loadDocumentsByComponent
|
||||||
@@ -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 })
|
||||||
@@ -104,6 +104,19 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
|
||||||
|
const result: any = await updateDocument(id, data)
|
||||||
|
if (result.success) {
|
||||||
|
const e = entity()
|
||||||
|
const docs = e.documents || []
|
||||||
|
const index = docs.findIndex((doc: any) => doc.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
docs[index] = { ...docs[index], ...data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
@@ -118,5 +131,6 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
ensureDocumentsLoaded,
|
ensureDocumentsLoaded,
|
||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
|
editDocument,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type EntityHistoryEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ENTITY_ENDPOINTS: Record<string, string> = {
|
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||||
|
machine: '/machines',
|
||||||
composant: '/composants',
|
composant: '/composants',
|
||||||
piece: '/pieces',
|
piece: '/pieces',
|
||||||
product: '/products',
|
product: '/products',
|
||||||
@@ -35,7 +36,7 @@ const extractItems = (payload: any): EntityHistoryEntry[] => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEntityHistory(entityType: 'composant' | 'piece' | 'product') {
|
export function useEntityHistory(entityType: 'machine' | 'composant' | 'piece' | 'product') {
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const basePath = ENTITY_ENDPOINTS[entityType]
|
const basePath = ENTITY_ENDPOINTS[entityType]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -126,9 +127,10 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||||
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?: { error?: string; message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.error || 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
|
||||||
@@ -151,9 +153,10 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||||
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?: { error?: string; message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.error || 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
|
||||||
@@ -168,9 +171,10 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
showSuccess(`Type de ${label} supprimé`)
|
showSuccess(`Type de ${label} supprimé`)
|
||||||
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?: { error?: string; message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.error || 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
|
||||||
|
|||||||
98
app/composables/useEntityVersions.ts
Normal file
98
app/composables/useEntityVersions.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { ref, toValue } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import type { MaybeRef } from 'vue'
|
||||||
|
|
||||||
|
export interface VersionEntry {
|
||||||
|
version: number
|
||||||
|
action: 'create' | 'update' | 'restore' | string
|
||||||
|
createdAt: string
|
||||||
|
actor: { id: string; label: string } | null
|
||||||
|
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestorePreview {
|
||||||
|
version: number
|
||||||
|
restoreMode: 'full' | 'partial'
|
||||||
|
diff: Record<string, { current: unknown; restored: unknown }>
|
||||||
|
warnings: Array<{
|
||||||
|
field: string
|
||||||
|
message: string
|
||||||
|
missingEntityId: string | null
|
||||||
|
missingEntityName: string | null
|
||||||
|
}>
|
||||||
|
snapshot: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreResult {
|
||||||
|
success: boolean
|
||||||
|
newVersion: number
|
||||||
|
restoredFromVersion: number
|
||||||
|
restoreMode: 'full' | 'partial'
|
||||||
|
warnings: RestorePreview['warnings']
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||||
|
machine: '/machines',
|
||||||
|
composant: '/composants',
|
||||||
|
piece: '/pieces',
|
||||||
|
product: '/products',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
entityType: MaybeRef<string>
|
||||||
|
entityId: MaybeRef<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntityVersions(deps: Deps) {
|
||||||
|
const { get, post } = useApi()
|
||||||
|
|
||||||
|
const versions = ref<VersionEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const getPath = () => {
|
||||||
|
const type = toValue(deps.entityType)
|
||||||
|
const id = toValue(deps.entityId)
|
||||||
|
const base = ENTITY_ENDPOINTS[type]
|
||||||
|
return `${base}/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchVersions = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await get(`${getPath()}/versions`)
|
||||||
|
if (!result.success) {
|
||||||
|
error.value = result.error ?? 'Impossible de charger les versions.'
|
||||||
|
versions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
versions.value = result.data?.items ?? []
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
error.value = err?.message ?? 'Erreur inconnue'
|
||||||
|
versions.value = []
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
|
||||||
|
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const restore = async (version: number): Promise<RestoreResult | null> => {
|
||||||
|
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
return { versions, loading, error, fetchVersions, fetchPreview, restore }
|
||||||
|
}
|
||||||
@@ -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 } = 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,348 +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) {
|
||||||
if (hasRequirements && result.data?.id) {
|
const machineId = result.data?.id
|
||||||
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
|
|| (result.data?.machine as any)?.id
|
||||||
componentLinks,
|
|| null
|
||||||
pieceLinks,
|
|
||||||
productLinks,
|
|
||||||
} as any)
|
|
||||||
if (!skeletonResult.success) {
|
|
||||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(),
|
loadSites(),
|
||||||
loadPieces(),
|
loadMachines(),
|
||||||
loadProducts(),
|
])
|
||||||
])
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -398,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
449
app/composables/useMachineDetailCustomFields.ts
Normal file
449
app/composables/useMachineDetailCustomFields.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAllMachineCustomFields = async () => {
|
||||||
|
if (!machine.value) return
|
||||||
|
|
||||||
|
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||||
|
const fieldsToSave = fields.filter(
|
||||||
|
(field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const field of fieldsToSave) {
|
||||||
|
const { id: customFieldId, customFieldValueId } = field
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (customFieldValueId) {
|
||||||
|
await updateCustomFieldValueApi(customFieldValueId as string, {
|
||||||
|
value: field.value ?? '',
|
||||||
|
} as any)
|
||||||
|
} else if (customFieldId) {
|
||||||
|
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
|
||||||
|
if (createdValue?.id) {
|
||||||
|
field.customFieldValueId = createdValue.id
|
||||||
|
if (!createdValue.customField) {
|
||||||
|
createdValue.customField = {
|
||||||
|
id: customFieldId,
|
||||||
|
name: field.name,
|
||||||
|
type: field.type,
|
||||||
|
required: field.required,
|
||||||
|
options: field.options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingValues = Array.isArray(machine.value.customFieldValues)
|
||||||
|
? (machine.value.customFieldValues as AnyRecord[]).filter(
|
||||||
|
(item) => item.id !== createdValue.id,
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
machine.value.customFieldValues = [...existingValues, createdValue]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
machineCustomFields,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
visibleMachineCustomFields,
|
||||||
|
|
||||||
|
// Transform functions
|
||||||
|
transformCustomFields,
|
||||||
|
transformComponentCustomFields,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
syncMachineCustomFields,
|
||||||
|
setMachineCustomFieldValue,
|
||||||
|
updateMachineCustomField,
|
||||||
|
updatePieceCustomField,
|
||||||
|
saveAllMachineCustomFields,
|
||||||
|
}
|
||||||
|
}
|
||||||
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