refactor : merge Inventory_frontend submodule into frontend/ directory

Merges the full git history of Inventory_frontend into the monorepo
under frontend/. Removes the submodule in favor of a unified repo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-01 14:17:57 +02:00
226 changed files with 56920 additions and 4 deletions

4
.gitignore vendored
View File

@@ -34,10 +34,6 @@ FEATURE_IDEAS.md
bin/.phpunit.result.cache
###< temp files ###
###> frontend ###
/frontend/
###< frontend ###
###> ide ###
/.idea/
###< ide ###

29
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# Playwright
e2e/.auth/
playwright-report/
test-results/

155
frontend/README.md Normal file
View File

@@ -0,0 +1,155 @@
# Inventory Frontend
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.
## Stack technique
| Technologie | Version | Rôle |
|-------------|---------|------|
| [Nuxt](https://nuxt.com) | 4 | Framework (SPA, SSR désactivé) |
| [Vue 3](https://vuejs.org) | 3.5 | Composition API + `<script setup>` |
| [TypeScript](https://www.typescriptlang.org) | 5.7 | Typage strict sur l'ensemble du projet |
| [TailwindCSS](https://tailwindcss.com) | 4 | Utility-first CSS |
| [DaisyUI](https://daisyui.com) | 5 | Composants UI (alertes, modales, badges, etc.) |
| [Lucide](https://lucide.dev) | via unplugin-icons | Icônes SVG |
| [Vitest](https://vitest.dev) | 4 | Tests unitaires |
| [Playwright](https://playwright.dev) | 1.58 | Tests E2E |
## Prérequis
- **Node.js** >= 20
- **npm**
- **Backend Symfony** démarré avec l'API sur `http://localhost:8081/api`
## Installation
```bash
npm install
```
## Développement
```bash
npm run dev
```
L'application est accessible sur **http://localhost:3001**.
## Commandes disponibles
| Commande | Description |
|----------|-------------|
| `npm run dev` | Serveur de développement avec HMR |
| `npm run build` | Build de production |
| `npm run lint:fix` | Correction automatique ESLint |
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
| `npm run test` | Tests unitaires Vitest |
| `npm run test:watch` | Tests unitaires en mode watch |
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
## Fonctionnalités
### Gestion du parc
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
- **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
- **Sites** : gestion multi-sites avec coordonnées de contact
### Catalogues
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
### Documents et traçabilité
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
### Administration
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
- **Profils** : gestion des utilisateurs et attribution des rôles
- **Notifications** : badge compteur de commentaires ouverts avec polling
## Architecture
```
app/
├── pages/ # 36 pages (file-based routing)
├── components/ # 57 composants Vue (auto-imported par Nuxt)
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
│ ├── form/ # Champs de formulaire (email, téléphone)
│ ├── layout/ # Navbar principale
│ ├── machine/ # Vue détail et création de machines
│ │ └── create/ # Wizard de création machine
│ ├── model-types/ # Gestion des types/catégories
│ └── sites/ # Modales site (création, édition)
├── composables/ # 45 composables (logique métier)
├── shared/ # Types, utilitaires, validation
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
│ ├── validation/ # Validation email, téléphone
│ └── model/ # Définitions de structures
├── services/ # Service layer (wrappers API spécialisés)
├── middleware/ # Middleware d'auth global (session cookie)
└── utils/ # Formatage dates, montants, événements
```
## Conventions de code
### Composables
Pattern avec injection de dépendances explicite :
```typescript
interface Deps {
machineId: Ref<string>
onSave: () => void
}
export function useMachineDetail(deps: Deps) {
// ...
}
```
### 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

63
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,63 @@
<template>
<div class="min-h-screen flex flex-col bg-base-200/40">
<!-- Subtle dot pattern background -->
<div class="fixed inset-0 -z-10 bg-[radial-gradient(oklch(85%_0.02_260)_1px,transparent_1px)] bg-[size:24px_24px] opacity-40" />
<AppNavbar
@open-settings="displaySettingsOpen = true"
@logout="handleLogout"
/>
<main class="flex-1">
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
</main>
<ToastContainer />
<ConfirmModal />
<DisplaySettings
:is-open="displaySettingsOpen"
@close="displaySettingsOpen = false"
@update-settings="handleSettingsUpdate"
/>
<footer class="border-t border-base-300/50 bg-base-100/60 backdrop-blur-sm">
<div class="container mx-auto flex items-center justify-between px-6 py-3">
<p class="text-xs text-base-content/40 font-medium tracking-wide">
&copy; Malio {{ new Date().getFullYear() }}
</p>
<NuxtLink
to="/changelog"
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
>
v{{ appVersion }}
</NuxtLink>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { navigateTo, useRuntimeConfig } from '#imports'
import { useProfileSession } from '~/composables/useProfileSession'
const displaySettingsOpen = ref(false)
const { ensureSession, logout } = useProfileSession()
const runtimeConfig = useRuntimeConfig()
const appVersion = computed(() => (runtimeConfig.public?.appVersion as string) ?? '0.1.0')
const handleSettingsUpdate = (_settings: unknown) => {
// Placeholder for future persistence
}
const handleLogout = async () => {
await logout()
await navigateTo('/profiles')
}
onMounted(async () => {
await ensureSession()
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

381
frontend/app/assets/app.css Normal file
View File

@@ -0,0 +1,381 @@
/* ─── Fonts ─── */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap');
@import "tailwindcss";
@plugin "daisyui";
/* ─── Theme ─── */
@plugin "daisyui/theme" {
name: "mytheme";
default: true;
prefersdark: false;
color-scheme: light;
/* Surfaces — warm gray with a hint of blue */
--color-base-100: oklch(98.5% 0.004 260);
--color-base-200: oklch(95% 0.008 260);
--color-base-300: oklch(91% 0.015 260);
--color-base-content: oklch(22% 0.025 260);
/* Primary — Malio blue, slightly richer */
--color-primary: oklch(40% 0.16 262);
--color-primary-content: oklch(98% 0.005 262);
/* Secondary — refined lavender */
--color-secondary: oklch(72% 0.06 275);
--color-secondary-content: oklch(22% 0.03 275);
/* Accent — warm amber-orange */
--color-accent: oklch(72% 0.17 55);
--color-accent-content: oklch(20% 0.04 55);
/* Neutral — deep slate */
--color-neutral: oklch(28% 0.04 260);
--color-neutral-content: oklch(95% 0.005 260);
/* Semantic */
--color-info: oklch(58% 0.14 255);
--color-info-content: oklch(98% 0.005 255);
--color-success: oklch(62% 0.19 150);
--color-success-content: oklch(98% 0.005 150);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(58% 0.22 25);
--color-error-content: oklch(98% 0.005 25);
/* Geometry */
--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;
}
@plugin "daisyui/theme" {
name: "mytheme-dark";
default: false;
prefersdark: true;
color-scheme: dark;
/* Surfaces — dark blue-gray */
--color-base-100: oklch(22% 0.015 260);
--color-base-200: oklch(18% 0.012 260);
--color-base-300: oklch(28% 0.018 260);
--color-base-content: oklch(92% 0.005 260);
/* Primary — Malio blue, brighter for dark */
--color-primary: oklch(55% 0.18 262);
--color-primary-content: oklch(98% 0.005 262);
/* Secondary — refined lavender */
--color-secondary: oklch(72% 0.06 275);
--color-secondary-content: oklch(22% 0.03 275);
/* Accent — warm amber-orange */
--color-accent: oklch(72% 0.17 55);
--color-accent-content: oklch(20% 0.04 55);
/* Neutral — lighter slate for dark mode */
--color-neutral: oklch(75% 0.02 260);
--color-neutral-content: oklch(18% 0.01 260);
/* Semantic */
--color-info: oklch(62% 0.14 255);
--color-info-content: oklch(98% 0.005 255);
--color-success: oklch(65% 0.19 150);
--color-success-content: oklch(98% 0.005 150);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(62% 0.22 25);
--color-error-content: oklch(98% 0.005 25);
/* Geometry — same as light */
--radius-selector: 0.75rem;
--radius-field: 0.375rem;
--radius-box: 0.625rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* ─── Typography ─── */
:root {
--font-heading: 'Outfit', system-ui, sans-serif;
--font-body: 'DM Sans', system-ui, sans-serif;
}
body {
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
h1, h2, h3, h4, h5, h6,
.card-title,
.stat-value,
.text-2xl,
.text-3xl,
.text-4xl {
font-family: var(--font-heading);
letter-spacing: -0.025em;
}
/* ─── Density variables ─── */
:root {
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
}
.density-compact {
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 0.75rem;
--spacing-lg: 1rem;
--spacing-xl: 1.25rem;
}
.density-comfortable {
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
}
.density-spacious {
--spacing-xs: 0.75rem;
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--spacing-lg: 2rem;
--spacing-xl: 3rem;
}
/* ─── High contrast mode ─── */
.contrast-high .btn { @apply border-2; }
.contrast-high .input { @apply border-2; }
.contrast-high .select { @apply border-2; }
.contrast-high .textarea { @apply border-2; }
.contrast-high .modal-box { @apply border-2 border-base-content; }
/* ─── Accessibility ─── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
*:focus-visible {
outline: 2px solid oklch(40% 0.16 262);
outline-offset: 2px;
}
/* ─── Cards ─── */
.card {
border: 1px solid oklch(91% 0.015 260 / 0.6);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.site-card {
background-color: oklch(100% 0 0);
}
[data-theme="mytheme-dark"] .site-card {
background-color: oklch(24% 0.015 260);
}
[data-theme="mytheme-dark"] .card {
border-color: oklch(30% 0.02 260 / 0.6);
}
.card:hover {
box-shadow:
0 4px 6px -1px oklch(22% 0.025 260 / 0.06),
0 2px 4px -2px oklch(22% 0.025 260 / 0.04);
}
/* ─── Navbar glass effect ─── */
.navbar-glass {
background: oklch(98.5% 0.004 260 / 0.82);
backdrop-filter: blur(12px) saturate(1.5);
-webkit-backdrop-filter: blur(12px) saturate(1.5);
border-bottom: 1px solid oklch(91% 0.015 260 / 0.5);
}
[data-theme="mytheme-dark"] .navbar-glass {
background: oklch(22% 0.015 260 / 0.85);
border-bottom-color: oklch(30% 0.02 260 / 0.5);
}
/* ─── Buttons ─── */
.btn {
font-family: var(--font-heading);
font-weight: 500;
letter-spacing: -0.01em;
transition: all 0.15s ease;
}
.btn-circle {
transition: all 0.2s ease-in-out;
}
.btn-circle:hover {
transform: scale(1.05);
}
.btn-circle:active {
transform: scale(0.95);
}
/* ─── Inputs ─── */
.input, .select, .textarea {
font-family: var(--font-body);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input:focus, .select:focus, .textarea:focus {
box-shadow: 0 0 0 3px oklch(40% 0.16 262 / 0.1);
}
/* ─── Tables ─── */
.table thead th {
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
font-size: 0.7rem;
color: oklch(45% 0.03 260);
}
.table tbody tr {
transition: background-color 0.1s ease;
}
/* ─── Badges ─── */
.badge {
font-family: var(--font-heading);
font-weight: 500;
letter-spacing: 0;
}
/* ─── Stats ─── */
.stat-title {
font-family: var(--font-body);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.6;
}
.stat-value {
font-weight: 700;
}
/* ─── Modals ─── */
.modal {
transition: opacity 0.25s ease;
}
.modal-box {
font-family: var(--font-body);
border-radius: 0.75rem;
border: 1px solid oklch(91% 0.015 260 / 0.5);
}
@keyframes modalSlideUp {
from { opacity: 0; transform: translateY(0.5rem); }
to { opacity: 1; transform: translateY(0); }
}
.modal.modal-open .modal-box {
animation: modalSlideUp 0.25s ease-out;
}
/* ─── Page transitions ─── */
.page-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-leave-active {
transition: opacity 0.15s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.page-leave-to {
opacity: 0;
}
/* ─── Scrollbar styling ─── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(75% 0.02 260);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(60% 0.03 260);
}
/* ─── Readability ─── */
.text-sm { line-height: 1.5; }
.text-xs { line-height: 1.4; }
/* ─── Adaptive spacing ─── */
.p-1 { padding: var(--spacing-xs); }
.p-2 { padding: var(--spacing-sm); }
.p-3 { padding: var(--spacing-md); }
.p-4 { padding: var(--spacing-lg); }
.p-5 { padding: var(--spacing-xl); }
.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 {
.form-control .label {
@apply mb-2;
padding-bottom: 0;
margin-right: 15px;
}
.form-control .label + * {
margin-top: var(--spacing-xs);
}
}
@layer base {
label + input,
label + select,
label + textarea,
label + .input,
label + .select,
label + .textarea {
margin-top: var(--spacing-xs);
}
}

View 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>

View 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>

View File

@@ -0,0 +1,47 @@
<template>
<div class="space-y-4">
<!-- Root Components -->
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4">
<ComponentItem
:component="component"
:is-edit-mode="isEditMode"
:show-delete="showDelete"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('delete')"
/>
</div>
</div>
</template>
<script setup>
import ComponentItem from './ComponentItem.vue'
defineProps({
components: {
type: Array,
required: true
},
isEditMode: {
type: Boolean,
default: false
},
showDelete: {
type: Boolean,
default: false
},
collapseAll: {
type: Boolean,
default: true
},
toggleToken: {
type: Number,
default: 0
}
})
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
</script>

View File

@@ -0,0 +1,465 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Component Header -->
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
<IconLucideChevronRight
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
:class="{ 'rotate-90': !isCollapsed }"
aria-hidden="true"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold text-base-content truncate">
{{ component.name }}
</h3>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span>
</div>
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-xs text-base-content/50"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span>
<span v-if="displayProductName" class="badge badge-info badge-xs">
{{ displayProductName }}
</span>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer ce composant"
@click.stop="$emit('delete')"
>
Supprimer
</button>
</div>
<!-- Expanded content -->
<div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only info -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
<div>
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
<p class="text-base-content">{{ component.name }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
<p class="text-base-content">{{ component.reference || '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
<p class="text-base-content">{{ component.prix ? `${component.prix}` : '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
<div v-if="componentConstructeursDisplay.length">
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-base-content"
>
{{ 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"
>
Voir le produit
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<!-- 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">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement...
</p>
<DocumentUpload
v-if="isEditMode"
v-model="selectedFiles"
title="Déposer des fichiers pour ce composant"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentListInline
:documents="componentDocuments"
:can-delete="isEditMode"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à ce composant."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<!-- Component Pieces (real MachinePieceLinks) -->
<div v-if="linkedPieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces du composant
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in linkedPieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
/>
</div>
</div>
<!-- Structure pieces (read-only, from composant definition) -->
<div v-if="structurePieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces incluses par défaut
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in structurePieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="false"
/>
</div>
</div>
<!-- Sub Components -->
<div v-if="childComponents.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Sous-composants
</p>
<div class="space-y-2 pl-4 border-l-2 border-base-200">
<ComponentItem
v-for="subComponent in childComponents"
:key="subComponent.id"
:component="subComponent"
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import PieceItem from './PieceItem.vue'
import DocumentUpload from './DocumentUpload.vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
component: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
// --- Shared composables ---
const {
documents: componentDocuments,
selectedFiles,
uploadingDocuments,
loadingDocuments,
previewDocument,
previewVisible,
openPreview,
closePreview,
ensureDocumentsLoaded,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
const {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
} = useEntityProductDisplay({ entity: () => props.component })
const {
displayedCustomFields,
updateCustomField: updateComponentCustomField,
} = 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 ---
const isCollapsed = ref(true)
watch(
() => props.toggleToken,
() => {
isCollapsed.value = props.collapseAll
if (!isCollapsed.value) ensureDocumentsLoaded()
},
{ immediate: true },
)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
if (!isCollapsed.value) ensureDocumentsLoaded()
}
// --- Child components ---
const childComponents = computed(() => {
const list = props.component.subcomponents || props.component.subComponents || []
return Array.isArray(list) ? list : []
})
// --- Pieces split: real links vs structure definitions ---
const allPieces = computed(() => {
const list = props.component.pieces
return Array.isArray(list) ? list : []
})
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
// --- Constructeurs ---
const { constructeurs } = useConstructeurs()
const componentConstructeurLinks = computed(() =>
parseConstructeurLinksFromApi(
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
),
)
const supplierReferenceMap = computed(() => {
const map = new Map()
componentConstructeurLinks.value.forEach(l => {
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
})
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) =>
formatConstructeurContactSummary(constructeur)
const handleConstructeurChange = async (value) => {
const ids = uniqueConstructeurIds(value)
props.component.constructeurIds = [...ids]
props.component.constructeurId = null
props.component.constructeur = null
props.component.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
)
await updateComponent()
}
// --- Update / Event forwarding ---
const updateComponent = () => {
emit('update', {
...props.component,
constructeurIds: componentConstructeurIds.value,
})
}
const updatePiece = (updatedPiece) => {
emit('edit-piece', updatedPiece)
}
const editPiece = (piece) => {
emit('edit-piece', piece)
}
const updatePieceCustomField = (fieldUpdate) => {
emit('custom-field-update', fieldUpdate)
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
}
</script>

View File

@@ -0,0 +1,269 @@
<template>
<div class="space-y-6">
<StructureNodeEditor
:node="localStructure"
:depth="0"
:component-types="availableComponentTypes"
:piece-types="availablePieceTypes"
:product-types="availableProductTypes"
:lock-type="lockRootType"
:locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
is-root
/>
</div>
</template>
<script setup lang="ts">
import { reactive, watch, computed, onMounted, ref } from 'vue'
import StructureNodeEditor from '~/components/StructureNodeEditor.vue'
import {
defaultStructure,
hydrateStructureForEditor,
cloneStructure,
} from '~/shared/modelUtils'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
defineOptions({ name: 'ComponentModelStructureEditor' })
const props = defineProps({
modelValue: {
type: Object,
default: () => defaultStructure(),
},
rootTypeId: {
type: String,
default: '',
},
rootTypeLabel: {
type: String,
default: '',
},
lockRootType: {
type: Boolean,
default: false,
},
allowSubcomponents: {
type: Boolean,
default: true,
},
maxSubcomponentDepth: {
type: Number,
default: Infinity,
},
})
const emit = defineEmits(['update:modelValue'])
const localStructure = reactive<ComponentModelStructure>(hydrateStructureForEditor(props.modelValue))
const previousLockedLabel = ref(props.rootTypeLabel || '')
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
const availableComponentTypes = computed(() => componentTypes.value ?? [])
const availableProductTypes = computed(() => productTypes.value ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
)
const fallbackRootTypeLabel = computed(() => {
if (!props.rootTypeId) {
return ''
}
const match = availableComponentTypes.value.find((type) => type?.id === props.rootTypeId)
return match?.name || ''
})
const displayedRootTypeLabel = computed(() => props.rootTypeLabel || fallbackRootTypeLabel.value)
const formatOptionsText = (field: Record<string, any>) => {
if (typeof field?.optionsText === 'string') {
return field.optionsText
}
if (Array.isArray(field?.options)) {
return field.options.join('\n')
}
return ''
}
const normalizeLineEndings = (text: string) =>
text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const parseOptionsFromText = (text: string) => {
return text
.split('\n')
.map((option) => option.trim())
.filter((option) => option.length > 0)
}
const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) => {
if (!node || typeof node !== 'object') {
return
}
if (Array.isArray(node.customFields)) {
node.customFields = node.customFields.map((field: Record<string, any>) => {
if (!field || typeof field !== 'object') {
return field
}
const next = { ...field }
if (next.type === 'select') {
const baseText = normalizeLineEndings(formatOptionsText(next))
if (next.optionsText !== baseText) {
next.optionsText = baseText
}
const parsedOptions = parseOptionsFromText(next.optionsText || '')
if (parsedOptions.length > 0) {
next.options = parsedOptions
} else {
delete next.options
}
} else {
if (next.options !== undefined) {
delete next.options
}
if (next.optionsText !== undefined && next.optionsText !== '') {
next.optionsText = ''
}
}
return next
})
}
if (Array.isArray(node.subcomponents)) {
node.subcomponents = node.subcomponents.map((sub: Record<string, any>) => {
if (!sub || typeof sub !== 'object') {
return sub
}
const copy = { ...sub }
applyCustomFieldOptions(copy)
return copy
})
}
}
const prepareStructureForEmit = (structure: any) => {
const clone = cloneStructure(structure)
applyCustomFieldOptions(clone as Record<string, any>)
return clone
}
const syncRootType = () => {
if (!props.lockRootType) {
previousLockedLabel.value = props.rootTypeLabel || ''
return
}
const newTypeId = props.rootTypeId || ''
const newLabel = displayedRootTypeLabel.value
localStructure.typeComposantId = newTypeId
localStructure.typeComposantLabel = newLabel
const match = availableComponentTypes.value.find((type) => type?.id === newTypeId)
if (match?.code) {
localStructure.familyCode = match.code
}
const previousLabel = previousLockedLabel.value
if (!localStructure.alias || localStructure.alias === previousLabel || localStructure.alias === '') {
localStructure.alias = newLabel || localStructure.alias
}
previousLockedLabel.value = newLabel
}
let lastEmitted = JSON.stringify(prepareStructureForEmit(props.modelValue))
const syncFromProps = (value: any) => {
const normalizedIncoming = prepareStructureForEmit(value)
const incomingSerialized = JSON.stringify(normalizedIncoming)
if (incomingSerialized === lastEmitted) {
return
}
const hydrated = hydrateStructureForEditor(value)
localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces
localStructure.products = hydrated.products
localStructure.subcomponents = hydrated.subcomponents
localStructure.typeComposantId = hydrated.typeComposantId
localStructure.typeComposantLabel = hydrated.typeComposantLabel
localStructure.modelId = hydrated.modelId
localStructure.familyCode = hydrated.familyCode
localStructure.alias = hydrated.alias
lastEmitted = incomingSerialized
syncRootType()
}
watch(
() => props.modelValue,
(value) => {
syncFromProps(value)
},
{ deep: true }
)
watch(
() => [props.rootTypeId, props.rootTypeLabel, props.lockRootType],
() => {
syncRootType()
},
{ immediate: true }
)
watch(
availableComponentTypes,
() => {
syncRootType()
}
)
watch(
localStructure,
(value) => {
const payload = prepareStructureForEmit(value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
},
{ deep: true }
)
onMounted(async () => {
const loaders: Promise<unknown>[] = []
if (!availablePieceTypes.value.length) {
loaders.push(loadPieceTypes())
}
if (!availableComponentTypes.value.length) {
loaders.push(loadComponentTypes())
}
if (!availableProductTypes.value.length) {
loaders.push(loadProductTypes())
}
if (loaders.length) {
await Promise.allSettled(loaders)
}
syncRootType()
})
watch(
allowSubcomponents,
(allowed) => {
if (!allowed && Array.isArray(localStructure.subcomponents) && localStructure.subcomponents.length) {
localStructure.subcomponents = []
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,262 @@
<template>
<div :class="wrapperClass">
<section v-if="!isRoot" class="rounded-lg border border-base-200 bg-base-100 p-4 space-y-3">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ requirementLabel }}
</h4>
<p class="text-xs text-base-content/70">
{{ requirementDescription }}
</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Sélectionner un composant</span>
</label>
<SearchSelect
:model-value="assignment.selectedComponentId || ''"
:options="componentOptions"
:loading="componentsLoading || componentLoadingByPath[assignment.path]"
size="sm"
placeholder="Rechercher un composant..."
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel"
:option-description="componentOptionDescription"
server-search
@search="fetchComponentOptions"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.pieces.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Pièces requises par le squelette' : 'Pièces associées à ce sous-composant' }}
</h4>
<p class="text-xs text-base-content/70">
Sélectionnez les pièces concrètes à associer pour chaque emplacement.
</p>
</header>
<div
v-for="pieceAssignment in assignment.pieces"
:key="pieceAssignment.path"
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describePieceRequirement(pieceAssignment) }}
</p>
<p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
Aucune pièce disponible pour cette famille.
</p>
</div>
<SearchSelect
:model-value="pieceAssignment.selectedPieceId || ''"
:options="getPieceOptions(pieceAssignment)"
:loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
size="xs"
placeholder="Rechercher une pièce..."
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:option-label="pieceOptionLabel"
:option-description="pieceOptionDescription"
server-search
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.products.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Produits requis par le squelette' : 'Produits associés à ce sous-composant' }}
</h4>
<p class="text-xs text-base-content/70">
Sélectionnez les produits catalogue à lier sur chaque position définie.
</p>
</header>
<div
v-for="productAssignment in assignment.products"
:key="productAssignment.path"
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describeProductRequirement(productAssignment) }}
</p>
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
Aucun produit disponible pour cette catégorie.
</p>
</div>
<SearchSelect
:model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment)"
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
size="xs"
placeholder="Rechercher un produit..."
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel"
:option-description="productOptionDescription"
server-search
@search="(term) => fetchProductOptions(productAssignment, term)"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.subcomponents.length" class="space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Sous-composants définis par le squelette' : 'Sous-composants imbriqués' }}
</h4>
<p class="text-xs text-base-content/70">
Choisissez un composant existant pour chaque sous-niveau requis.
</p>
</header>
<ComponentStructureAssignmentNode
v-for="subAssignment in assignment.subcomponents"
:key="subAssignment.path"
:assignment="subAssignment"
:pieces="pieces"
:products="products"
:components="components"
:components-loading="componentsLoading"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:depth="depth + 1"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue';
import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
import type {
ComponentOption,
PieceOption,
ProductOption,
} from '~/composables/useStructureAssignmentFetch';
export type {
StructureAssignmentNode,
StructurePieceAssignment,
StructureProductAssignment,
} from '~/composables/useStructureAssignmentFetch';
const props = withDefaults(
defineProps<{
assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
pieces: PieceOption[] | null;
products: ProductOption[] | null;
components: ComponentOption[] | null;
depth?: number;
componentsLoading?: boolean;
piecesLoading?: boolean;
productsLoading?: boolean;
pieceTypeLabelMap?: Record<string, string>;
productTypeLabelMap?: Record<string, string>;
componentTypeLabelMap?: Record<string, string>;
}>(),
{
depth: 0,
pieces: () => [],
products: () => [],
components: () => [],
componentsLoading: false,
piecesLoading: false,
productsLoading: false,
pieceTypeLabelMap: () => ({}),
productTypeLabelMap: () => ({}),
componentTypeLabelMap: () => ({}),
},
);
const depth = computed(() => props.depth ?? 0);
const isRoot = computed(() => depth.value === 0);
const wrapperClass = computed(() =>
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
);
const {
pieceLoadingByPath,
productLoadingByPath,
componentLoadingByPath,
componentOptions,
componentOptionLabel,
componentOptionDescription,
fetchComponentOptions,
getPieceOptions,
pieceOptionLabel,
pieceOptionDescription,
fetchPieceOptions,
describePieceRequirement,
getProductOptions,
productOptionLabel,
productOptionDescription,
fetchProductOptions,
describeProductRequirement,
} = useStructureAssignmentFetch({
assignment: props.assignment,
pieces: props.pieces,
products: props.products,
components: props.components,
isRoot: () => isRoot.value,
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
productTypeLabelMap: props.productTypeLabelMap ?? {},
componentTypeLabelMap: props.componentTypeLabelMap ?? {},
});
const normalizeSelectionValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
return '';
};
const requirementLabel = computed(() => {
const definition = props.assignment.definition || {};
const alias = definition.alias || definition.typeComposantLabel;
if (alias) {
return alias;
}
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
return props.componentTypeLabelMap[definition.typeComposantId];
}
if (definition.typeComposant?.name) {
return definition.typeComposant.name;
}
if (definition.familyCode) {
return `Famille ${definition.familyCode}`;
}
return 'Sous-composant';
});
const requirementDescription = computed(() => {
const definition = props.assignment.definition || {};
const family =
definition.typeComposantLabel
|| (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
|| definition.typeComposant?.name
|| definition.familyCode;
if (family) {
return `Doit appartenir à la famille "${family}".`;
}
return 'Sélectionnez un composant enfant conforme à cette position.';
});
</script>

View 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>

View 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>

View File

@@ -0,0 +1,395 @@
<template>
<div class="space-y-2 constructeur-select">
<label v-if="label" class="label"><span class="label-text">{{ label }}</span></label>
<div class="flex items-start gap-2">
<div class="relative flex-1">
<input
v-model="searchTerm"
type="text"
class="input input-bordered w-full pr-10"
:placeholder="placeholder"
@focus="openDropdown = true; ensureOptionsLoaded()"
@input="onSearch"
>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs"
@click="ensureOptionsLoaded(true)"
>
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
</button>
<div
v-if="openDropdown"
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
v-if="filteredOptions.length === 0"
class="px-3 py-2 text-xs text-gray-500"
>
Aucun fournisseur trouvé
</div>
<button
v-for="option in filteredOptions"
:key="option.id"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
:class="{ 'bg-base-200': isSelected(option.id) }"
@click="toggleOption(option)"
>
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col">
<span class="font-medium">{{ option.name }}</span>
<span class="text-xs text-gray-500">
{{ formatConstructeurContact(option) || '—' }}
</span>
</div>
<IconLucideCheck v-if="isSelected(option.id)" class="w-4 h-4 text-primary" aria-hidden="true" />
</div>
</button>
</div>
</div>
<button type="button" class="btn btn-outline btn-sm" @click="openCreateModal = true">
Nouveau
</button>
</div>
<div class="flex flex-wrap gap-2 min-h-[1.5rem]">
<span v-if="!selectedConstructeurs.length" class="text-sm text-gray-500">
Aucun fournisseur sélectionné
</span>
<span
v-for="constructeur in selectedConstructeurs"
:key="constructeur.id"
class="badge badge-outline gap-1"
>
<span>{{ constructeur.name }}</span>
<button
type="button"
class="btn btn-ghost btn-xs p-0"
aria-label="Retirer le fournisseur"
@click="removeConstructeur(constructeur.id)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</span>
</div>
<dialog class="modal" :class="{ 'modal-open': openCreateModal }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Nouveau fournisseur
</h3>
<form @submit.prevent="handleCreate">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="createForm.name" type="text" class="input input-bordered" required>
</div>
<FieldEmail
v-model="createForm.email"
class="mb-3"
label="Email"
placeholder="ex: contact@fournisseur.com"
autocomplete="email"
/>
<FieldPhone
v-model="createForm.phone"
class="mb-3"
label="Téléphone"
placeholder="ex: 01 23 45 67 89"
/>
<div class="modal-action">
<button type="button" class="btn" @click="closeCreateModal">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="creating">
<span v-if="creating" class="loading loading-spinner loading-xs mr-2" />
Créer
</button>
</div>
</form>
</div>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import type { PropType } from 'vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import {
type ConstructeurSummary,
formatConstructeurContact,
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
const props = defineProps({
modelValue: {
type: Array as PropType<string[]>,
default: () => [],
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Sélectionner ou créer un fournisseur...',
},
initialOptions: {
type: Array as PropType<ConstructeurSummary[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const {
constructeurs,
searchConstructeurs,
createConstructeur,
ensureConstructeurs,
} = useConstructeurs()
const searchTerm = ref('')
const openDropdown = ref(false)
const openCreateModal = ref(false)
const creating = ref(false)
const options = ref<ConstructeurSummary[]>([])
const selectedIds = ref<string[]>([])
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
const seen = new Map<string, ConstructeurSummary>()
items.forEach((item) => {
if (item && typeof item === 'object' && typeof item.id === 'string') {
seen.set(item.id, item)
}
})
return Array.from(seen.values())
}
const normalizedInitialOptions = computed(() =>
uniqueOptions((props.initialOptions as ConstructeurSummary[]) || []),
)
const applyOptions = (items: ConstructeurSummary[] = []) => {
options.value = uniqueOptions([
...normalizedInitialOptions.value,
...items,
])
}
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({
name: '',
email: '',
phone: '',
})
const optionLookup = computed(() => {
const map = new Map<string, ConstructeurSummary>()
normalizedInitialOptions.value.forEach((item) => {
map.set(item.id, item)
})
constructeurs.value.forEach((item: ConstructeurSummary) => {
map.set(item.id, item)
})
options.value.forEach((item) => {
map.set(item.id, item)
})
return map
})
const selectedConstructeurs = computed<ConstructeurSummary[]>(() => {
if (!selectedIds.value.length) {
return []
}
return selectedIds.value
.map((id) => optionLookup.value.get(id))
.filter((item): item is ConstructeurSummary => Boolean(item))
})
const isSelected = (id: string) => selectedIds.value.includes(id)
const emitSelection = (ids: string[]) => {
const normalized = uniqueConstructeurIds(ids)
selectedIds.value = normalized
emit('update:modelValue', normalized)
}
const extractDataArray = (data: unknown): ConstructeurSummary[] => {
if (Array.isArray(data)) {
return data as ConstructeurSummary[]
}
if (data && typeof data === 'object' && 'id' in data) {
return [data as ConstructeurSummary]
}
return []
}
const ensureOptionsLoaded = async (force = false) => {
if (!force && constructeurs.value.length) {
applyOptions(constructeurs.value as ConstructeurSummary[])
return
}
const result = await searchConstructeurs('')
if (result.success) {
applyOptions(extractDataArray(result.data))
}
}
const onSearch = () => {
openDropdown.value = true
ensureOptionsLoaded()
}
const toggleOption = (option: ConstructeurSummary) => {
const ids = new Set(selectedIds.value)
if (ids.has(option.id)) {
ids.delete(option.id)
} else {
ids.add(option.id)
}
emitSelection(Array.from(ids))
}
const removeConstructeur = (id: string) => {
emitSelection(selectedIds.value.filter((item) => item !== id))
}
const closeCreateModal = () => {
openCreateModal.value = false
createForm.value = { name: '', email: '', phone: '' }
}
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
const payload: { name: string; email?: string; phone?: string } = {
name: trimmedName,
}
if (createForm.value.email) {
payload.email = createForm.value.email
}
if (createForm.value.phone) {
payload.phone = createForm.value.phone
}
const result = await createConstructeur(payload)
creating.value = false
if (result.success && result.data && !Array.isArray(result.data)) {
emitSelection([...selectedIds.value, result.data.id])
searchTerm.value = ''
closeCreateModal()
await ensureOptionsLoaded(true)
}
}
const clickHandler = (event: Event) => {
const element = event.target as HTMLElement | null
if (element && element.closest) {
if (
element.closest('.menu') ||
element.closest('.modal-box') ||
element.closest('.btn') ||
element.closest('.constructeur-select')
) {
return
}
}
openDropdown.value = false
}
watch(
() => props.modelValue,
(newValue) => {
selectedIds.value = uniqueConstructeurIds(newValue)
},
{ immediate: true },
)
watch(
selectedIds,
async (ids) => {
if (!ids.length) {
return
}
const missing = ids.some((id) => !optionLookup.value.get(id))
if (missing) {
const fetched = await ensureConstructeurs(ids)
if (fetched.length) {
applyOptions([...options.value, ...fetched])
}
}
},
{ immediate: true },
)
watch(
constructeurs,
(list) => {
applyOptions((list as ConstructeurSummary[]) || [])
},
{ immediate: true },
)
watch(
normalizedInitialOptions,
() => {
applyOptions(options.value)
},
{ immediate: true },
)
onMounted(() => {
window.addEventListener('click', clickHandler)
ensureOptionsLoaded()
})
onBeforeUnmount(() => {
window.removeEventListener('click', clickHandler)
})
watch(
selectedIds,
(ids) => {
// ensure options contain newly selected ids
const resolved = resolveConstructeurs(
ids,
constructeurs.value as ConstructeurSummary[],
options.value,
)
if (resolved.length) {
applyOptions([...resolved, ...options.value])
}
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div v-if="customFields && customFields.length > 0" class="space-y-4">
<h4 class="font-semibold text-base-content/80 mb-3">
Champs personnalisés
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in sortedCustomFields"
:key="field.id"
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>
<!-- Champ de type TEXT -->
<input
v-if="field.type === 'text'"
v-model="fieldValues[field.id]"
type="text"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateCustomFieldValue(field.id)"
>
<!-- Champ de type NUMBER -->
<input
v-else-if="field.type === 'number'"
v-model="fieldValues[field.id]"
type="number"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateCustomFieldValue(field.id)"
>
<!-- Champ de type SELECT -->
<select
v-else-if="field.type === 'select'"
v-model="fieldValues[field.id]"
class="select select-bordered select-sm"
:required="field.required"
@change="updateCustomFieldValue(field.id)"
>
<option value="">
Sélectionner...
</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Champ de type BOOLEAN -->
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="fieldValues[field.id]"
type="checkbox"
class="toggle toggle-primary toggle-sm"
:checked="fieldValues[field.id] === 'true'"
@change="updateCustomFieldValue(field.id)"
>
<span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<!-- Champ de type DATE -->
<input
v-else-if="field.type === 'date'"
v-model="fieldValues[field.id]"
type="date"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateCustomFieldValue(field.id)"
>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, watch, computed } from 'vue'
const props = defineProps({
customFields: {
type: Array,
default: () => []
},
entityId: {
type: String,
required: true
},
entityType: {
type: String,
required: true, // 'machine', 'composant', 'piece'
validator: value => ['machine', 'composant', 'piece'].includes(value)
}
})
const emit = defineEmits(['update'])
const sortedCustomFields = computed(() => {
if (!Array.isArray(props.customFields)) {
return []
}
return [...props.customFields].sort((a, b) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
})
// Valeurs des champs personnalisés
const fieldValues = reactive({})
// Initialiser les valeurs sans appliquer de valeur par défaut implicite
const initializeFieldValues = () => {
props.customFields.forEach((field) => {
if (!(field.id in fieldValues)) {
fieldValues[field.id] = field.value ?? ''
}
})
}
// Mettre à jour la valeur d'un champ personnalisé
const updateCustomFieldValue = (fieldId) => {
const field = props.customFields.find(f => f.id === fieldId)
if (field) {
emit('update', {
fieldId,
value: fieldValues[fieldId],
field
})
}
}
// Surveiller les changements dans les champs personnalisés
watch(() => props.customFields, () => {
initializeFieldValues()
}, { deep: true })
onMounted(() => {
initializeFieldValues()
})
</script>

View 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>

View File

@@ -0,0 +1,207 @@
<template>
<div class="modal" :class="{ 'modal-open': isOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Paramètres d'affichage
</h3>
<!-- Contrôle du zoom -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Taille du texte</span>
<span class="label-text-alt">{{ zoomLevel }}%</span>
</label>
<input
type="range"
min="80"
max="150"
step="10"
:value="zoomLevel"
class="range range-primary"
@input="updateZoom"
>
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>80%</span>
<span>100%</span>
<span>150%</span>
</div>
</div>
<!-- Contrôle de la densité -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Densité de l'interface</span>
</label>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="density === 'compact' ? 'btn-primary' : 'btn-outline'"
@click="setDensity('compact')"
>
Compacte
</button>
<button
class="btn btn-sm"
:class="density === 'comfortable' ? 'btn-primary' : 'btn-outline'"
@click="setDensity('comfortable')"
>
Confortable
</button>
<button
class="btn btn-sm"
:class="density === 'spacious' ? 'btn-primary' : 'btn-outline'"
@click="setDensity('spacious')"
>
Espacée
</button>
</div>
</div>
<!-- Contrôle du contraste -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Contraste</span>
</label>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="contrast === 'normal' ? 'btn-primary' : 'btn-outline'"
@click="setContrast('normal')"
>
Normal
</button>
<button
class="btn btn-sm"
:class="contrast === 'high' ? 'btn-primary' : 'btn-outline'"
@click="setContrast('high')"
>
Élevé
</button>
</div>
</div>
<!-- Réinitialiser -->
<div class="form-control">
<button
class="btn btn-outline btn-sm"
@click="resetSettings"
>
Réinitialiser les paramètres
</button>
</div>
<!-- Actions -->
<div class="modal-action">
<button class="btn btn-primary" @click="closeModal">
Fermer
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({
isOpen: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'update-settings'])
// Paramètres d'affichage
const zoomLevel = ref(100)
const density = ref('comfortable')
const contrast = ref('normal')
// Charger les paramètres depuis le localStorage
onMounted(() => {
const savedZoom = localStorage.getItem('display-zoom')
const savedDensity = localStorage.getItem('display-density')
const savedContrast = localStorage.getItem('display-contrast')
if (savedZoom) { zoomLevel.value = parseInt(savedZoom) }
if (savedDensity) { density.value = savedDensity }
if (savedContrast) { contrast.value = savedContrast }
applySettings()
})
const updateZoom = (event) => {
zoomLevel.value = parseInt(event.target.value)
applySettings()
}
const setDensity = (newDensity) => {
density.value = newDensity
applySettings()
}
const setContrast = (newContrast) => {
contrast.value = newContrast
applySettings()
}
const applySettings = () => {
// Sauvegarder dans localStorage
localStorage.setItem('display-zoom', zoomLevel.value.toString())
localStorage.setItem('display-density', density.value)
localStorage.setItem('display-contrast', contrast.value)
// Appliquer les styles
const root = document.documentElement
// Zoom - exclure complètement le modal des paramètres
const modal = document.querySelector('.modal')
if (modal) {
// Forcer la taille normale pour le modal et tous ses enfants
modal.style.fontSize = '100%'
modal.style.transform = 'none'
modal.style.scale = '1'
// Appliquer aux enfants du modal
const modalElements = modal.querySelectorAll('*')
modalElements.forEach((element) => {
element.style.fontSize = 'inherit'
element.style.transform = 'none'
element.style.scale = '1'
})
}
// Appliquer le zoom au reste de la page (sauf le modal)
root.style.fontSize = `${zoomLevel.value}%`
// Densité - utiliser les classes DaisyUI
root.classList.remove('density-compact', 'density-comfortable', 'density-spacious')
root.classList.add(`density-${density.value}`)
// Contraste - utiliser les classes DaisyUI
root.classList.remove('contrast-normal', 'contrast-high')
root.classList.add(`contrast-${contrast.value}`)
// Émettre les changements
emit('update-settings', {
zoom: zoomLevel.value,
density: density.value,
contrast: contrast.value
})
}
const resetSettings = () => {
zoomLevel.value = 100
density.value = 'comfortable'
contrast.value = 'normal'
applySettings()
}
const closeModal = () => {
emit('close')
}
</script>
<style scoped>
/* Les styles sont maintenant gérés par DaisyUI et le CSS global */
</style>

View 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>

View File

@@ -0,0 +1,250 @@
<template>
<teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm px-4 py-6"
@click.self="close"
>
<div class="w-full max-w-[1600px] h-full max-h-[94vh] bg-base-100 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<header class="flex items-start justify-between gap-4 p-6 border-b border-base-200">
<div class="min-w-0">
<h3 class="font-bold text-xl truncate">
Prévisualisation
<span v-if="navTotal > 1" class="text-base font-normal text-base-content/50">
{{ activeIndex + 1 }} / {{ navTotal }}
</span>
</h3>
<p class="text-sm text-base-content/50 truncate">
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span>
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
</button>
</header>
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
<button
v-if="hasPrev"
type="button"
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document précédent (←)"
@click="goToPrev"
>
</button>
<button
v-if="hasNext"
type="button"
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document suivant (→)"
@click="goToNext"
>
</button>
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
<template v-if="previewType === 'image'">
<img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
</template>
<template v-else-if="previewType === 'pdf'">
<iframe
:src="documentSrc"
class="w-full h-full bg-white"
frameborder="0"
title="Aperçu PDF"
/>
</template>
<template v-else-if="previewType === 'audio'">
<audio :src="documentSrc" controls class="w-full" />
</template>
<template v-else-if="previewType === 'video'">
<video :src="documentSrc" controls class="w-full h-full bg-black" />
</template>
<template v-else-if="previewType === 'text'">
<div class="w-full h-full overflow-auto">
<div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-base-content/50">
<span class="loading loading-spinner loading-md mr-2" />
Chargement du document...
</div>
<div v-else-if="textError" class="alert alert-error text-sm">
{{ textError }}
</div>
<pre v-else class="bg-base-100 border border-base-300 rounded-lg p-4 whitespace-pre-wrap">
{{ textContent }}
</pre>
</div>
</template>
<template v-else>
<div class="text-sm text-base-content/50 text-center px-6">
Prévisualisation non disponible pour ce type de document.
</div>
</template>
</div>
</section>
<footer class="border-t border-base-200 px-6 py-4 flex flex-wrap gap-2 justify-end bg-base-100">
<button type="button" class="btn" @click="close">
Fermer
</button>
<button type="button" class="btn btn-primary" @click="download">
Télécharger
</button>
</footer>
</div>
</div>
</teleport>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
const props = defineProps({
document: {
type: Object,
default: null,
},
visible: {
type: Boolean,
default: false,
},
documents: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['close'])
// --- Carousel navigation ---
const previewableDocuments = computed(() => {
if (!props.documents?.length) return []
return props.documents.filter((doc) => canPreviewDocument(doc))
})
const navTotal = computed(() => previewableDocuments.value.length)
const activeIndex = ref(0)
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
watch(
() => props.document,
(doc) => {
if (!doc || !previewableDocuments.value.length) {
activeIndex.value = 0
return
}
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
activeIndex.value = idx >= 0 ? idx : 0
},
{ immediate: true },
)
const activeDoc = computed(() => {
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
return previewableDocuments.value[activeIndex.value]
}
return props.document
})
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
const goToPrev = () => {
if (hasPrev.value) activeIndex.value--
}
const goToNext = () => {
if (hasNext.value) activeIndex.value++
}
// Keyboard navigation
const handleKeydown = (e) => {
if (!props.visible) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
goToPrev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
goToNext()
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
watch(
() => props.visible,
(val) => {
if (val) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
},
)
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// --- Preview logic (uses activeDoc) ---
const previewType = computed(() => getPreviewType(activeDoc.value))
const documentDescription = computed(() => describeDocument(activeDoc.value))
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
const textContent = ref('')
const textLoading = ref(false)
const textError = ref('')
watch(
activeDoc,
async (doc) => {
textContent.value = ''
textError.value = ''
textLoading.value = false
if (!doc) { return }
if (getPreviewType(doc) !== 'text') { return }
try {
textLoading.value = true
const url = doc.fileUrl || doc.path || ''
if (!url) {
textError.value = 'Aucune URL de document disponible.'
return
}
const response = await fetch(url, { credentials: 'include' })
if (!response.ok) {
throw new Error('Téléchargement du document impossible')
}
textContent.value = await response.text()
} catch (error) {
console.error('Erreur lors du chargement du texte:', error)
textError.value = error.message || 'Impossible de lire ce document.'
} finally {
textLoading.value = false
}
},
{ immediate: true },
)
const close = () => {
emit('close')
}
const download = () => {
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
if (!url) { return }
window.open(url, '_blank')
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<div
v-if="document"
class="flex items-center justify-center overflow-hidden rounded-md border border-base-200 bg-base-200/70"
:class="thumbnailClass"
>
<img
v-if="canRenderImage"
:src="previewSrc"
class="h-full w-full object-cover"
:alt="altText"
loading="lazy"
decoding="async"
>
<component
v-else
:is="icon.component"
class="h-6 w-6"
:class="icon.colorClass"
aria-hidden="true"
/>
</div>
<div
v-else
class="flex h-16 w-16 items-center justify-center rounded-md border border-dashed border-base-200 bg-base-200/40 text-xs text-base-content/40"
aria-hidden="true"
>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { getFileIcon } from '~/utils/fileIcons';
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview';
type GenericDocument = {
id?: string | number;
name?: string | null;
filename?: string | null;
mimeType?: string | null;
path?: string | null;
fileUrl?: string | null;
downloadUrl?: string | null;
size?: number | null;
};
const props = defineProps<{
document: GenericDocument | null | undefined;
alt?: string;
}>();
const normalizedDocument = computed(() => props.document ?? null);
const canRenderImage = computed(() => {
const doc = normalizedDocument.value;
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
});
const canRenderPdf = computed(() => {
// Rendering many PDF iframes in a list is very heavy for the browser.
// We intentionally disable inline PDF previews and fall back to an icon.
return false;
});
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src;
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`;
}
return `${src}#toolbar=0&navpanes=0`;
};
const previewSrc = computed(() => {
const doc = normalizedDocument.value;
const url = doc?.fileUrl || doc?.path;
if (!doc || !url) {
return '';
}
if (isPdfDocument(doc)) {
return appendPdfViewerParams(url);
}
return url;
});
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
const icon = computed(() => {
const doc = normalizedDocument.value;
return getFileIcon({
name: doc?.filename || doc?.name || '',
mime: doc?.mimeType || undefined,
});
});
const altText = computed(() => {
if (props.alt) {
return props.alt;
}
const doc = normalizedDocument.value;
return doc?.name ? `Aperçu de ${doc.name}` : 'Aperçu du document';
});
</script>

View File

@@ -0,0 +1,245 @@
<template>
<div
class="border-2 border-dashed rounded-lg p-6 transition-colors"
:class="dragActive ? 'border-primary bg-primary/5' : 'border-base-300 bg-base-100'"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<div class="flex flex-col items-center gap-3 text-center">
<IconLucideCloudUpload class="w-10 h-10 text-primary" aria-hidden="true" />
<div>
<h3 class="font-semibold">
{{ title }}
</h3>
<p class="text-sm text-base-content/50">
{{ subtitle }}
</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
Sélectionner des fichiers
</button>
<span class="text-xs text-base-content/50">ou glisser-déposer ici</span>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
:accept="accept"
:multiple="multiple"
@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">
<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="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-300 bg-base-200/70 flex items-center justify-center">
<img
v-if="isImageFile(file)"
:src="getFilePreview(file)"
class="h-full w-full object-cover"
:alt="`Aperçu de ${file.name}`"
>
<component
v-else
:is="getIcon(file).component"
class="h-6 w-6"
:class="getIcon(file).colorClass"
aria-hidden="true"
/>
</div>
<div class="flex flex-col">
<span class="font-medium">{{ file.name }}</span>
<span class="text-xs text-base-content/50">{{ formatSize(file.size) }} {{ file.type || 'Type inconnu' }}</span>
</div>
</div>
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">
Retirer
</button>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useToast } from '~/composables/useToast'
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
import { getFileIcon } from '~/utils/fileIcons'
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
const props = defineProps({
title: {
type: String,
default: 'Ajouter des documents'
},
subtitle: {
type: String,
default: 'Formats acceptés : PDF, images, textes…'
},
accept: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: true
},
modelValue: {
type: Array,
default: () => []
},
maxFileSizeMb: {
type: Number,
default: 200
},
documentType: {
type: String,
default: 'documentation'
}
})
const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
const dragActive = ref(false)
const fileInput = ref(null)
const internalFiles = ref([])
const { showError } = useToast()
const previewUrls = new Map()
const isImageFile = (file) => (file?.type || '').startsWith('image/')
const getFilePreview = (file) => {
if (!isImageFile(file)) { return null }
if (!previewUrls.has(file)) {
previewUrls.set(file, URL.createObjectURL(file))
}
return previewUrls.get(file)
}
const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
const nextSet = new Set(nextFiles)
previousFiles.forEach((file) => {
if (!nextSet.has(file)) {
const url = previewUrls.get(file)
if (url) {
URL.revokeObjectURL(url)
previewUrls.delete(file)
}
}
})
}
const selectedFiles = internalFiles
watch(
() => props.modelValue,
(newValue) => {
if (Array.isArray(newValue)) {
cleanupRemovedPreviews(internalFiles.value, newValue)
internalFiles.value = [...newValue]
}
},
{ immediate: true }
)
const triggerFileDialog = () => {
fileInput.value?.click()
}
const emitFiles = (files) => {
cleanupRemovedPreviews(internalFiles.value, files)
internalFiles.value = files
emit('update:modelValue', files)
emit('files-added', files)
}
const handleFiles = (fileList) => {
const files = Array.from(fileList)
const maxBytes = props.maxFileSizeMb * 1024 * 1024
if (!props.multiple) {
const validFile = files[0]
if (validFile && validFile.size > maxBytes) {
showError(`Le fichier "${validFile.name}" dépasse la limite de ${props.maxFileSizeMb} Mo`)
return
}
emitFiles(files.slice(0, 1))
} else {
const merged = [...internalFiles.value]
files.forEach((file) => {
if (file.size > maxBytes) {
showError(`Le fichier "${file.name}" dépasse la limite de ${props.maxFileSizeMb} Mo`)
return
}
if (!merged.some(existing => existing.name === file.name && existing.size === file.size)) {
merged.push(file)
}
})
emitFiles(merged)
}
}
const onFileChange = (event) => {
handleFiles(event.target.files || [])
event.target.value = ''
}
const onDragOver = () => {
dragActive.value = true
}
const onDragLeave = () => {
dragActive.value = false
}
const onDrop = (event) => {
dragActive.value = false
if (event.dataTransfer?.files?.length) {
handleFiles(event.dataTransfer.files)
}
}
const removeFile = (fileToRemove) => {
const filtered = internalFiles.value.filter(file => file !== fileToRemove)
emitFiles(filtered)
}
const formatSize = (size) => {
if (!size) { return '0 B' }
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.floor(Math.log(size) / Math.log(1024))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const getIcon = (file) => {
return getFileIcon({ name: file.name, mime: file.type })
}
onBeforeUnmount(() => {
previewUrls.forEach((url) => {
URL.revokeObjectURL(url)
})
previewUrls.clear()
})
</script>

View File

@@ -0,0 +1,138 @@
<template>
<dialog class="modal" :class="{ 'modal-open': open }">
<div class="modal-box max-w-3xl">
<form method="dialog" class="modal-close" @submit.prevent />
<h3 class="font-bold text-lg mb-2">
Préparer l'impression
</h3>
<p class="text-sm text-base-content/70 mb-4">
Choisissez les sections à inclure avant de lancer l'impression.
</p>
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" class="btn btn-xs btn-outline" @click="emit('select-all')">
Tout sélectionner
</button>
<button type="button" class="btn btn-xs btn-outline" @click="emit('deselect-all')">
Tout désélectionner
</button>
</div>
<div class="max-h-[420px] overflow-y-auto pr-2 space-y-6">
<section class="bg-base-200/50 rounded-xl p-4 space-y-3">
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
Machine
</h4>
<label class="flex items-start gap-3">
<input
v-model="selection.machine.info"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div>
<p class="font-medium">Informations générales</p>
<p class="text-xs text-base-content/60">
Nom, site et fournisseur de la machine.
</p>
</div>
</label>
<label class="flex items-start gap-3">
<input
v-model="selection.machine.customFields"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div>
<p class="font-medium">Champs personnalisés</p>
<p class="text-xs text-base-content/60">
Valeurs spécifiques configurées pour cette machine.
</p>
</div>
</label>
<label class="flex items-start gap-3">
<input
v-model="selection.machine.documents"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div>
<p class="font-medium">Documents</p>
<p class="text-xs text-base-content/60">
Pièces jointes liées directement à la machine.
</p>
</div>
</label>
</section>
<section v-if="hasComponents" class="bg-base-200/30 rounded-xl p-4 space-y-3">
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
Composants & pièces
</h4>
<div class="space-y-3">
<MachinePrintSelectionNode
v-for="component in componentsList"
:key="component.id"
:component="component"
:selection="selection"
/>
</div>
</section>
<section v-if="hasPieces" class="bg-base-200/30 rounded-xl p-4 space-y-3">
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
Pièces indépendantes
</h4>
<div class="space-y-2">
<label
v-for="piece in piecesList"
:key="piece.id"
class="flex items-start gap-3"
>
<input
v-model="selection.pieces[piece.id]"
type="checkbox"
class="checkbox checkbox-secondary mt-1"
>
<div>
<p class="font-medium">{{ piece.name }}</p>
<p class="text-xs text-base-content/60">
{{ piece.reference || 'Référence inconnue' }}
</p>
</div>
</label>
</div>
</section>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="emit('close')">
Annuler
</button>
<button type="button" class="btn btn-primary" @click="emit('confirm')">
Imprimer
</button>
</div>
</div>
</dialog>
</template>
<script setup>
import { computed, toRef } from 'vue'
import MachinePrintSelectionNode from '~/components/MachinePrintSelectionNode.vue'
const props = defineProps({
open: { type: Boolean, default: false },
selection: { type: Object, required: true },
components: { type: Array, default: () => [] },
pieces: { type: Array, default: () => [] }
})
const emit = defineEmits(['close', 'confirm', 'select-all', 'deselect-all'])
const selection = toRef(props, 'selection')
const componentsList = computed(() => props.components || [])
const piecesList = computed(() => props.pieces || [])
const hasComponents = computed(() => componentsList.value.length > 0)
const hasPieces = computed(() => piecesList.value.length > 0)
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="rounded-lg border border-base-300 bg-base-100/80 p-3 space-y-3">
<label class="flex items-start gap-3">
<input
v-model="selection.components[component.id]"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div class="flex-1">
<p class="font-medium">{{ component.name }}</p>
<p v-if="component.reference" class="text-xs text-base-content/60">
{{ component.reference }}
</p>
</div>
</label>
<div v-if="childPieces.length" class="pl-6 space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">
Pièces
</p>
<label
v-for="piece in childPieces"
:key="piece.id"
class="flex items-start gap-3"
>
<input
v-model="selection.pieces[piece.id]"
type="checkbox"
class="checkbox checkbox-secondary mt-1"
>
<div>
<p class="font-medium">{{ piece.name }}</p>
<p class="text-xs text-base-content/60">
{{ piece.reference || 'Référence inconnue' }}
</p>
</div>
</label>
</div>
<div v-if="childComponents.length" class="pl-6 space-y-3 border-l border-base-200">
<MachinePrintSelectionNode
v-for="child in childComponents"
:key="child.id"
:component="child"
:selection="selection"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
defineOptions({ name: 'MachinePrintSelectionNode' })
const props = defineProps({
component: { type: Object, required: true },
selection: { type: Object, required: true }
})
const childComponents = computed(() => props.component.subcomponents || props.component.subComponents || [])
const childPieces = computed(() => props.component.pieces || [])
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2 text-sm text-base-content/60">
<span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span>
<span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span>
<span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span>
<span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-base-content/50">
Structure vide
</span>
</div>
<details class="collapse collapse-arrow bg-base-200">
<summary class="collapse-title text-sm font-medium">
Voir la structure JSON
</summary>
<div class="collapse-content">
<pre class="mockup-code whitespace-pre-wrap text-xs bg-base-300 p-4 rounded">
<code>{{ formatted }}</code>
</pre>
</div>
</details>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computeStructureStats } from '~/shared/modelUtils'
const props = defineProps({
structure: {
type: Object,
default: () => ({})
}
})
const stats = computed(() => computeStructureStats(props.structure))
const formatted = computed(() => JSON.stringify(props.structure ?? {}, null, 2))
</script>

View File

@@ -0,0 +1,81 @@
<template>
<section :class="sectionClasses">
<div :class="contentClasses">
<div :class="['space-y-3', maxWidthClass]">
<component :is="headingTag" v-if="title" class="text-4xl font-bold tracking-tight">
{{ title }}
</component>
<p v-if="subtitle" class="text-sm opacity-80 leading-relaxed">
{{ subtitle }}
</p>
<slot />
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
},
gradientFrom: {
type: String,
default: 'from-primary'
},
gradientTo: {
type: String,
default: 'to-secondary'
},
minHeight: {
type: String,
default: 'min-h-[25vh]'
},
maxWidth: {
type: String,
default: 'max-w-xl'
},
rounded: {
type: Boolean,
default: false
},
alignment: {
type: String,
default: 'center',
validator: value => ['center', 'start', 'end'].includes(value)
},
headingTag: {
type: String,
default: 'h1'
}
})
const sectionClasses = computed(() => {
const classes = ['hero', 'bg-gradient-to-br', props.gradientFrom, props.gradientTo, props.minHeight]
if (props.rounded) {
classes.push('rounded-xl', 'overflow-hidden')
}
return classes
})
const contentClasses = computed(() => {
const base = ['hero-content', 'text-neutral-content']
if (props.alignment === 'center') {
base.push('text-center')
} else if (props.alignment === 'start') {
base.push('justify-start', 'text-left')
} else if (props.alignment === 'end') {
base.push('justify-end', 'text-right')
}
return base
})
const maxWidthClass = computed(() => props.maxWidth)
</script>

View File

@@ -0,0 +1,578 @@
<template>
<div class="space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
<div class="flex items-start gap-3 flex-1 min-w-0">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
:class="{ 'rotate-90': !isCollapsed }"
:aria-expanded="!isCollapsed"
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
@click="toggleCollapse"
>
<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&nbsp;: {{ displayProductName }}
</span>
</div>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer cette pièce"
@click="$emit('delete')"
>
Supprimer
</button>
</div>
<div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control">
<label class="label">
<span class="label-text text-sm">Quantité</span>
</label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm md:input-md w-24"
@blur="updatePiece"
/>
</div>
<div v-else-if="displayQuantity > 1">
<span class="font-medium">Quantité:</span>
<span class="ml-2">{{ displayQuantity }}</span>
</div>
<div>
<span class="font-medium">Référence:</span>
<input
v-if="isEditMode"
:id="`piece-reference-${piece.id}`"
v-model="pieceData.reference"
type="text"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
pieceData.reference || "Non définie"
}}</span>
</div>
<div v-if="pieceData.referenceAuto">
<span class="font-medium">Référence auto:</span>
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
</div>
<div>
<span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2">
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
>
<span class="font-medium">
{{ 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
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-base-content/50"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
<ConstructeurSelect
v-else
class="w-full"
:model-value="pieceConstructeurIds"
:initial-options="pieceConstructeursDisplay"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange"
/>
</div>
<div>
<span class="font-medium">Prix:</span>
<input
v-if="isEditMode"
:id="`piece-prix-${piece.id}`"
v-model="pieceData.prix"
type="number"
step="0.01"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
pieceData.prix ? `${pieceData.prix}` : "Non défini"
}}</span>
</div>
<div>
<span class="font-medium">Produit catalogue:</span>
<div v-if="isEditMode" class="mt-2 space-y-2">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div>
<div class="ml-2">
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
<span class="font-semibold">{{ info.label }} :</span>
<span class="ml-1">{{ info.value }}</span>
</p>
<ProductDocumentsInline
:documents="productDocuments"
@preview="openPreview"
/>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
</div>
</div>
</div>
<!-- Champs personnalisés de la pièce -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span
v-if="isEditMode && selectedFiles.length"
class="badge badge-outline"
>
{{ selectedFiles.length }} fichier{{
selectedFiles.length > 1 ? "s" : ""
}}
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
</span>
</div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement des documents...
</p>
<DocumentUpload
v-if="isEditMode"
v-model="selectedFiles"
title="Déposer des fichiers pour cette pièce"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentListInline
:documents="pieceDocuments"
:can-delete="isEditMode"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
resolveFieldId,
resolveFieldReadOnly,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
// --- Local reactive data for editing ---
const pieceData = reactive({
name: props.piece.name || '',
reference: props.piece.reference || '',
referenceAuto: props.piece.referenceAuto || null,
prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
})
// --- Products ---
const { products, loadProducts, getProduct } = useProducts()
const selectedProduct = computed(() => {
const id = pieceData.productId
if (!id) return null
const list = Array.isArray(products.value) ? products.value : []
const cached = list.find((p) => p && p.id === id) || null
if (cached) return cached
const current = props.piece.product
if (current && current.id === id) return current
return null
})
// --- Shared composables ---
const {
documents: pieceDocuments,
selectedFiles,
uploadingDocuments,
loadingDocuments,
previewDocument,
previewVisible,
openPreview,
closePreview,
refreshDocuments,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
const {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
const {
displayedCustomFields,
updateCustomField,
} = 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 ---
const { constructeurs } = useConstructeurs()
const pieceConstructeurLinks = computed(() =>
parseConstructeurLinksFromApi(
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
),
)
const supplierReferenceMap = computed(() => {
const map = new Map()
pieceConstructeurLinks.value.forEach(l => {
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
})
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) =>
formatConstructeurContactSummary(constructeur)
const handleConstructeurChange = (value) => {
const ids = uniqueConstructeurIds(value)
props.piece.constructeurIds = [...ids]
props.piece.constructeurId = null
props.piece.constructeur = null
props.piece.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
)
updatePiece()
}
// --- Product handling ---
const ensureProductLoaded = async (id) => {
if (!id) return null
const list = Array.isArray(products.value) ? products.value : []
const cached = list.find((p) => p && p.id === id)
if (cached) return cached
const result = await getProduct(id, { force: true })
return result.success && result.data ? result.data : null
}
const handleProductChange = async (value) => {
const nextId = value || null
pieceData.productId = nextId
props.piece.productId = nextId
if (!nextId) {
props.piece.product = null
updatePiece()
return
}
const resolved = await ensureProductLoaded(nextId)
if (resolved) {
props.piece.product = resolved
const supplierPrice = resolved.supplierPrice
if (
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
supplierPrice !== null && supplierPrice !== undefined
) {
const number = Number(supplierPrice)
if (!Number.isNaN(number)) pieceData.prix = String(number)
}
}
updatePiece()
}
// --- Custom field event handlers ---
const handleCustomFieldInput = (field, value) => {
if (resolveFieldReadOnly(field)) return
const fieldValueId = resolveFieldId(field)
if (!fieldValueId) return
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
if (fieldValue) fieldValue.value = value
}
const handleCustomFieldBlur = async (field) => {
await updateCustomField(field)
const cfId = field?.customFieldId || field?.customField?.id || null
if (cfId || field?.customFieldValueId) {
emit('custom-field-update', {
fieldId: cfId,
pieceId: props.piece.id,
value: field?.value ?? '',
})
}
}
// --- Update piece ---
const updatePiece = () => {
const prixValue = pieceData.prix
let parsedPrice = null
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
const numeric = Number(prixValue)
if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
}
const product = selectedProduct.value ? { ...selectedProduct.value } : null
emit('update', {
...props.piece,
...pieceData,
prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
})
}
// --- Watchers ---
watch(
() => props.piece.product?.id || props.piece.productId || null,
async (id) => {
if (pieceData.productId === id) {
if (id && !selectedProduct.value) {
const resolved = await ensureProductLoaded(id)
if (resolved) props.piece.product = resolved
}
if (!id) props.piece.product = null
return
}
pieceData.productId = id
if (id) {
const resolved = await ensureProductLoaded(id)
if (resolved) {
props.piece.product = resolved
if (
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
resolved.supplierPrice !== null && resolved.supplierPrice !== undefined
) {
const number = Number(resolved.supplierPrice)
if (!Number.isNaN(number)) pieceData.prix = String(number)
}
}
} else {
props.piece.product = null
}
},
{ immediate: true },
)
watch(
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
},
)
onMounted(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments()
})
</script>

View File

@@ -0,0 +1,186 @@
<template>
<div class="space-y-6">
<section class="space-y-3">
<header>
<h3 class="text-sm font-semibold">
Produits inclus par défaut
</h3>
<p class="text-xs text-base-content/70">
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
</p>
</header>
<p v-if="!products.length" class="text-xs text-base-content/50">
Aucun produit défini.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(product, index) in products"
:key="product.uid"
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Famille de produit</span>
</label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypeOptions"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</li>
</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 class="space-y-3">
<h3 class="text-sm font-semibold">
Champs personnalisés
</h3>
<p v-if="!fields.length" class="text-xs text-base-content/50">
Aucun champ personnalisé n'a encore été défini.
</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-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&#10;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" />
</button>
</div>
</li>
</ul>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
</div>
</template>
<script setup lang="ts">
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
defineOptions({ name: 'PieceModelStructureEditor' })
const props = defineProps<{
modelValue?: PieceModelStructure | null
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void
}>()
const {
fields,
products,
productTypeOptions,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,
removeProduct,
addField,
removeField,
reorderClass,
onDragStart,
onDragEnter,
onDrop,
onDragEnd,
} = usePieceStructureEditorLogic({ props, emit })
</script>

View 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>

View 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>

View File

@@ -0,0 +1,130 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="productOptions"
: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 { useProducts } from '~/composables/useProducts'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typeProductId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner un produit…',
emptyText: 'Aucun produit disponible',
helperText: '',
disabled: false,
typeProductId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { loading: globalLoading, loadProducts } = useProducts()
const localProducts = ref<any[]>([])
const localLoading = ref(false)
const loading = computed(() => localLoading.value || globalLoading.value)
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
}
}
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const handleSearch = (term: string) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => loadFilteredProducts(term.trim()), 300)
}
onMounted(() => {
loadFilteredProducts()
})
watch(
() => props.typeProductId,
() => {
loadFilteredProducts()
},
)
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 || 'Produit'
return option.reference ? `${name}${option.reference}` : name
}
const formatDescription = (option: any) => {
const parts: string[] = []
const typeName = option?.typeProduct?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) {
parts.push(`Ref. ${option.reference}`)
}
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
const price = Number(option.supplierPrice)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

View File

@@ -0,0 +1,416 @@
<template>
<div :class="containerClass">
<div class="border border-base-200 rounded-lg bg-base-100 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-base-200 px-4 py-3">
<div class="flex-1 min-w-[220px] space-y-2">
<label class="label">
<span class="label-text text-xs font-semibold">
{{ isRoot ? 'Composant racine de la catégorie' : 'Famille de composant' }}
</span>
</label>
<template v-if="isRoot">
<p class="text-[11px] text-base-content/50">
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
</p>
</template>
<template v-else-if="!lockType">
<select
v-model="node.typeComposantId"
class="select select-bordered select-sm w-full"
:disabled="isLocked"
@change="handleComponentTypeSelect(node)"
>
<option value="">
Sélectionner une famille de composant
</option>
<option
v-for="type in componentTypes"
:key="type.id"
:value="type.id"
>
{{ formatComponentTypeOption(type) }}
</option>
</select>
<p class="text-[11px] text-base-content/50">
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p>
<div v-if="!isRoot" class="form-control mt-2">
<label class="label py-1">
<span class="label-text text-[11px]">Alias (optionnel)</span>
</label>
<input
v-model="node.alias"
type="text"
class="input input-bordered input-xs"
placeholder="Alias du sous-composant"
:disabled="isLocked"
/>
</div>
</template>
<template v-else>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ lockedTypeDisplay }}
</div>
</template>
</div>
<button
v-if="!isRoot && !isLocked"
type="button"
class="btn btn-error btn-xs btn-square"
@click="emit('remove')"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
<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 class="px-4 py-4 space-y-5">
<section v-if="isRoot" class="space-y-3">
<h4 :class="headingClass">
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
</h4>
<p v-if="!(node.customFields?.length)" class="text-xs text-base-content/50">
Aucun champ n'a encore été défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(field, index) in node.customFields"
:key="`field-${index}`"
class="border border-base-200 rounded-md p-3 space-y-2 transition-colors"
:class="customFieldReorderClass(index)"
draggable="true"
@dragstart="onCustomFieldDragStart(index, $event)"
@dragenter="onCustomFieldDragEnter(index)"
@dragover.prevent
@drop="onCustomFieldDrop(index)"
@dragend="onCustomFieldDragEnd"
>
<div class="flex items-start justify-between gap-2">
<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="true"
@dragstart.stop="onCustomFieldDragStart(index, $event)"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<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&#10;Option 2"
></textarea>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeCustomField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="isRoot" class="space-y-3">
<h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4>
<p v-if="!(node.products?.length)" class="text-xs text-base-content/50">
Aucun produit défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(product, index) in node.products"
:key="`product-${index}`"
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
:class="productReorderClass(index)"
@dragenter="onProductDragEnter(index)"
@dragover="onProductDragOver"
@drop="onProductDrop(index)"
>
<button
type="button"
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onProductDragStart(index, $event)"
@dragend="onProductDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Famille de produit</span></label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypes"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<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 v-if="isRoot" class="space-y-3">
<h4 :class="headingClass">
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
</h4>
<p v-if="!(node.pieces?.length)" class="text-xs text-base-content/50">
Aucune pièce définie.
</p>
<div v-else class="space-y-2">
<div
v-for="(piece, index) in node.pieces"
:key="`piece-${index}`"
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
:class="pieceReorderClass(index)"
@dragenter="onPieceDragEnter(index)"
@dragover="onPieceDragOver"
@drop="onPieceDrop(index)"
>
<button
type="button"
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onPieceDragStart(index, $event)"
@dragend="onPieceDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label"><span class="label-text">Famille de pièce</span></label>
<div>
<select
v-model="piece.typePieceId"
class="select select-bordered select-xs"
@change="handlePieceTypeSelect(piece)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in pieceTypes"
:key="type.id"
:value="type.id"
>
{{ formatPieceTypeOption(type) }}
</option>
</select>
</div>
<p class="mt-1 text-[11px] text-base-content/50">
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p>
</div>
<!-- Quantity is set per-component on the component edit page -->
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
<h4 :class="headingClass">Sous-composants</h4>
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-base-content/50">
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
</p>
<p v-if="!hasSubcomponents" class="text-xs text-base-content/50">
Aucun sous-composant défini.
</p>
<div v-else class="space-y-3">
<div
v-for="(subComponent, index) in node.subcomponents"
:key="`sub-${index}`"
class="relative pl-8 transition-shadow rounded-lg"
:class="subcomponentReorderClass(index)"
@dragenter="onSubcomponentDragEnter(index)"
@dragover="onSubcomponentDragOver"
@drop="onSubcomponentDrop(index)"
>
<button
type="button"
class="absolute left-0 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onSubcomponentDragStart(index, $event)"
@dragend="onSubcomponentDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<StructureNodeEditor
:node="subComponent"
:depth="depth + 1"
:component-types="componentTypes"
:piece-types="pieceTypes"
:product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
@remove="removeSubComponent(index)"
/>
</div>
</div>
<button
v-if="canManageSubcomponents"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
>
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import type { EditableStructureNode, ModelTypeOption } from '~/composables/useStructureNodeLogic'
defineOptions({ name: 'StructureNodeEditor' })
const props = withDefaults(defineProps<{
node: EditableStructureNode
depth?: number
componentTypes?: ModelTypeOption[]
pieceTypes?: ModelTypeOption[]
productTypes?: ModelTypeOption[]
isRoot?: boolean
lockType?: boolean
lockedTypeLabel?: string
allowSubcomponents?: boolean
maxSubcomponentDepth?: number
isLocked?: boolean
}>(), {
depth: 0,
componentTypes: () => [],
pieceTypes: () => [],
productTypes: () => [],
isRoot: false,
lockType: false,
lockedTypeLabel: '',
allowSubcomponents: true,
maxSubcomponentDepth: Infinity,
isLocked: false,
})
const emit = defineEmits(['remove'])
const {
isLocked,
componentTypes,
pieceTypes,
productTypes,
canManageSubcomponents,
childAllowSubcomponents,
hasSubcomponents,
containerClass,
headingClass,
lockedTypeDisplay,
getComponentTypeLabel,
getPieceTypeLabel,
formatComponentTypeOption,
formatPieceTypeOption,
formatProductTypeOption,
handleComponentTypeSelect,
handlePieceTypeSelect,
handleProductTypeSelect,
addCustomField,
removeCustomField,
addPiece,
removePiece,
addProduct,
removeProduct,
addSubComponent,
removeSubComponent,
onCustomFieldDragStart,
onCustomFieldDragEnter,
onCustomFieldDrop,
onCustomFieldDragEnd,
customFieldReorderClass,
onPieceDragStart,
onPieceDragEnter,
onPieceDragOver,
onPieceDrop,
onPieceDragEnd,
pieceReorderClass,
onProductDragStart,
onProductDragEnter,
onProductDragOver,
onProductDrop,
onProductDragEnd,
productReorderClass,
onSubcomponentDragStart,
onSubcomponentDragEnter,
onSubcomponentDragOver,
onSubcomponentDrop,
onSubcomponentDragEnd,
subcomponentReorderClass,
} = useStructureNodeLogic(props)
</script>

View File

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

View File

@@ -0,0 +1,114 @@
<template>
<div
class="toast-container pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2 items-end"
>
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast-item"
:class="[
'transform transition-all duration-300 ease-in-out',
toast.visible ? 'translate-y-0 opacity-100 pointer-events-auto' : 'translate-y-4 opacity-0 pointer-events-none'
]"
>
<div
class="alert toast-card shadow-md px-3 py-2 text-sm"
:class="getToastClasses(toast.type)"
>
<div class="flex items-center gap-2">
<!-- Icon -->
<div class="flex-shrink-0">
<IconLucideCheck
v-if="toast.type === 'success'"
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideCircleX
v-else-if="toast.type === 'error'"
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideAlertTriangle
v-else-if="toast.type === 'warning'"
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideInfo
v-else
class="w-4 h-4"
aria-hidden="true"
/>
</div>
<!-- Message -->
<div class="flex-1">
<span class="font-medium">{{ toast.message }}</span>
</div>
<!-- Close button -->
<button
class="btn btn-ghost btn-2xs"
@click="removeToast(toast.id)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import IconLucideCircleX from '~icons/lucide/circle-x'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideInfo from '~icons/lucide/info'
const { toasts, removeToast } = useToast()
const getToastClasses = (type) => {
switch (type) {
case 'success':
return 'alert-success text-success-content'
case 'error':
return 'alert-error text-error-content'
case 'warning':
return 'alert-warning text-warning-content'
case 'info':
return 'alert-info text-info-content'
default:
return 'alert-info'
}
}
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateY(16px);
}
.toast-leave-to {
opacity: 0;
transform: translateY(16px);
}
.toast-move {
transition: transform 0.3s ease;
}
.toast-card {
max-width: 20rem;
pointer-events: auto;
border-radius: 0.75rem;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<teleport to="body">
<div
v-if="confirmState.open"
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="handleCancel"
>
<div class="bg-base-100 rounded-box shadow-xl w-full max-w-md mx-4 p-6 space-y-4">
<h3 class="font-bold text-lg">
{{ confirmState.title }}
</h3>
<p class="whitespace-pre-line text-base-content/80">
{{ confirmState.message }}
</p>
<div class="flex justify-end gap-2 pt-2">
<button
class="btn btn-ghost btn-sm"
@click="handleCancel"
>
{{ confirmState.cancelText }}
</button>
<button
class="btn btn-sm"
:class="confirmState.dangerous ? 'btn-error' : 'btn-primary'"
@click="handleConfirm"
>
{{ confirmState.confirmText }}
</button>
</div>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { useConfirm } from '~/composables/useConfirm'
const { confirmState, handleConfirm, handleCancel } = useConfirm()
</script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>&middot;</span>
<span>{{ formatDate(entry.createdAt) }}</span>
<span v-if="entry.actor">&middot; {{ 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>

View File

@@ -0,0 +1,128 @@
<template>
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(1)"
>
<IconLucideChevronFirst class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
<IconLucideChevronLeft class="w-4 h-4" />
</button>
<template v-for="page in visiblePages" :key="page">
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
<button
v-else
type="button"
class="btn btn-sm"
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
@click="goToPage(page)"
>
{{ page }}
</button>
</template>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
<IconLucideChevronRight class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(totalPages)"
>
<IconLucideChevronLast class="w-4 h-4" />
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalPages: {
type: Number,
required: true
},
maxVisiblePages: {
type: Number,
default: 5
}
})
const emit = defineEmits(['update:currentPage'])
const visiblePages = computed(() => {
const pages = []
const total = props.totalPages
const current = props.currentPage
const maxVisible = props.maxVisiblePages
if (total <= maxVisible + 2) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
return pages
}
// Always show first page
pages.push(1)
const half = Math.floor(maxVisible / 2)
let start = Math.max(2, current - half)
let end = Math.min(total - 1, current + half)
// Adjust if near start
if (current <= half + 1) {
end = maxVisible
}
// Adjust if near end
if (current >= total - half) {
start = total - maxVisible + 1
}
if (start > 2) {
pages.push('ellipsis-start')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < total - 1) {
pages.push('ellipsis-end')
}
// Always show last page
pages.push(total)
return pages
})
const goToPage = (page) => {
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
emit('update:currentPage', page)
}
}
</script>

View File

@@ -0,0 +1,406 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-lg">{{ labels.headerTitle }}</h3>
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
{{ labels.addButton }}
</button>
</div>
<p class="text-sm text-base-content/50">
{{ labels.description }}
</p>
<div v-if="requirements.length === 0" class="text-sm text-base-content/50 bg-base-200/60 rounded-md p-4">
{{ labels.emptyState }}
</div>
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="relative border border-base-200 rounded-lg p-4 pl-12 space-y-3 transition-colors"
:class="requirementReorderClass(index)"
@dragenter="onRequirementDragEnter(index)"
@dragover="onRequirementDragOver"
@drop="onRequirementDrop(index)"
>
<button
type="button"
class="absolute left-3 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onRequirementDragStart(index, $event)"
@dragend="onRequirementDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.typeSelectLabel }}</span>
<span class="label-text-alt text-error">*</span>
</label>
<SearchSelect
:model-value="normalizeTypeModel(requirement[typeField])"
:options="typeOptions"
:loading="typeLoading"
size="sm"
:placeholder="labels.typePlaceholder"
:empty-text="typeOptions.length ? 'Aucun résultat' : 'Aucune option disponible'"
:option-label="optionLabel"
:option-description="optionDescription"
@update:modelValue="(value) => updateRequirement(index, { [typeField]: normalizeTypeValue(value) })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.labelFieldLabel }}</span>
<span v-if="labels.labelFieldHelper" class="label-text-alt text-xs">{{ labels.labelFieldHelper }}</span>
</label>
<input
:value="requirement.label ?? ''"
type="text"
class="input input-bordered input-sm"
:placeholder="labels.labelPlaceholder"
@input="handleLabelInput(index, $event)"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.minLabel }}</span>
</label>
<input
:value="requirement.minCount ?? minFallback"
type="number"
min="0"
class="input input-bordered input-sm"
@input="handleMinInput(index, $event)"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.maxLabel }}</span>
<span v-if="labels.maxHelper" class="label-text-alt text-xs">{{ labels.maxHelper }}</span>
</label>
<input
:value="requirement.maxCount ?? ''"
type="number"
min="0"
class="input input-bordered input-sm"
@input="handleMaxInput(index, $event)"
/>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
@click="removeRequirement(index)"
>
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="(requirement.required ?? requiredFallback) === true"
@change="handleRequiredChange(index, $event)"
/>
{{ labels.requiredLabel }}
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
@change="handleAllowNewModelsChange(index, $event)"
/>
{{ labels.allowNewModelsLabel }}
</label>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { PropType } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
type Option = {
id: string | number
name: string
description?: string | null
}
type Requirement = Record<string, unknown> & {
id?: string | number
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
orderIndex?: number | 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 PropType<Requirement[]>,
default: () => [],
},
typeOptions: {
type: Array as PropType<Option[]>,
default: () => [],
},
typeField: {
type: String,
required: true,
},
labels: {
type: Object as PropType<Labels>,
required: true,
},
defaultRequirement: {
type: Function as PropType<() => Requirement>,
required: true,
},
requiredFallback: {
type: Boolean,
default: false,
},
allowNewModelsFallback: {
type: Boolean,
default: true,
},
minFallback: {
type: Number,
default: 0,
},
typeLoading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const requirements = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const optionLabel = (option: Option) => {
if (!option) {
return ''
}
return option.name || ''
}
const optionDescription = (option: Option) => {
if (!option) {
return ''
}
if (typeof option.description === 'string' && option.description.trim()) {
return option.description.trim()
}
return ''
}
const applyOrderIndex = (list: Requirement[]): Requirement[] =>
list.map((item, index) => ({
...item,
orderIndex: index,
}))
const addRequirement = () => {
requirements.value = applyOrderIndex([
...requirements.value,
props.defaultRequirement(),
])
}
const removeRequirement = (index: number) => {
requirements.value = applyOrderIndex(
requirements.value.filter((_, i) => i !== index),
)
}
const updateRequirement = (index: number, patch: Partial<Requirement>) => {
requirements.value = applyOrderIndex(
requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item,
),
)
}
const parseNumber = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const parseOptionalNumber = (value: string) => {
if (value === '' || value === null || value === undefined) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
// Type-safe event handlers
const getInputValue = (event: Event): string => {
const target = event.target as HTMLInputElement | null
return target?.value ?? ''
}
const getCheckboxValue = (event: Event): boolean => {
const target = event.target as HTMLInputElement | null
return target?.checked ?? false
}
const handleLabelInput = (index: number, event: Event) => {
updateRequirement(index, { label: getInputValue(event) })
}
const handleMinInput = (index: number, event: Event) => {
updateRequirement(index, { minCount: parseNumber(getInputValue(event)) })
}
const handleMaxInput = (index: number, event: Event) => {
updateRequirement(index, { maxCount: parseOptionalNumber(getInputValue(event)) })
}
const handleRequiredChange = (index: number, event: Event) => {
updateRequirement(index, { required: getCheckboxValue(event) })
}
const handleAllowNewModelsChange = (index: number, event: Event) => {
updateRequirement(index, { allowNewModels: getCheckboxValue(event) })
}
const draggingRequirementIndex = ref<number | null>(null)
const requirementDropTargetIndex = ref<number | null>(null)
const resetRequirementDragState = () => {
draggingRequirementIndex.value = null
requirementDropTargetIndex.value = null
}
const reorderRequirements = (from: number, to: number) => {
const list = requirements.value
if (!Array.isArray(list)) {
resetRequirementDragState()
return
}
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetRequirementDragState()
return
}
const updated = list.slice() as Requirement[]
const [moved] = updated.splice(from, 1)
if (!moved) {
resetRequirementDragState()
return
}
updated.splice(to, 0, moved)
requirements.value = applyOrderIndex(updated)
resetRequirementDragState()
}
const onRequirementDragStart = (index: number, event: DragEvent) => {
draggingRequirementIndex.value = index
requirementDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onRequirementDragEnter = (index: number) => {
if (draggingRequirementIndex.value === null) {
return
}
requirementDropTargetIndex.value = index
}
const onRequirementDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onRequirementDrop = (index: number) => {
if (draggingRequirementIndex.value === null) {
resetRequirementDragState()
return
}
reorderRequirements(draggingRequirementIndex.value, index)
}
const onRequirementDragEnd = () => {
resetRequirementDragState()
}
const requirementReorderClass = (index: number) => {
if (draggingRequirementIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingRequirementIndex.value !== null &&
requirementDropTargetIndex.value === index &&
draggingRequirementIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const normalizeTypeModel = (value: unknown) => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string' || typeof value === 'number') {
return value
}
return ''
}
const normalizeTypeValue = (value: string | number | null | undefined) => {
if (value === '' || value === null || value === undefined) {
return null
}
return value
}
</script>
<!--
Éditeur générique de groupes de contraintes (pièces/composants) pour les types de machine.
Paramétrer les libellés et la structure via les props pour réutiliser ce bloc.
-->

View File

@@ -0,0 +1,362 @@
<template>
<div class="space-y-1 search-select">
<label v-if="$slots.label" class="label">
<span class="label-text">
<slot name="label" />
</span>
</label>
<div class="relative">
<input
ref="inputRef"
v-model="searchTerm"
type="text"
:placeholder="placeholder"
:class="inputClasses"
@focus="handleFocus"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrevious"
@keydown.enter.prevent="selectHighlighted"
@input="handleInput"
>
<button
v-if="clearable && modelValue"
type="button"
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
aria-label="Effacer la sélection"
@click.stop="clearSelection"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
<button
type="button"
:class="toggleButtonClasses"
@click="toggleDropdown"
aria-label="Afficher les options"
>
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
</button>
<transition name="fade">
<div
v-if="openDropdown"
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
>
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-base-content/50">
<span class="loading loading-spinner loading-xs" />
Recherche en cours
</div>
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
{{ emptyText }}
</div>
<ul v-else class="flex flex-col">
<li
v-for="(option, index) in displayedOptions"
:key="resolveValue(option) ?? index"
>
<button
type="button"
class="flex w-full flex-col items-start gap-1 px-3 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
:class="{
'bg-base-200': isOptionSelected(option),
'bg-base-300/60': highlightedIndex === index
}"
@mouseenter="highlightedIndex = index"
@mouseleave="highlightedIndex = -1"
@click="selectOption(option)"
>
<span class="font-medium text-sm">
<slot name="option-label" :option="option">
{{ resolveLabel(option) }}
</slot>
</span>
<span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
<slot name="option-description" :option="option">
{{ resolveDescription(option) }}
</slot>
</span>
</button>
</li>
</ul>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
options: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: 'Rechercher…'
},
emptyText: {
type: String,
default: 'Aucun résultat'
},
loading: {
type: Boolean,
default: false
},
optionValue: {
type: [String, Function],
default: 'id'
},
optionLabel: {
type: [String, Function],
default: 'name'
},
optionDescription: {
type: [String, Function],
default: null
},
clearable: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
validator: (value) => ['xs', 'sm', 'md', 'lg'].includes(value)
},
maxVisible: {
type: Number,
default: 50
},
serverSearch: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'search'])
const searchTerm = ref('')
const openDropdown = ref(false)
const highlightedIndex = ref(-1)
const inputRef = ref(null)
const baseOptions = computed(() => Array.isArray(props.options) ? props.options : [])
const selectedOption = computed(() => {
return baseOptions.value.find(option => isEqualValue(resolveValue(option), props.modelValue)) || null
})
const displayedOptions = computed(() => {
const items = baseOptions.value.slice()
const filtered = (!props.serverSearch && searchTerm.value.trim())
? items.filter((option) => {
const term = searchTerm.value.trim().toLowerCase()
const label = resolveLabel(option).toLowerCase()
const description = resolveDescription(option)?.toLowerCase() || ''
return label.includes(term) || description.includes(term)
})
: items
if (props.maxVisible && filtered.length > props.maxVisible) {
return filtered.slice(0, props.maxVisible)
}
return filtered
})
const inputClasses = computed(() => {
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
const base = ['input', 'input-bordered', 'w-full', pr]
if (props.size === 'xs') base.push('input-xs')
if (props.size === 'sm') base.push('input-sm')
if (props.size === 'lg') base.push('input-lg')
return base.join(' ')
})
const toggleButtonClasses = computed(() => {
const base = ['absolute', 'top-1/2', '-translate-y-1/2', 'right-2', 'btn', 'btn-ghost']
if (props.size === 'xs' || props.size === 'sm') {
base.push('btn-xs')
} else {
base.push('btn-sm')
}
return base.join(' ')
})
watch(
() => props.modelValue,
() => {
if (!openDropdown.value) {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
},
{ immediate: true }
)
watch(
baseOptions,
(_newOptions) => {
if (!openDropdown.value && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
},
{ deep: true }
)
watch(openDropdown, (isOpen) => {
if (isOpen) {
highlightedIndex.value = -1
}
})
function resolveValue (option) {
if (!option) {
return null
}
if (typeof props.optionValue === 'function') {
return props.optionValue(option)
}
return option[props.optionValue]
}
function resolveLabel (option) {
if (!option) {
return ''
}
if (typeof props.optionLabel === 'function') {
return props.optionLabel(option) || ''
}
return option[props.optionLabel] || ''
}
function resolveDescription (option) {
if (!option || !props.optionDescription) {
return ''
}
if (typeof props.optionDescription === 'function') {
return props.optionDescription(option) || ''
}
return option[props.optionDescription] || ''
}
function isEqualValue (a, b) {
if (a === b) {
return true
}
return String(a ?? '') === String(b ?? '')
}
function isOptionSelected (option) {
return isEqualValue(resolveValue(option), props.modelValue)
}
function selectOption (option) {
emit('update:modelValue', resolveValue(option) ?? '')
searchTerm.value = resolveLabel(option)
openDropdown.value = false
}
function handleFocus () {
openDropdown.value = true
if (searchTerm.value === '' && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}
function toggleDropdown () {
openDropdown.value = !openDropdown.value
if (openDropdown.value && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
if (openDropdown.value && inputRef.value) {
inputRef.value.focus()
}
}
function handleInput () {
if (!openDropdown.value) {
openDropdown.value = true
}
emit('search', searchTerm.value)
}
function clearSelection () {
emit('update:modelValue', '')
searchTerm.value = ''
openDropdown.value = false
}
function closeDropdown () {
openDropdown.value = false
if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}
function highlightNext () {
if (!openDropdown.value || displayedOptions.value.length === 0) {
return
}
highlightedIndex.value = (highlightedIndex.value + 1) % displayedOptions.value.length
}
function highlightPrevious () {
if (!openDropdown.value || displayedOptions.value.length === 0) {
return
}
highlightedIndex.value =
highlightedIndex.value <= 0
? displayedOptions.value.length - 1
: highlightedIndex.value - 1
}
function selectHighlighted () {
if (!openDropdown.value) {
return
}
if (highlightedIndex.value >= 0 && highlightedIndex.value < displayedOptions.value.length) {
selectOption(displayedOptions.value[highlightedIndex.value])
}
}
const handleGlobalClick = (event) => {
if (!openDropdown.value) {
return
}
const target = event.target
if (target?.closest?.('.search-select')) {
return
}
closeDropdown()
}
onMounted(() => {
window.addEventListener('click', handleGlobalClick)
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleGlobalClick)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.12s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View 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"> &bull; Obligatoire</span>
<span v-if="Array.isArray(field.options) && field.options.length">
&bull; Options : {{ field.options.join(', ') }}
</span>
<span v-if="field.defaultValue">
&bull; 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>

View 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>

View File

@@ -0,0 +1,112 @@
<template>
<div class="form-control">
<label v-if="label" class="label" :for="inputId">
<span class="label-text">{{ label }}</span>
<span v-if="required" class="label-text-alt text-error">*</span>
</label>
<input
:id="inputId"
:value="modelValue"
type="email"
class="input input-bordered"
:class="{ 'input-error': Boolean(errorMessage) }"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:name="name"
:autocomplete="autocomplete"
:pattern="EMAIL_INPUT_PATTERN"
:aria-invalid="Boolean(errorMessage)"
:aria-describedby="describedBy"
@input="onInput"
@blur="onBlur"
@focus="(event) => emit('focus', event)"
/>
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
{{ help }}
</p>
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
{{ errorMessage }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, useId } from 'vue'
import { normalizeEmail } from '~/utils/formatters/email'
import { EMAIL_INPUT_PATTERN, EMAIL_VALIDATION_ERROR, emailSchema } from '~/shared/validation/email'
type Emits = {
(event: 'update:modelValue', value: string): void
(event: 'blur', value: FocusEvent): void
(event: 'focus', value: FocusEvent): void
}
const emit = defineEmits<Emits>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
required?: boolean
error?: string | null
help?: string | null
placeholder?: string
disabled?: boolean
name?: string
id?: string
autocomplete?: string
normalizeOnBlur?: boolean
validateOnBlur?: boolean
}>(),
{
modelValue: '',
label: 'Email',
required: false,
error: null,
help: null,
placeholder: 'ex: contact@example.com',
disabled: false,
name: undefined,
id: undefined,
autocomplete: 'email',
normalizeOnBlur: false,
validateOnBlur: false,
}
)
const fallbackId = useId()
const inputId = computed(() => props.id || fallbackId)
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
const errorMessage = computed(() => props.error || null)
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const onBlur = (event: FocusEvent) => {
const target = event.target as HTMLInputElement
if (props.normalizeOnBlur) {
const normalized = normalizeEmail(target.value)
if (normalized !== target.value) {
target.value = normalized
emit('update:modelValue', normalized)
}
}
if (props.validateOnBlur && !errorMessage.value) {
const validation = emailSchema.validate(target.value)
if (!validation.valid) {
target.setCustomValidity(EMAIL_VALIDATION_ERROR)
} else {
target.setCustomValidity('')
}
}
emit('blur', event)
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="form-control">
<label v-if="label" class="label" :for="inputId">
<span class="label-text">{{ label }}</span>
<span v-if="required" class="label-text-alt text-error">*</span>
</label>
<input
:id="inputId"
:value="modelValue"
type="tel"
class="input input-bordered"
:class="{ 'input-error': Boolean(errorMessage) }"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:name="name"
:autocomplete="autocomplete"
:pattern="PHONE_INPUT_PATTERN"
inputmode="tel"
:aria-invalid="Boolean(errorMessage)"
:aria-describedby="describedBy"
@input="onInput"
@blur="onBlur"
@focus="(event) => emit('focus', event)"
/>
<p v-if="help" :id="helpId" class="mt-2 text-xs text-base-content/50">
{{ help }}
</p>
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
{{ errorMessage }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, useId } from 'vue'
import { formatPhone } from '~/utils/formatters/phone'
import { PHONE_INPUT_PATTERN, PHONE_VALIDATION_ERROR, phoneSchema } from '~/shared/validation/phone'
type Emits = {
(event: 'update:modelValue', value: string): void
(event: 'blur', value: FocusEvent): void
(event: 'focus', value: FocusEvent): void
}
const emit = defineEmits<Emits>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
required?: boolean
error?: string | null
help?: string | null
placeholder?: string
disabled?: boolean
name?: string
id?: string
autocomplete?: string
normalizeOnBlur?: boolean
validateOnBlur?: boolean
}>(),
{
modelValue: '',
label: 'Téléphone',
required: false,
error: null,
help: null,
placeholder: 'Ex: 06 00 00 00 00',
disabled: false,
name: undefined,
id: undefined,
autocomplete: 'tel',
normalizeOnBlur: false,
validateOnBlur: false,
}
)
const fallbackId = useId()
const inputId = computed(() => props.id || fallbackId)
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
const errorMessage = computed(() => props.error || null)
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const onBlur = (event: FocusEvent) => {
const target = event.target as HTMLInputElement
if (props.normalizeOnBlur) {
const formatted = formatPhone(target.value)
if (formatted !== target.value) {
target.value = formatted
emit('update:modelValue', formatted)
}
}
if (props.validateOnBlur && !errorMessage.value) {
const validation = phoneSchema.validate(target.value)
if (!validation.valid) {
target.setCustomValidity(PHONE_VALIDATION_ERROR)
} else {
target.setCustomValidity('')
}
}
emit('blur', event)
}
</script>

View 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>

View 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>

View File

@@ -0,0 +1,466 @@
<template>
<div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
<div class="navbar-start">
<!-- Mobile hamburger menu -->
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
</button>
</li>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
Paramètres d'affichage
</button>
</li>
<!-- Mobile: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
<!-- Mobile: dropdown groups -->
<li
v-for="group in navGroups"
:key="group.id + '-mobile'"
class="mt-1 border-t border-base-200 pt-2"
>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-mobile'"
@click="toggleDropdown(group.id + '-mobile')"
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
>
<span class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
</span>
<IconLucideChevronRight
class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === group.id + '-mobile'"
class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="rounded-md px-3 py-1.5 transition-colors block text-sm"
:class="childLinkClass(child)"
>
{{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-2.5 group">
<div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
<img
:src="logoSrc"
alt="Logo Malio"
class="h-full w-full object-contain"
/>
</div>
<span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
Inventory
</span>
</NuxtLink>
</div>
<!-- Desktop navbar -->
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal gap-0.5 px-1">
<!-- Desktop: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
<!-- Desktop: dropdown groups -->
<li
v-for="group in navGroups"
:key="group.id + '-desktop'"
class="relative"
@mouseenter="setDropdown(group.id + '-desktop')"
@mouseleave="scheduleDropdownClose(group.id + '-desktop')"
@focusin="setDropdown(group.id + '-desktop')"
@focusout="scheduleDropdownClose(group.id + '-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 transition-all text-sm font-medium"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
>
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
<IconLucideChevronDown
class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === group.id + '-desktop'"
class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="block rounded-lg px-3 py-2 transition-all text-sm"
:class="childLinkClass(child)"
>
{{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Navbar end -->
<div class="navbar-end">
<div class="flex items-center gap-1.5">
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
:title="isDark ? 'Mode clair' : 'Mode sombre'"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
</button>
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
title="Paramètres d'affichage"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
</button>
<ClientOnly>
<div v-if="activeProfile" class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="indicator cursor-pointer"
>
<span
v-if="unresolvedCount > 0"
class="indicator-item badge badge-warning badge-xs"
>
{{ unresolvedCount }}
</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 }}
</span>
</div>
</div>
<ul
tabindex="0"
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-3 py-2">
<div class="flex flex-col gap-1 pointer-events-none">
<span class="text-xs text-base-content/50">Connecté en tant que</span>
<span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
</div>
</li>
<div class="divider my-0.5 px-2" />
<li v-if="isAdmin">
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
Administration
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink>
</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>
<button
type="button"
class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
@click="$emit('logout')"
>
Déconnexion
<IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</li>
</ul>
</div>
</ClientOnly>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
import { useProfileSession } from '~/composables/useProfileSession'
import { useComments } from '~/composables/useComments'
import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link'
import IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{
(e: 'open-settings'): void
(e: 'logout'): void
}>()
interface NavLink {
to: string
label: string
icon?: Component
}
interface NavGroup {
id: string
label: string
icon?: Component
activePaths: string[]
children: NavLink[]
}
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
]
const navGroups: NavGroup[] = [
{
id: 'component',
label: 'Composants',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{
id: 'pieces',
label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'],
children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
{ to: '/piece-category', label: 'Catégorie de pièce' },
],
},
{
id: 'products',
label: 'Produits',
icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'],
children: [
{ to: '/product-catalog', label: 'Catalogue des produits' },
{ to: '/product-category', label: 'Catégorie de produit' },
],
},
{
id: 'resources',
label: 'Ressources liées',
icon: IconLucideLink,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
children: [
{ to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/comments', label: 'Commentaires' },
{ to: '/activity-log', label: 'Journal d\'activité' },
],
},
]
const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
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) => {
if (path === '/') {
return route.path === '/'
}
return route.path.startsWith(path)
}
const isGroupActive = (group: NavGroup) => {
return group.activePaths.some((path) => isActive(path))
}
const linkClass = (link: NavLink) => {
return isActive(link.to)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const groupClass = (group: NavGroup) => {
return isGroupActive(group)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const childLinkClass = (child: NavLink) => {
return isActive(child.to)
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content/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(() => {
if (!activeProfile.value) {
return 'Profil inconnu'
}
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
})
const activeProfileInitials = computed(() => {
if (!activeProfile.value) {
return '??'
}
const { firstName = '', lastName = '' } = activeProfile.value
return (
`${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
)
})
</script>
<style scoped>
.nav-dropdown-desktop-enter-active,
.nav-dropdown-desktop-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.nav-dropdown-desktop-enter-from,
.nav-dropdown-desktop-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
.nav-dropdown-desktop-enter-to,
.nav-dropdown-desktop-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
.nav-dropdown-mobile-enter-active,
.nav-dropdown-mobile-leave-active {
transition: max-height 0.2s ease, opacity 0.2s ease;
}
.nav-dropdown-mobile-enter-from,
.nav-dropdown-mobile-leave-to {
max-height: 0;
opacity: 0;
}
.nav-dropdown-mobile-enter-to,
.nav-dropdown-mobile-leave-from {
max-height: 12rem;
opacity: 1;
}
</style>

View 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"
>
&times;
</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>

View File

@@ -0,0 +1,72 @@
<template>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<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 v-if="components.length === 0" class="text-sm text-gray-500 py-4">
Aucun composant associé à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="component in components" :key="component.id">
<ComponentHierarchy
:components="[component]"
:is-edit-mode="false"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@edit-piece="$emit('edit-piece', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
/>
</div>
</div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
</div>
</div>
</template>
<script setup lang="ts">
import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{
components: any[]
isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>()
defineEmits<{
'toggle-collapse': []
'update-component': [component: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-component': []
'remove-component': [linkId: string]
}>()
</script>

View 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&#10;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>

View File

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

View File

@@ -0,0 +1,67 @@
<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>
</div>
<div class="flex items-center gap-2 print:hidden" data-print-hide>
<button
@click="$emit('toggle-edit')"
class="btn btn-primary"
:class="{ 'btn-outline': isEditMode }"
>
<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
v-if="!isEditMode"
@click="$emit('open-print')"
type="button"
class="btn btn-outline btn-secondary"
>
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
Imprimer
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour aux machines
</button>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer'
const router = useRouter()
defineProps<{
title: string
isEditMode: boolean
}>()
defineEmits<{
'toggle-edit': []
'open-print': []
}>()
function goBack() {
if (window.history.length > 1) {
router.back()
}
else {
navigateTo('/machines')
}
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<div class="card bg-base-100 shadow-sm mt-6">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="card-title">Documents de la machine</h2>
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
</div>
<span v-if="isEditMode && files.length" class="badge badge-outline">
{{ files.length }} fichier{{ files.length > 1 ? 's' : '' }} sélectionné{{ files.length > 1 ? 's' : '' }}
</span>
</div>
<DocumentUpload
v-if="isEditMode"
:model-value="files"
@update:model-value="$emit('update:files', $event)"
title="Déposer des fichiers pour la machine"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="$emit('files-added', $event)"
/>
<div v-if="documents.length" class="space-y-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-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(doc)"
>
<img
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
:src="doc.fileUrl || doc.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${doc.name}`"
>
<iframe
v-else-if="shouldInlinePdf(doc)"
:src="documentPreviewSrc(doc)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(doc).component"
class="h-6 w-6"
:class="documentIcon(doc).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">{{ doc.name }}</div>
<div class="text-xs text-gray-500">
{{ doc.mimeType || 'Inconnu' }} &bull; {{ formatSize(doc.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<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="$emit('preview', doc)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="$emit('download', doc)">
Télécharger
</button>
<button
v-if="isEditMode"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="uploading"
@click="$emit('remove', doc.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">Aucun document lié à cette machine.</p>
</div>
</div>
</template>
<script setup lang="ts">
import DocumentUpload from '~/components/DocumentUpload.vue'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
documentIcon,
} from '~/shared/utils/documentDisplayUtils'
defineProps<{
documents: any[]
isEditMode: boolean
uploading: boolean
files: File[]
}>()
defineEmits<{
'update:files': [files: File[]]
'files-added': [files: File[]]
'preview': [doc: any]
'download': [doc: any]
'remove': [documentId: string]
}>()
</script>

View File

@@ -0,0 +1,230 @@
<template>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title tracking-tight">Informations de la machine</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom</span>
</label>
<input
v-if="isEditMode"
:id="getMachineFieldId('name')"
:value="machineName"
type="text"
class="input input-bordered"
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
/>
<div v-else class="input input-bordered bg-base-200">
{{ machineName }}
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
</label>
<select
v-if="isEditMode"
:value="machineSiteId"
class="select select-bordered"
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
>
<option value="">Sélectionner un site</option>
<option
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ site.name }}
</option>
</select>
<div v-else class="input input-bordered bg-base-200">
{{ machineSiteName || 'Non défini' }}
</div>
</div>
<div v-if="isEditMode || machineReference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
:id="getMachineFieldId('reference')"
:value="machineReference"
type="text"
class="input input-bordered"
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
/>
<div v-else class="input input-bordered bg-base-200">
{{ machineReference }}
</div>
</div>
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
class="w-full"
:model-value="machineConstructeurIds"
:initial-options="machineConstructeursDisplay"
placeholder="Rechercher un ou plusieurs fournisseurs..."
@update:modelValue="$emit('update:constructeur-ids', $event)"
/>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="!isEditMode"
@update:model-value="$emit('update:constructeur-links', $event)"
@remove="$emit('remove-constructeur-link', $event)"
/>
<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 class="text-base-content/50">Non défini</span>
</div>
</div>
</div>
<!-- Champs personnalisés -->
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-base-200">
<h4 class="font-semibold text-base-content/80 mb-3">Champs personnalisés de la machine</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<template v-if="isEditMode">
<input
v-if="field.type === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
/>
<input
v-else-if="field.type === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm"
:required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
/>
<select
v-else-if="field.type === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm"
:required="field.required"
@change="$emit('set-custom-field-value', 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">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
>
<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"
:required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
/>
<div v-else class="text-xs text-error">
Type de champ non pris en charge
</div>
</template>
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ formatCustomFieldValue(field) }}
</div>
</template>
</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>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
const props = defineProps<{
isEditMode: boolean
machineName: string
machineReference: string
machineSiteId: string
machineSiteName: string
sites: any[]
machineConstructeurIds: string[]
machineConstructeursDisplay: any[]
hasMachineConstructeur: boolean
constructeurLinks: ConstructeurLinkEntry[]
visibleCustomFields: any[]
getMachineFieldId: (fieldName: string) => string
machineId: string
machineCustomFieldDefs: any[]
}>()
const emit = defineEmits<{
'update:machine-name': [value: string]
'update:machine-reference': [value: string]
'update:machine-site-id': [value: string]
'update:constructeur-ids': [ids: unknown]
'update:constructeur-links': [links: ConstructeurLinkEntry[]]
'remove-constructeur-link': [constructeurId: string]
'set-custom-field-value': [field: any, value: unknown]
'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>

View File

@@ -0,0 +1,71 @@
<template>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
Aucune pièce associée à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="piece in pieces" :key="piece.id">
<PieceItem
:piece="piece"
:is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
/>
</div>
</div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-piece')"
>
Ajouter une pièce
</button>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{
pieces: any[]
isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>()
defineEmits<{
'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any]
'add-piece': []
'remove-piece': [linkId: string]
}>()
</script>

View File

@@ -0,0 +1,175 @@
<template>
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="previewDocumentList"
@close="closePreview"
/>
<div class="flex items-center justify-between">
<div>
<h2 class="card-title">Produits associés</h2>
<p class="text-xs text-gray-500">
Produits sélectionnés directement pour cette machine.
</p>
</div>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
<div v-if="products.length" class="space-y-3">
<div
v-for="product in products"
:key="product.id || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
>
<div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold text-base-content">
{{ product.name }}
</p>
<div class="flex items-center gap-2">
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
{{ product.groupLabel }}
</span>
<button
v-if="isEditMode"
type="button"
class="btn btn-ghost btn-xs text-error"
title="Supprimer ce produit"
@click="$emit('remove-product', (product.linkId || product.id) as string)"
>
Supprimer
</button>
</div>
</div>
<p v-if="product.reference" class="text-xs text-base-content/70">
<span class="font-medium">Référence :</span>
<span class="ml-1">{{ product.reference }}</span>
</p>
<p v-if="product.supplierLabel" class="text-xs text-base-content/70">
<span class="font-medium">Fournisseurs :</span>
<span class="ml-1">{{ product.supplierLabel }}</span>
</p>
<p v-if="product.priceLabel" class="text-xs text-base-content/70">
<span class="font-medium">Prix indicatif :</span>
<span class="ml-1">{{ product.priceLabel }}</span>
</p>
<!-- Documents liés au produit -->
<div v-if="product.documents?.length" class="mt-2 space-y-1">
<p class="text-xs font-medium text-base-content/70">Documents :</p>
<div
v-for="doc in product.documents"
:key="doc.id || doc.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-xs">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
>
<component
:is="documentIcon(doc).component"
class="h-4 w-4"
:class="documentIcon(doc).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">{{ doc.name }}</div>
<div class="text-xs text-base-content/60">
{{ doc.mimeType || 'Inconnu' }} {{ formatSize(doc.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(doc, product.documents || [])"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(doc)"
>
Télécharger
</button>
</div>
</div>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">
Aucun produit n'a été associé directement à cette machine.
</p>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
formatSize,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
defineProps<{
products: Array<{
id?: string | null
linkId?: string | null
name?: string
reference?: string | null
supplierLabel?: string | null
priceLabel?: string | null
groupLabel?: string
documents?: Array<{
id?: string
name?: string
mimeType?: string
size?: number
fileUrl?: string
downloadUrl?: string
}>
}>
isEditMode: boolean
}>()
defineEmits<{
'add-product': []
'remove-product': [linkId: string]
}>()
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const previewDocumentList = ref<any[]>([])
const openPreview = (doc: any, docs: any[]) => {
previewDocument.value = doc
previewDocumentList.value = docs
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
</script>

View 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>

View File

@@ -0,0 +1,438 @@
<template>
<main
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
>
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
<p class="text-base text-base-content/70">
{{ descriptionText }}
</p>
</header>
<nav
v-if="allowCategorySwitch"
class="tabs tabs-boxed inline-flex"
role="tablist"
aria-label="Catégories"
>
<button
v-for="option in categories"
:key="option.value"
type="button"
class="tab"
:class="{ 'tab-active': option.value === selectedCategory }"
role="tab"
:aria-selected="option.value === selectedCategory"
@click="onCategoryChange(option.value)"
>
{{ option.label }}
</button>
</nav>
<DataTable
:columns="columns"
:rows="items"
:loading="loading"
:sort="currentSort"
:pagination="paginationState"
:show-per-page="true"
row-key="id"
empty-message="Aucune catégorie trouvée."
no-results-message="Aucune catégorie ne correspond à votre recherche."
@sort="handleSort"
@update:current-page="handlePageChange"
@update:per-page="handlePerPageChange"
>
<template #toolbar>
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
<input
v-model="searchInput"
type="search"
class="grow min-w-0"
placeholder="Rechercher par nom…"
autocomplete="off"
/>
</label>
<button
v-if="canEdit"
type="button"
class="btn btn-primary btn-sm"
:disabled="loading"
@click="openCreatePage"
>
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
Créer
</button>
</template>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.name }}</span>
</template>
<template #cell-notes="{ row }">
<span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
<span v-else class="text-base-content/50"></span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
Liés
</button>
<button
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-xs text-warning"
@click="openConversionModal(row)"
>
Convertir
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
Éditer
</button>
<button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
Supprimer
</button>
</div>
</template>
</DataTable>
<ConversionModal
:open="conversionModalOpen"
:model-type="conversionTarget"
@close="closeConversionModal"
@converted="onConverted"
/>
<RelatedItemsModal
:open="relatedModalOpen"
:model-type="relatedType"
@close="relatedModalOpen = false"
@open-edit="openRelatedEdit"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { useHead, useRouter } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import ConversionModal from '~/components/model-types/ConversionModal.vue'
import { useUrlState } from '~/composables/useUrlState'
import type { DataTableSort } from '~/shared/types/dataTable'
import {
deleteModelType,
listModelTypes,
type ModelCategory,
type ModelType,
type ModelTypeListResponse,
} from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import IconLucideSearch from '~icons/lucide/search'
import IconLucidePlus from '~icons/lucide/plus'
const DEFAULT_DESCRIPTION
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
const props = withDefaults(
defineProps<{
category: ModelCategory
heading: string
description?: string
allowCategorySwitch?: boolean
}>(),
{
allowCategorySwitch: false,
},
)
const selectedCategory = ref<ModelCategory>(props.category)
const searchInput = ref('')
// State synced with URL query params
const urlState = useUrlState({
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
// Initialize searchInput from URL
searchInput.value = searchTerm.value
const items = ref<ModelType[]>([])
const total = ref(0)
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let activeController: AbortController | null = null
const router = useRouter()
const { showError, showSuccess } = useToast()
const { canEdit } = usePermissions()
const headingText = computed(() => props.heading)
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
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 {
data?: Record<string, unknown>
statusMessage?: string
message?: string
}
if (maybeFetchError.data) {
const data = maybeFetchError.data
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
else if (typeof data.detail === 'string') raw = data.detail
else if (typeof data.message === 'string') raw = data.message
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
else if (typeof data.error === 'string') raw = data.error
}
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
}
return humanizeError(raw)
}
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
if (resetOffset) offset.value = 0
if (activeController) activeController.abort()
const controller = new AbortController()
activeController = controller
loading.value = true
try {
const response: ModelTypeListResponse = await listModelTypes(
{
q: searchTerm.value || undefined,
category: selectedCategory.value,
sort: sort.value,
dir: dir.value,
limit: limit.value,
offset: offset.value,
},
{ signal: controller.signal },
)
items.value = response.items
total.value = response.total
offset.value = response.offset
limit.value = response.limit
}
catch (error: unknown) {
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
showError(extractErrorMessage(error))
}
finally {
if (activeController === controller) {
loading.value = false
activeController = null
}
}
}
watch(
() => props.category,
(value) => {
if (value !== selectedCategory.value) {
selectedCategory.value = value
doRefresh({ resetOffset: true })
}
},
)
const onCategoryChange = (value: ModelCategory) => {
if (!props.allowCategorySwitch) return
if (selectedCategory.value !== value) {
selectedCategory.value = value
doRefresh({ resetOffset: true })
}
}
const resolveCategoryBasePath = (category: ModelCategory) => {
if (category === 'COMPONENT') return '/component-category'
if (category === 'PIECE') return '/piece-category'
return '/product-category'
}
const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value)
router.push(`${basePath}/new`).catch(() => {
showError('Navigation impossible vers la page de création.')
})
}
const openEditPage = (item: ModelType) => {
const category = item.category ?? selectedCategory.value
const basePath = resolveCategoryBasePath(category)
router.push(`${basePath}/${item.id}/edit`).catch(() => {
showError("Navigation impossible vers la page d'édition.")
})
}
const { confirm } = useConfirm()
const confirmDelete = async (item: ModelType) => {
const confirmed = await confirm({
message: 'Supprimer ce type ? Cette action est irréversible.',
})
if (!confirmed) return
try {
await deleteModelType(item.id)
invalidateEntityTypeCache(item.category)
showSuccess(`Type « ${item.name} » supprimé avec succès.`)
if (items.value.length === 1 && offset.value >= limit.value) {
offset.value = Math.max(0, offset.value - limit.value)
}
await doRefresh()
}
catch (error) {
showError(extractErrorMessage(error))
}
}
const relatedModalOpen = ref(false)
const relatedType = ref<ModelType | null>(null)
const resolveRelatedEditBasePath = (category: ModelCategory) => {
if (category === 'COMPONENT') return '/component'
if (category === 'PIECE') return '/pieces'
return '/product'
}
const openRelatedModal = (item: ModelType) => {
relatedType.value = item
relatedModalOpen.value = true
}
const openRelatedEdit = (entry: { id: string }) => {
const current = relatedType.value
if (!current) return
const basePath = resolveRelatedEditBasePath(current.category)
relatedModalOpen.value = false
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
showError("Navigation impossible vers la fiche d'édition.")
})
}
const conversionModalOpen = ref(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(
() => searchInput.value,
(value) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
searchTerm.value = value.trim()
doRefresh({ resetOffset: true })
}, 300)
},
)
onMounted(() => {
doRefresh()
})
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer)
if (activeController) activeController.abort()
})
</script>

View File

@@ -0,0 +1,405 @@
<template>
<form class="space-y-8" @submit.prevent="handleSubmit">
<section class="space-y-4">
<div>
<label class="label" for="model-type-name">
<span class="label-text">Nom *</span>
</label>
<input
id="model-type-name"
ref="nameInput"
v-model.trim="form.name"
type="text"
class="input input-bordered w-full"
name="name"
minlength="2"
maxlength="120"
required
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div>
<div>
<label class="label" for="model-type-category">
<span class="label-text">Catégorie *</span>
</label>
<select
id="model-type-category"
v-model="form.category"
class="select select-bordered w-full"
name="category"
required
:disabled="lockCategory || isReadonly"
>
<option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option>
<option value="PRODUCT">Produits</option>
</select>
</div>
<div>
<label class="label" for="model-type-notes">
<span class="label-text">Notes</span>
</label>
<textarea
id="model-type-notes"
v-model.trim="form.notes"
class="textarea textarea-bordered w-full"
rows="4"
name="notes"
maxlength="2000"
></textarea>
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
</div>
</section>
<section class="space-y-4">
<header>
<h3 class="text-lg font-semibold text-base-content">Structure du squelette</h3>
<p class="mt-1 text-sm text-base-content/70">
Définissez la structure canonique appliquée lors de la création des composants ou pièces de cette catégorie.
</p>
</header>
<div
v-if="structureLoading"
class="flex items-center justify-center rounded-lg border border-dashed border-base-300 py-12"
>
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement du squelette</span>
</div>
<template v-else>
<div
v-if="form.category === 'COMPONENT'"
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ componentStructurePreview }}</span>
</p>
<ComponentModelStructureEditor
v-model="componentStructure"
:allow-subcomponents="allowComponentSubcomponents"
:max-subcomponent-depth="componentSubcomponentMaxDepth"
/>
</div>
<div
v-else-if="form.category === 'PIECE'"
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="pieceStructure" />
</div>
<div
v-else
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="productStructure" />
</div>
</template>
</section>
<ReferenceFormulaBuilder
v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
v-model="form.referenceFormula"
:custom-fields="formulaBuilderCustomFields"
:disabled="isReadonly"
/>
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler
</button>
<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>
{{ submitLabel }}
</button>
</footer>
</form>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
import {
clonePieceStructure,
cloneProductStructure,
cloneStructure,
defaultPieceStructure,
defaultProductStructure,
defaultStructure,
formatPieceStructurePreview,
formatProductStructurePreview,
formatStructurePreview,
normalizePieceStructureForSave,
normalizeProductStructureForSave,
normalizeStructureForEditor,
normalizeStructureForSave,
} from '~/shared/modelUtils'
import type { ModelCategory, ModelTypePayload } from '~/services/modelTypes'
const props = withDefaults(defineProps<{
mode: 'create' | 'edit'
initialCategory: ModelCategory
initialData?: Partial<ModelTypePayload> | null
saving?: boolean
lockCategory?: boolean
structureLoading?: boolean
allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number
readonly?: boolean
}>(), {
initialData: null,
saving: false,
lockCategory: false,
structureLoading: false,
allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1,
readonly: false,
})
const emit = defineEmits<{
(e: 'submit', payload: ModelTypePayload): void
(e: 'cancel'): void
}>()
const lockCategory = computed(() => props.lockCategory ?? false)
const structureLoading = computed(() => props.structureLoading ?? false)
const saving = computed(() => props.saving ?? false)
const allowComponentSubcomponents = computed(() => props.allowComponentSubcomponents !== false)
const componentSubcomponentMaxDepth = computed(() =>
typeof props.componentSubcomponentMaxDepth === 'number'
? props.componentSubcomponentMaxDepth
: 1,
)
const isReadonly = computed(() => props.readonly === true)
const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
name: '',
code: '',
category: props.initialCategory,
notes: '',
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 nameInput = ref<HTMLInputElement | null>(null)
const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
const productStructure = ref(normalizeProductStructureForSave(defaultProductStructure()))
const generateCodeFromName = (name: string) => {
const fallback = 'type'
if (!name) {
return fallback
}
return name
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || fallback
}
const resetStructures = (incomingStructure: ModelTypePayload['structure'], category: ModelCategory) => {
if (category === 'COMPONENT') {
componentStructure.value = normalizeStructureForEditor(
incomingStructure && props.initialData?.category === 'COMPONENT'
? incomingStructure
: defaultStructure(),
)
return
}
if (category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(
incomingStructure && props.initialData?.category === 'PIECE'
? incomingStructure
: defaultPieceStructure(),
)
return
}
productStructure.value = normalizeProductStructureForSave(
incomingStructure && props.initialData?.category === 'PRODUCT'
? cloneProductStructure(incomingStructure)
: defaultProductStructure(),
)
}
const resetForm = () => {
const incoming = props.initialData ?? {}
form.name = typeof incoming.name === 'string' ? incoming.name : ''
form.code = typeof incoming.code === 'string' && incoming.code
? incoming.code
: generateCodeFromName(form.name)
form.category = incoming.category ?? props.initialCategory
const incomingRecord = incoming as Record<string, unknown>
form.notes = typeof incoming.notes === 'string'
? incoming.notes
: typeof incomingRecord.description === 'string'
? incomingRecord.description
: ''
errors.name = undefined
const incomingAny = incoming as Record<string, unknown>
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
resetStructures(incoming.structure, form.category)
}
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || isReadonly.value)
const validate = () => {
errors.name = undefined
const trimmedName = form.name.trim()
if (trimmedName.length < 2) {
errors.name = 'Le nom doit contenir au moins 2 caractères.'
}
if (trimmedName.length > 120) {
errors.name = 'Le nom ne peut pas dépasser 120 caractères.'
}
return !errors.name
}
const handleSubmit = () => {
if (isReadonly.value) return
if (!validate()) {
return
}
const trimmedName = form.name.trim()
const resolvedCode = form.code?.trim()
? form.code.trim()
: generateCodeFromName(trimmedName)
const common = {
name: trimmedName,
code: resolvedCode,
notes: form.notes?.trim() ? form.notes.trim() : undefined,
}
if (form.category === 'COMPONENT') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', {
...common,
category: 'COMPONENT',
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return
}
if (form.category === 'PIECE') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', {
...common,
category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return
}
emit('submit', {
...common,
category: 'PRODUCT',
structure: normalizeProductStructureForSave(cloneProductStructure(productStructure.value)),
})
}
watch(
() => props.initialData,
() => {
resetForm()
},
{ deep: true, immediate: true },
)
watch(
() => form.name,
(value) => {
if (props.mode === 'create') {
form.code = generateCodeFromName(value)
}
},
)
watch(
() => props.initialCategory,
() => {
if (!props.initialData) {
form.category = props.initialCategory
resetStructures(undefined, form.category)
}
},
)
watch(
() => form.category,
(category, previous) => {
if (category === previous) {
return
}
if (category === 'COMPONENT') {
componentStructure.value = normalizeStructureForEditor(defaultStructure())
}
if (category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
}
if (category === 'PRODUCT') {
productStructure.value = normalizeProductStructureForSave(defaultProductStructure())
}
},
)
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
const productStructurePreview = computed(() => formatProductStructurePreview(productStructure.value))
onMounted(() => {
nextTick(() => nameInput.value?.focus())
})
</script>

View 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>

View 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>

View File

@@ -0,0 +1,190 @@
<template>
<section class="space-y-4" aria-live="polite">
<header class="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-base-content">Catégories enregistrées</h2>
<p class="text-sm text-base-content/70">
{{ totalLabel }}
</p>
</div>
<p v-if="loading" class="text-sm text-info flex items-center gap-2">
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Chargement en cours
</p>
</header>
<div v-if="loading && items.length === 0" class="space-y-3" role="status" aria-live="polite">
<div
v-for="index in 3"
:key="index"
class="rounded-xl border border-base-200 bg-base-200/70 animate-pulse h-24"
></div>
</div>
<div v-else-if="items.length === 0" class="rounded-xl border border-dashed border-base-200 p-10 text-center">
<IconLucideInbox class="mx-auto h-12 w-12 text-base-content/30" aria-hidden="true" />
<h3 class="mt-4 text-lg font-medium text-base-content">Aucune catégorie trouvée</h3>
<p class="text-sm text-base-content/70">Ajustez votre recherche ou créez une nouvelle catégorie.</p>
</div>
<div v-else class="space-y-6">
<div class="hidden sm:block overflow-x-auto">
<table class="table">
<thead>
<tr class="text-base-content/70">
<th scope="col">Nom</th>
<th scope="col">Notes</th>
<th scope="col" class="w-48 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td class="font-medium">{{ item.name }}</td>
<td class="max-w-xs align-middle">
<span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span>
<span v-else class="text-base-content/50"></span>
</td>
<td class="text-right space-x-2">
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</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)">
Éditer
</button>
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="grid gap-4 sm:hidden">
<article
v-for="item in items"
:key="item.id"
class="rounded-xl border border-base-200 bg-base-100 p-4 shadow-sm"
>
<header class="flex items-start justify-between gap-3">
<div>
<h3 class="text-lg font-semibold text-base-content">{{ item.name }}</h3>
<p class="text-sm text-base-content/60">{{ categoryLabel(item.category) }}</p>
</div>
</header>
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</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)">
Éditer
</button>
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer
</button>
</footer>
</article>
</div>
<nav class="flex flex-wrap items-center justify-between gap-3" aria-label="Pagination">
<span class="text-sm text-base-content/70">
Page {{ currentPage }} sur {{ totalPages }}
</span>
<div class="join">
<button
type="button"
class="btn btn-outline btn-sm join-item"
:disabled="!canGoPrevious"
@click="emit('update:offset', Math.max(0, offset - limit))"
>
Précédent
</button>
<button
type="button"
class="btn btn-outline btn-sm join-item"
:disabled="!canGoNext"
@click="emit('update:offset', offset + limit)"
>
Suivant
</button>
</div>
</nav>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import IconLucideInbox from '~icons/lucide/inbox';
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
import type { ModelType, ModelCategory } from '~/services/modelTypes';
const props = defineProps<{
items: ModelType[];
loading: boolean;
total: number;
limit: number;
offset: number;
category?: ModelCategory;
canEdit?: boolean;
}>();
const emit = defineEmits<{
(e: 'related', item: ModelType): void;
(e: 'edit', item: ModelType): void;
(e: 'delete', item: ModelType): void;
(e: 'convert', item: ModelType): void;
(e: 'update:offset', offset: number): void;
}>();
const showConvertButton = computed(() =>
props.category === 'PIECE' || props.category === 'COMPONENT',
);
const categoryDictionary: Record<ModelCategory, string> = {
COMPONENT: 'Composants',
PIECE: 'Pièces',
PRODUCT: 'Produits',
};
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
const currentPage = computed(() => {
if (props.limit <= 0) return 1;
return Math.floor(props.offset / props.limit) + 1;
});
const totalPages = computed(() => {
if (props.limit <= 0) return 1;
return Math.max(1, Math.ceil(props.total / props.limit));
});
const canGoPrevious = computed(() => props.offset > 0);
const canGoNext = computed(() => props.offset + props.limit < props.total);
const totalLabel = computed(() => {
if (props.total === 0) return 'Aucun résultat';
if (props.total === 1) return '1 catégorie trouvée';
return `${props.total} catégories trouvées`;
});
</script>

View File

@@ -0,0 +1,116 @@
<template>
<section class="space-y-4">
<nav
v-if="displayCategoryTabs"
class="tabs tabs-boxed inline-flex"
role="tablist"
aria-label="Catégories"
>
<button
v-for="option in categories"
:key="option.value"
type="button"
class="tab"
:class="{ 'tab-active': option.value === category }"
role="tab"
:aria-selected="option.value === category"
@click="emit('update:category', option.value)"
>
{{ option.label }}
</button>
</nav>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<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
:value="search"
type="search"
class="grow min-w-0"
placeholder="Rechercher par nom…"
autocomplete="off"
@input="onSearch"
/>
</label>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-base-content/70" for="model-type-sort">Trier par</label>
<select
id="model-type-sort"
class="select select-bordered select-sm"
:value="sort"
@change="emit('update:sort', ($event.target as HTMLSelectElement).value as SortField)"
>
<option value="name">Nom</option>
<option value="createdAt">Date de création</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-base-content/70" for="model-type-dir">Ordre</label>
<select
id="model-type-dir"
class="select select-bordered select-sm"
:value="dir"
@change="emit('update:dir', ($event.target as HTMLSelectElement).value as SortDirection)"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
</div>
</div>
<button
type="button"
class="btn btn-primary self-start"
:disabled="loading"
@click="emit('create')"
>
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
Créer
</button>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import IconLucidePlus from '~icons/lucide/plus';
import IconLucideSearch from '~icons/lucide/search';
import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc';
const props = defineProps<{
category: ModelCategory;
search: string;
sort: SortField;
dir: SortDirection;
loading?: boolean;
showCategoryTabs?: boolean;
canEdit?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:category', value: ModelCategory): void;
(e: 'update:search', value: string): void;
(e: 'update:sort', value: SortField): void;
(e: 'update:dir', value: SortDirection): void;
(e: 'create'): void;
}>();
const categories: Array<{ label: string; value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' },
{ label: 'Pièces', value: 'PIECE' },
{ label: 'Produits', value: 'PRODUCT' },
];
const onSearch = (event: Event) => {
emit('update:search', (event.target as HTMLInputElement).value);
};
const loading = computed(() => props.loading ?? false);
const displayCategoryTabs = computed(() => props.showCategoryTabs ?? true);
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div
class="card site-card shadow-md hover:shadow-xl transition-shadow overflow-hidden"
:style="{
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
>
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg text-base-content">
{{ site.name }}
</h3>
<div
class="badge font-bold"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ machineCount }} machines
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex items-center gap-2 text-base-content/80">
<IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" />
<span class="font-medium">{{ site.contactName }}</span>
</div>
<div class="flex items-center gap-2 text-base-content/60">
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
<span>{{ formattedContactPhone }}</span>
</div>
<div class="flex items-start gap-2 text-base-content/60">
<IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" />
<span>
{{ site.contactAddress }}<br>
{{ site.contactPostalCode }} {{ site.contactCity }}
</span>
</div>
<div class="flex items-center gap-2 text-base-content/60">
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span>{{ machineCount }} machine(s)</span>
</div>
</div>
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
Supprimer
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucidePhone from '~icons/lucide/phone'
import IconLucideUser from '~icons/lucide/user'
import { formatPhone } from '~/utils/formatters/phone'
const { canEdit } = usePermissions()
const props = defineProps({
site: {
type: Object,
required: true
}
})
const emit = defineEmits(['edit', 'delete'])
const machineCount = computed(() => props.site?.machines?.length || 0)
const formattedContactPhone = computed(() => {
const value = props.site?.contactPhone ?? ''
const formatted = formatPhone(value)
return formatted || value || '—'
})
</script>

View File

@@ -0,0 +1,132 @@
<template>
<div class="grid grid-cols-1 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du contact</span>
</label>
<input
v-model="contactName"
type="text"
placeholder="Nom et prénom"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
<div class="form-control">
<label class="label">
<span class="label-text">Adresse</span>
</label>
<input
v-model="contactAddress"
type="text"
placeholder="Adresse complète"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Code postal</span>
</label>
<input
v-model="contactPostalCode"
type="text"
placeholder="Code postal"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Ville</span>
</label>
<input
v-model="contactCity"
type="text"
placeholder="Ville"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import type { PropType } from 'vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
type SiteForm = {
contactName: string
contactPhone: string
contactAddress: string
contactPostalCode: string
contactCity: string
}
const props = defineProps({
form: {
type: Object as PropType<SiteForm>,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
})
const form = toRef(props, 'form')
const contactName = computed({
get: () => form.value.contactName,
set: (value: string) => {
form.value.contactName = value
},
})
const contactPhone = computed({
get: () => form.value.contactPhone,
set: (value: string) => {
form.value.contactPhone = value
},
})
const contactAddress = computed({
get: () => form.value.contactAddress,
set: (value: string) => {
form.value.contactAddress = value
},
})
const contactPostalCode = computed({
get: () => form.value.contactPostalCode,
set: (value: string) => {
form.value.contactPostalCode = value
},
})
const contactCity = computed({
get: () => form.value.contactCity,
set: (value: string) => {
form.value.contactCity = value
},
})
</script>
<!--
Bloc de formulaire partagé pour la saisie/édition des informations de contact d'un site.
Utilisation :
<SiteContactFormFields :form="siteForm" />
-->

View File

@@ -0,0 +1,115 @@
<template>
<div v-if="visible" class="modal modal-open">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4">Ajouter un nouveau site</h3>
<form @submit.prevent="emit('submit')" class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du site</span>
</label>
<input
v-model="siteName"
type="text"
placeholder="Ex: Usine principale"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Couleur</span>
</label>
<div v-if="siteRef.color" class="flex items-center gap-3">
<input
:value="siteRef.color"
type="color"
class="w-10 h-10 rounded cursor-pointer border border-base-300"
:disabled="disabled"
@input="(e: Event) => { siteRef.color = (e.target as HTMLInputElement).value }"
>
<input
v-model="siteRef.color"
type="text"
placeholder="#000000"
class="input input-bordered input-sm flex-1"
:disabled="disabled"
maxlength="7"
>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="disabled"
@click="siteRef.color = ''"
>
Effacer
</button>
</div>
<button
v-else
type="button"
class="btn btn-outline btn-sm w-fit"
:disabled="disabled"
@click="siteRef.color = '#3b82f6'"
>
Choisir une couleur
</button>
</div>
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
<div class="modal-action">
<button type="button" class="btn" @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 { computed, toRef } from 'vue'
import type { PropType } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
type SiteForm = {
name: string
color: string
contactName: string
contactPhone: string
contactAddress: string
contactPostalCode: string
contactCity: string
}
const props = defineProps({
visible: {
type: Boolean,
default: false
},
site: {
type: Object as PropType<SiteForm>,
required: true
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'submit'])
const siteRef = toRef(props, 'site')
const siteName = computed({
get: () => siteRef.value.name,
set: (value: string) => {
siteRef.value.name = value
}
})
</script>

View File

@@ -0,0 +1,220 @@
<template>
<div v-if="visible" class="modal modal-open">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4">
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
<span v-if="siteName" class="block text-sm font-normal text-base-content/50">{{ siteName }}</span>
</h3>
<form class="space-y-4" @submit.prevent="emit('submit')">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du site</span>
</label>
<input
v-model="form.name"
type="text"
placeholder="Nom du site"
class="input input-bordered"
:disabled="disabled"
required
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Couleur</span>
</label>
<div v-if="form.color" class="flex items-center gap-3">
<input
:value="form.color"
type="color"
class="w-10 h-10 rounded cursor-pointer border border-base-300"
:disabled="disabled"
@input="form.color = $event.target.value"
>
<input
v-model="form.color"
type="text"
placeholder="#000000"
class="input input-bordered input-sm flex-1"
:disabled="disabled"
maxlength="7"
>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="disabled"
@click="form.color = ''"
>
Effacer
</button>
</div>
<button
v-else
type="button"
class="btn btn-outline btn-sm w-fit"
:disabled="disabled"
@click="form.color = '#3b82f6'"
>
Choisir une couleur
</button>
</div>
<SiteContactFormFields :form="props.form" :disabled="disabled" />
<div class="border-t border-base-200 pt-4 space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="font-semibold text-sm">
Documents liés
</h4>
<p class="text-xs text-base-content/50">
Ajoutez des documents (PDF, images...) relatifs à ce site.
</p>
</div>
<span v-if="selectedFilesModel.length" class="badge badge-outline">
{{ selectedFilesModel.length }} fichier{{ selectedFilesModel.length > 1 ? 's' : '' }} prêt{{ selectedFilesModel.length > 1 ? 's' : '' }} à être ajouté
</span>
</div>
<DocumentUpload
v-if="!disabled"
v-model="selectedFilesModel"
title="Déposer vos fichiers"
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
/>
<div v-if="documents.length" class="space-y-3">
<h5 class="text-sm font-medium">
Documents existants
</h5>
<div class="space-y-2 max-h-48 overflow-y-auto pr-1">
<div
v-for="document in documents"
:key="document.id"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/50">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="emit('preview-document', document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
Télécharger
</button>
<button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
Supprimer
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="emit('close')">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
Enregistrer
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { isImageDocument } from '~/utils/documentPreview'
import DocumentUpload from '~/components/DocumentUpload.vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
siteName: {
type: String,
default: ''
},
form: {
type: Object,
required: true
},
documents: {
type: Array,
default: () => []
},
selectedFiles: {
type: Array,
default: () => []
},
uploadingDocuments: {
type: Boolean,
default: false
},
canPreviewDocument: {
type: Function,
required: true
},
documentIcon: {
type: Function,
required: true
},
formatSize: {
type: Function,
required: true
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'close',
'submit',
'remove-document',
'download-document',
'preview-document',
'update:selectedFiles'
])
const selectedFilesModel = computed({
get: () => props.selectedFiles,
set: value => emit('update:selectedFiles', value)
})
</script>

View File

@@ -0,0 +1,70 @@
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type ActivityLogActor = {
id: string
label: string
}
export type ActivityLogEntry = {
id: string
entityType: string
entityId: string
entityName: string | null
entityRef: string | null
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: ActivityLogActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
interface LoadActivityLogOptions {
page?: number
itemsPerPage?: number
entityType?: string
action?: string
}
export function useActivityLog() {
const { get } = useApi()
const entries = ref<ActivityLogEntry[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const loadActivityLog = async (options: LoadActivityLogOptions = {}) => {
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
params.set('page', String(options.page ?? 1))
params.set('itemsPerPage', String(options.itemsPerPage ?? 30))
if (options.entityType) params.set('entityType', options.entityType)
if (options.action) params.set('action', options.action)
const result = await get(`/activity-logs?${params.toString()}`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger le journal d\'activité.'
entries.value = []
return result
}
const data = result.data as any
entries.value = Array.isArray(data?.items) ? data.items : []
total.value = typeof data?.total === 'number' ? data.total : entries.value.length
return { success: true, data: entries.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
entries.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return { entries, total, loading, error, loadActivityLog }
}

View 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,
}
}

View File

@@ -0,0 +1,141 @@
import { useToast } from './useToast'
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
export interface ApiResponse<T = any> {
success: boolean
data?: T
error?: string
status?: number
}
interface ApiCallOptions extends RequestInit {
headers?: Record<string, string>
}
export function useApi() {
const { showError } = useToast()
const { public: publicConfig } = useRuntimeConfig()
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000'
const parsedApiTimeout = Number(publicConfig.apiTimeout ?? 30000)
const API_TIMEOUT = Number.isNaN(parsedApiTimeout) ? 30000 : parsedApiTimeout
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
const url = `${API_BASE_URL}${endpoint}`
const isFormData = options.body instanceof FormData
const defaultOptions: ApiCallOptions = {
credentials: 'include',
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
}
// Ajouter un timeout à la requête
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT)
try {
const response = await fetch(url, {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers,
},
signal: controller.signal,
})
clearTimeout(timeoutId)
if (response.ok) {
let data: T | null = null
if (response.status !== 204) {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json') || contentType.includes('application/ld+json') || contentType.includes('+json')) {
const text = await response.text()
data = text ? JSON.parse(text) : null
} else {
const text = await response.text()
data = (text || null) as T | null
}
}
return { success: true, data: data as T }
} else {
const contentType = response.headers.get('content-type') || ''
let errorData: Record<string, unknown> = {}
if (contentType.includes('json')) {
errorData = await response.json().catch(() => ({}))
} else {
const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {}
}
const rawMessage = response.status === 403
? 'Permissions insuffisantes pour cette action.'
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
const errorMessage = humanizeError(rawMessage)
showError(errorMessage)
return { success: false, error: errorMessage, status: response.status }
}
} catch (error) {
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
const errorMessage = err.name === 'AbortError'
? '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 }
}
}
const get = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, { method: 'GET' })
}
const post = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
},
body: data !== undefined ? JSON.stringify(data) : undefined,
})
}
const patch = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: data !== undefined ? JSON.stringify(data) : undefined,
})
}
const put = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/ld+json',
},
body: data !== undefined ? JSON.stringify(data) : undefined,
})
}
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'POST',
body: formData,
})
}
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, { method: 'DELETE' })
}
return {
apiCall,
get,
post,
postFormData,
patch,
put,
delete: del,
}
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -0,0 +1,12 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ComponentHistoryActor = EntityHistoryActor
export type ComponentHistoryEntry = EntityHistoryEntry
export function useComponentHistory() {
return useEntityHistory('composant')
}

View File

@@ -0,0 +1,29 @@
/**
* Backward-compatible wrapper around useEntityTypes.
* Preserves the original API surface (renamed fields) so consumers need no changes.
*/
import { useEntityTypes, type EntityType } from './useEntityTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { Ref } from 'vue'
export interface ComponentType extends EntityType {
structure: ComponentModelStructure | null
}
export function useComponentTypes() {
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
category: 'COMPONENT',
label: 'composant',
})
return {
componentTypes: types as Ref<ComponentType[]>,
loadingComponentTypes: loading,
loadComponentTypes: loadTypes,
createComponentType: createType,
updateComponentType: updateType,
deleteComponentType: deleteType,
getComponentTypes: () => types.value as ComponentType[],
isComponentTypeLoading: () => loading.value,
}
}

View File

@@ -0,0 +1,276 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Composant {
id: string
name: string
reference?: string | null
description?: string | null
typeComposantId?: string | null
typeComposant?: { id: string; name?: string } | null
productId?: string | null
product?: { id: string; name?: string } | null
constructeurs?: Constructeur[]
constructeurIds?: string[]
documents?: unknown[]
createdAt?: string | null
updatedAt?: string | null
[key: string]: unknown
}
interface ComposantListResult {
success: boolean
data?: { items: Composant[]; total: number; page: number; itemsPerPage: number }
error?: string
}
interface ComposantSingleResult {
success: boolean
data?: Composant
error?: string
}
interface LoadComposantsOptions {
search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
typeName?: string
typeComposantId?: string
force?: boolean
}
const composants = ref<Composant[]>([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useComposants() {
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()
const { ensureConstructeurs } = useConstructeurs()
const withResolvedConstructeurs = async (composant: Composant): Promise<Composant> => {
if (!composant || typeof composant !== 'object') {
return composant
}
if (!composant.typeComposantId) {
const typeComposantId = extractRelationId(composant.typeComposant)
if (typeComposantId) {
composant.typeComposantId = typeComposantId
}
}
if (!composant.productId) {
const productId = extractRelationId(composant.product)
if (productId) {
composant.productId = productId
}
}
const ids = uniqueConstructeurIds(
composant.constructeurIds,
composant.constructeurs,
)
const hasResolvedConstructeurs =
Array.isArray(composant.constructeurs) &&
composant.constructeurs.length > 0 &&
composant.constructeurs.every((item) => item && typeof item === 'object')
if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids)
if (resolved.length) {
composant.constructeurs = resolved
composant.constructeurIds = ids
}
}
return composant
}
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
typeName,
typeComposantId,
force = false,
} = options
if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
if (!typeComposantId && loading.value) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true
try {
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && 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)
const result = await get(`/composants?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
const resultTotal = extractTotal(result.data, items.length)
if (!typeComposantId) {
composants.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return {
success: true,
data: {
items: enrichedItems,
total: resultTotal,
page,
itemsPerPage,
},
}
}
return result as ComposantListResult
} catch (error) {
console.error('Erreur lors du chargement des composants:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true
try {
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await post('/composants', normalizedPayload)
if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data as Composant)
composants.value.unshift(enriched)
total.value += 1
const definition = (composantData as Record<string, unknown>)?.definition as Record<string, unknown> | undefined
const displayName =
(result.data as Composant)?.name ||
(definition?.name as string | undefined) ||
composantData?.name ||
'Composant'
showSuccess(`Composant "${displayName}" créé avec succès`)
return { success: true, data: enriched }
}
return { success: false, error: result.error }
} catch (error) {
console.error('Erreur lors de la création du composant:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true
try {
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await patch(`/composants/${id}`, normalizedPayload)
if (result.success && result.data) {
const updated = await withResolvedConstructeurs(result.data as Composant)
const index = composants.value.findIndex((comp) => comp.id === id)
if (index !== -1) {
composants.value[index] = updated
}
showSuccess(`Composant "${updated?.name || composantData.name || ''}" mis à jour avec succès`)
return { success: true, data: updated }
}
return { success: false, error: result.error }
} catch (error) {
console.error('Erreur lors de la mise à jour du composant:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const deleteComposant = async (id: string): Promise<ComposantSingleResult> => {
loading.value = true
try {
const result = await del(`/composants/${id}`)
if (result.success) {
const deletedComposant = composants.value.find((comp) => comp.id === id)
composants.value = composants.value.filter((comp) => comp.id !== id)
total.value = Math.max(0, total.value - 1)
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
return { success: true }
}
return { success: false, error: result.error }
} catch (error) {
console.error('Erreur lors de la suppression du composant:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const getComposants = () => composants.value
const isLoading = () => loading.value
const clearComposantsCache = () => {
composants.value = []
total.value = 0
loaded.value = false
}
return {
composants,
total,
loading,
loaded,
loadComposants,
createComposant,
updateComposant: updateComposantData,
deleteComposant,
getComposants,
isLoading,
clearComposantsCache,
}
}

View File

@@ -0,0 +1,73 @@
/**
* Promise-based confirmation dialog composable.
*
* Usage:
* const { confirm, confirmState } = useConfirm()
* const ok = await confirm({ message: 'Supprimer ?' })
* if (ok) { ... }
*
* The ConfirmModal component reads `confirmState` to render the dialog.
*/
import { reactive } from 'vue'
export interface ConfirmOptions {
title?: string
message: string
confirmText?: string
cancelText?: string
dangerous?: boolean
}
export interface ConfirmState {
open: boolean
title: string
message: string
confirmText: string
cancelText: string
dangerous: boolean
resolve: ((value: boolean) => void) | null
}
const state = reactive<ConfirmState>({
open: false,
title: '',
message: '',
confirmText: 'Supprimer',
cancelText: 'Annuler',
dangerous: true,
resolve: null,
})
function confirm(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
state.title = options.title ?? 'Confirmation'
state.message = options.message
state.confirmText = options.confirmText ?? 'Supprimer'
state.cancelText = options.cancelText ?? 'Annuler'
state.dangerous = options.dangerous ?? true
state.resolve = resolve
state.open = true
})
}
function handleConfirm() {
state.resolve?.(true)
state.open = false
state.resolve = null
}
function handleCancel() {
state.resolve?.(false)
state.open = false
state.resolve = null
}
export function useConfirm() {
return {
confirm,
confirmState: state,
handleConfirm,
handleCancel,
}
}

View 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 }
}

View File

@@ -0,0 +1,219 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Constructeur {
id: string
name: string
email?: string | null
phone?: string | null
}
interface ConstructeurResult {
success: boolean
data?: Constructeur | Constructeur[]
error?: string
}
const constructeurs = ref<Constructeur[]>([])
const loading = ref(false)
const loaded = ref(false)
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
const map = new Map<string, Constructeur>()
items.forEach((item) => {
if (item && typeof item === 'object' && typeof item.id === 'string') {
map.set(item.id, item)
}
})
return Array.from(map.values())
}
const normalizeIds = (ids: unknown[] = []): string[] => {
if (!Array.isArray(ids)) {
return []
}
return Array.from(
new Set(
ids
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter((value) => value.length > 0),
),
)
}
const upsertConstructeurs = (items: Constructeur[] = []) => {
if (!Array.isArray(items) || !items.length) {
return
}
const merged = uniqueConstructeurs([...constructeurs.value, ...items])
constructeurs.value = merged
}
const getIndexedConstructeur = (id: string): Constructeur | null =>
constructeurs.value.find((item) => item && item.id === id) || null
const pendingFetches = new Map<string, Promise<Constructeur | null>>()
export function useConstructeurs() {
const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
if (!search && !options.force && loaded.value) {
return { success: true, data: constructeurs.value }
}
loading.value = true
try {
const query = search ? `?search=${encodeURIComponent(search)}` : ''
const result = await get(`/constructeurs${query}`)
if (result.success) {
const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items)
if (!search) loaded.value = true
}
return result as ConstructeurResult
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement des fournisseurs:', error)
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const searchConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
return loadConstructeurs(search)
}
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
loading.value = true
try {
const result = await post('/constructeurs', data)
if (result.success) {
upsertConstructeurs([result.data as Constructeur])
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" créé`)
} else if (result.error) {
showError(result.error)
}
return result as ConstructeurResult
} catch (error) {
const err = error as Error
console.error('Erreur lors de la création du fournisseur:', error)
showError('Impossible de créer le fournisseur')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const ensureConstructeurs = async (ids: unknown[] = []): Promise<Constructeur[]> => {
const normalizedIds = normalizeIds(ids)
if (!normalizedIds.length) {
return []
}
const collected: Constructeur[] = []
const missing: string[] = []
normalizedIds.forEach((id) => {
const existing = getIndexedConstructeur(id)
if (existing) {
collected.push(existing)
} else {
missing.push(id)
}
})
if (missing.length) {
const fetchTasks = missing.map((id) => {
const cached = pendingFetches.get(id)
if (cached) {
return cached
}
const task = get(`/constructeurs/${id}`)
.then((result) => {
if (result.success && result.data) {
return result.data as Constructeur
}
return null
})
.catch((error) => {
console.error('Erreur lors du chargement du fournisseur:', error)
return null
})
.finally(() => {
pendingFetches.delete(id)
})
pendingFetches.set(id, task)
return task
})
const fetched = await Promise.all(fetchTasks)
const validFetched = fetched.filter((item): item is Constructeur => item !== null && item.id !== undefined)
if (validFetched.length) {
upsertConstructeurs(validFetched)
}
}
return normalizedIds
.map((id) => getIndexedConstructeur(id))
.filter((item): item is Constructeur => item !== null)
}
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
loading.value = true
try {
const result = await patch(`/constructeurs/${id}`, data)
if (result.success) {
upsertConstructeurs([result.data as Constructeur])
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" mis à jour`)
} else if (result.error) {
showError(result.error)
}
return result as ConstructeurResult
} catch (error) {
const err = error as Error
console.error('Erreur lors de la mise à jour du fournisseur:', error)
showError('Impossible de mettre à jour le fournisseur')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const deleteConstructeur = async (id: string): Promise<ConstructeurResult> => {
loading.value = true
try {
const result = await del(`/constructeurs/${id}`)
if (result.success) {
constructeurs.value = constructeurs.value.filter((item) => item.id !== id)
showSuccess('Fournisseur supprimé')
} else if (result.error) {
showError(result.error)
}
return result as ConstructeurResult
} catch (error) {
const err = error as Error
console.error('Erreur lors de la suppression du fournisseur:', error)
showError('Impossible de supprimer le fournisseur')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const getConstructeurById = (id: string) => getIndexedConstructeur(id)
return {
constructeurs,
loading,
loadConstructeurs,
searchConstructeurs,
createConstructeur,
updateConstructeur,
deleteConstructeur,
getConstructeurById,
ensureConstructeurs,
}
}

View File

@@ -0,0 +1,113 @@
import { ref } from 'vue'
import { useApi, type ApiResponse } from './useApi'
export interface CustomFieldValue {
id: string
customFieldId: string
entityType: string
entityId: string
value: unknown
[key: string]: unknown
}
export function useCustomFields() {
const { apiCall } = useApi()
const customFieldValues = ref<CustomFieldValue[]>([])
const loading = ref(false)
// Créer une valeur de champ personnalisé
const createCustomFieldValue = async (customFieldValueData: Record<string, unknown>): Promise<ApiResponse> => {
try {
const result = await apiCall('/custom-fields/values', {
method: 'POST',
body: JSON.stringify(customFieldValueData),
})
return result
} catch (error) {
console.error('Erreur lors de la création de la valeur de champ personnalisé:', error)
return { success: false, error: (error as Error).message }
}
}
// Obtenir les valeurs de champs personnalisés pour une entité
const getCustomFieldValuesByEntity = async (entityType: string, entityId: string): Promise<ApiResponse> => {
try {
loading.value = true
const result = await apiCall(`/custom-fields/values/${entityType}/${entityId}`, {
method: 'GET',
})
if (result.success) {
customFieldValues.value = result.data as CustomFieldValue[]
}
return result
} catch (error) {
console.error('Erreur lors de la récupération des valeurs de champs personnalisés:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
// Mettre à jour une valeur de champ personnalisé
const updateCustomFieldValue = async (id: string, updateData: Record<string, unknown>): Promise<ApiResponse> => {
try {
const result = await apiCall(`/custom-fields/values/${id}`, {
method: 'PATCH',
body: JSON.stringify(updateData),
})
return result
} catch (error) {
console.error('Erreur lors de la mise à jour de la valeur de champ personnalisé:', error)
return { success: false, error: (error as Error).message }
}
}
// Créer ou mettre à jour une valeur de champ personnalisé
const upsertCustomFieldValue = async (
customFieldId: string | null,
entityType: string,
entityId: string,
value: unknown,
metadata: Record<string, unknown> = {},
): Promise<ApiResponse> => {
try {
const result = await apiCall('/custom-fields/values/upsert', {
method: 'POST',
body: JSON.stringify({
customFieldId,
entityType,
entityId,
value,
...metadata,
}),
})
return result
} catch (error) {
console.error('Erreur lors de la création/mise à jour de la valeur de champ personnalisé:', error)
return { success: false, error: (error as Error).message }
}
}
// Supprimer une valeur de champ personnalisé
const deleteCustomFieldValue = async (id: string): Promise<ApiResponse> => {
try {
const result = await apiCall(`/custom-fields/values/${id}`, {
method: 'DELETE',
})
return result
} catch (error) {
console.error('Erreur lors de la suppression de la valeur de champ personnalisé:', error)
return { success: false, error: (error as Error).message }
}
}
return {
customFieldValues,
loading,
createCustomFieldValue,
getCustomFieldValuesByEntity,
updateCustomFieldValue,
upsertCustomFieldValue,
deleteCustomFieldValue,
}
}

View File

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

View File

@@ -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,
}
}

View File

@@ -0,0 +1,334 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Document {
id: string
name: string
filename: string
mimeType: string
size: number
fileUrl: string
downloadUrl: string
type?: string
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
path?: string
createdAt?: string
siteId?: string
machineId?: string
composantId?: string
productId?: string
pieceId?: string
site?: { id: string; name?: string } | null
machine?: { id: string; name?: string } | null
composant?: { id: string; name?: string } | null
piece?: { id: string; name?: string } | null
product?: { id: string; name?: string } | null
}
export interface UploadContext {
siteId?: string
machineId?: string
composantId?: string
productId?: string
pieceId?: string
type?: string
}
export interface DocumentResult {
success: boolean
data?: Document | Document[]
error?: string
}
interface LoadDocumentsOptions {
search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
attachmentFilter?: string
type?: string
force?: boolean
}
const documents = ref<Document[]>([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function useDocuments() {
const { get, patch, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast()
const loadFromEndpoint = async (
endpoint: string,
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
): Promise<DocumentResult> => {
loading.value = true
try {
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
const result = await get(url)
if (result.success) {
const data = extractCollection(result.data)
if (updateStore) {
documents.value = data
}
return { success: true, data }
}
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 (${endpoint}):`, error)
showError('Impossible de charger les documents')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
const {
search = '',
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 (
siteId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!siteId) {
return { success: false, error: 'Aucun site sélectionné' }
}
return loadFromEndpoint(`/documents/site/${siteId}`, { updateStore: options.updateStore ?? false })
}
const loadDocumentsByMachine = async (
machineId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!machineId) {
return { success: false, error: 'Aucune machine sélectionnée' }
}
return loadFromEndpoint(`/documents/machine/${machineId}`, { updateStore: options.updateStore ?? false })
}
const loadDocumentsByComponent = async (
componentId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!componentId) {
return { success: false, error: 'Aucun composant sélectionné' }
}
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
}
const loadDocumentsByProduct = async (
productId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!productId) {
return { success: false, error: 'Aucun produit sélectionné' }
}
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
}
const loadDocumentsByPiece = async (
pieceId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!pieceId) {
return { success: false, error: 'Aucune pièce sélectionnée' }
}
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
}
const uploadDocuments = async (
{ files, context = {} }: { files: File[]; context?: UploadContext },
{ updateStore = false }: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!files.length) {
return { success: false, error: 'Aucun fichier sélectionné' }
}
loading.value = true
const created: Document[] = []
try {
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
formData.append('name', file.name)
if (context.type) formData.append('type', context.type)
if (context.siteId) formData.append('siteId', context.siteId)
if (context.machineId) formData.append('machineId', context.machineId)
if (context.composantId) formData.append('composantId', context.composantId)
if (context.productId) formData.append('productId', context.productId)
if (context.pieceId) formData.append('pieceId', context.pieceId)
const result = await postFormData('/documents', formData)
if (result.success) {
created.push(result.data as Document)
showSuccess(`Document "${file.name}" ajouté`)
} else if (result.error) {
showError(`Erreur sur ${file.name} : ${result.error}`)
}
}
if (created.length) {
if (updateStore) {
documents.value = [...created, ...documents.value]
}
return { success: true, data: created }
}
return { success: false, error: 'Aucun document ajouté' }
} catch (error) {
const err = error as Error
console.error("Erreur lors de l'upload des documents:", error)
showError("Échec de l'ajout des documents")
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const deleteDocument = async (
id: string | number,
{ updateStore = false }: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!id) {
return { success: false, error: 'Identifiant manquant' }
}
loading.value = true
try {
const result = await del(`/documents/${id}`)
if (result.success) {
if (updateStore) {
documents.value = documents.value.filter((doc) => doc.id !== id)
}
showSuccess('Document supprimé')
}
return result as DocumentResult
} catch (error) {
const err = error as Error
console.error('Erreur lors de la suppression du document:', error)
showError('Impossible de supprimer le document')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
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 {
documents,
total,
loading,
loaded,
loadDocuments,
loadDocumentsBySite,
loadDocumentsByMachine,
loadDocumentsByComponent,
loadDocumentsByPiece,
loadDocumentsByProduct,
uploadDocuments,
updateDocument,
deleteDocument,
}
}

View 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,
}
}

View File

@@ -0,0 +1,181 @@
/**
* Reactive custom field management for entity items (ComponentItem, PieceItem).
*
* Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
* watchers, and API calls for updating/upserting custom field values.
*/
import { computed, watch } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
buildDefinitionSources,
buildCandidateCustomFields,
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
ensureCustomFieldId,
resolveFieldId,
resolveFieldName,
resolveFieldType,
resolveFieldReadOnly,
resolveCustomFieldId,
buildCustomFieldMetadata,
} from '~/shared/utils/entityCustomFieldLogic'
export interface EntityCustomFieldsDeps {
entity: () => any
entityType: 'composant' | 'piece'
}
export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
const { entity, entityType } = deps
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
const definitionSources = computed(() =>
buildDefinitionSources(entity(), entityType),
)
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
definitionSources.value,
entity().customFieldValues,
),
),
)
const candidateCustomFields = computed(() =>
buildCandidateCustomFields(entity(), definitionSources.value),
)
// Watchers to ensure field IDs are resolved
watch(
candidateCustomFields,
() => {
const candidates = candidateCustomFields.value
;(displayedCustomFields.value || []).forEach((field: any) => {
if (field) ensureCustomFieldId(field, candidates)
})
},
{ immediate: true, deep: true },
)
watch(
displayedCustomFields,
(fields) => {
const candidates = candidateCustomFields.value
;(fields || []).forEach((field: any) => {
if (field) ensureCustomFieldId(field, candidates)
})
},
{ immediate: true, deep: true },
)
const updateCustomField = async (field: any) => {
if (!field || resolveFieldReadOnly(field)) return
const e = entity()
const fieldValueId = resolveFieldId(field)
// Update existing field value
if (fieldValueId) {
const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
if (existingValue?.customField?.id) {
field.customFieldId = existingValue.customField.id
field.customField = existingValue.customField
}
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
}
return
}
// Create new field value
const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
const fieldName = resolveFieldName(field)
if (!e?.id) {
showError(`Impossible de créer la valeur pour ce champ`)
return
}
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
showError(`Impossible de créer la valeur pour ce champ`)
return
}
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
const result: any = await upsertCustomFieldValue(
customFieldId,
entityType,
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
field.customField = newValue.customField
}
if (Array.isArray(e.customFieldValues)) {
const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
if (index !== -1) {
e.customFieldValues.splice(index, 1, newValue)
} else {
e.customFieldValues.push(newValue)
}
} else {
e.customFieldValues = [newValue]
}
}
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
// Update definitions list
const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
const existingIndex = definitions.findIndex((definition: any) => {
const definitionId = resolveCustomFieldId(definition)
if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
return definition?.name === resolveFieldName(field)
})
const updatedDefinition = {
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
customFieldValueId: field.customFieldValueId,
customFieldId: fieldIdentifier,
name: resolveFieldName(field),
type: resolveFieldType(field),
required: field.required ?? false,
options: field.options ?? [],
value: field.value ?? '',
customField: field.customField ?? null,
}
if (existingIndex !== -1) {
definitions.splice(existingIndex, 1, updatedDefinition)
} else {
definitions.push(updatedDefinition)
}
e.customFields = definitions
} else {
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
}
}
return {
displayedCustomFields,
candidateCustomFields,
updateCustomField,
}
}

View File

@@ -0,0 +1,136 @@
/**
* Reactive document management for entity items (ComponentItem, PieceItem).
*
* Handles document CRUD operations, preview modal state, and lazy loading.
* Display helpers (formatSize, shouldInlinePdf, etc.) are imported from
* shared/utils/documentDisplayUtils.ts.
*/
import { ref, computed, watch } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { canPreviewDocument } from '~/utils/documentPreview'
export interface EntityDocumentsDeps {
entity: () => any
entityType: 'composant' | 'piece'
}
export function useEntityDocuments(deps: EntityDocumentsDeps) {
const { entity, entityType } = deps
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
const loadDocumentsFn = entityType === 'composant'
? useDocuments().loadDocumentsByComponent
: useDocuments().loadDocumentsByPiece
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const documentsLoaded = ref(!!(entity().documents && entity().documents.length))
const documents = computed(() => entity().documents || [])
// Preview modal state
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const openPreview = (doc: any) => {
if (!canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
// Document watchers
watch(
() => entity().documents,
(docs: any) => {
documentsLoaded.value = !!(docs && docs.length)
},
)
// CRUD operations
const refreshDocuments = async () => {
const e = entity()
if (!e?.id || e._structurePiece) return
loadingDocuments.value = true
try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
if (result.success) {
e.documents = result.data || []
documentsLoaded.value = true
}
} finally {
loadingDocuments.value = false
}
}
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return
await refreshDocuments()
}
const handleFilesAdded = async (files: File[]) => {
const e = entity()
if (!files.length || !e?.id) return
uploadingDocuments.value = true
try {
const contextKey = entityType === 'composant' ? 'composantId' : 'pieceId'
const result: any = await uploadDocuments(
{ files, context: { [contextKey]: e.id } } as any,
{ updateStore: false } as any,
)
if (result.success) {
const newDocs = result.data || []
e.documents = [...newDocs, ...(e.documents || [])]
documentsLoaded.value = true
selectedFiles.value = []
}
} finally {
uploadingDocuments.value = false
}
}
const removeDocument = async (documentId: string) => {
if (!documentId) return
const result: any = await deleteDocument(documentId, { updateStore: false } as any)
if (result.success) {
const e = entity()
e.documents = (e.documents || []).filter((doc: any) => doc.id !== documentId)
}
}
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 {
documents,
selectedFiles,
uploadingDocuments,
loadingDocuments,
documentsLoaded,
previewDocument,
previewVisible,
openPreview,
closePreview,
refreshDocuments,
ensureDocumentsLoaded,
handleFilesAdded,
removeDocument,
editDocument,
}
}

View File

@@ -0,0 +1,70 @@
/**
* Generic entity history composable.
*
* Replaces useComponentHistory, usePieceHistory, useProductHistory which were
* 99% identical (only the API endpoint differed).
*/
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type EntityHistoryActor = {
id: string
label: string
}
export type EntityHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: EntityHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const ENTITY_ENDPOINTS: Record<string, string> = {
machine: '/machines',
composant: '/composants',
piece: '/pieces',
product: '/products',
}
const extractItems = (payload: any): EntityHistoryEntry[] => {
if (Array.isArray(payload?.items)) return payload.items
if (Array.isArray(payload?.member)) return payload.member
if (Array.isArray(payload?.['hydra:member'])) return payload['hydra:member']
return []
}
export function useEntityHistory(entityType: 'machine' | 'composant' | 'piece' | 'product') {
const { get } = useApi()
const basePath = ENTITY_ENDPOINTS[entityType]
const history = ref<EntityHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (entityId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`${basePath}/${entityId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger l\'historique.'
history.value = []
return result
}
history.value = extractItems(result.data)
return { success: true, data: history.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
history.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return { history, loading, error, loadHistory }
}

View File

@@ -0,0 +1,103 @@
/**
* Reactive product display for entity items (ComponentItem, PieceItem).
*
* Resolves product information from entity.product, entity.__productDisplay,
* or a selectedProduct ref, and produces display-ready computed properties.
*/
import { computed, type Ref } from 'vue'
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
function buildProductDisplay(product: any) {
if (!product || typeof product !== 'object') return null
const suppliers = Array.isArray(product.constructeurs)
? product.constructeurs
.map((c: any) => c?.name)
.filter((name: any) => typeof name === 'string' && name.trim().length > 0)
.join(', ')
: product.supplierLabel || null
const priceValue = product.supplierPrice ?? product.price ?? product.priceLabel ?? product.priceDisplay ?? null
let price: string | null = null
if (priceValue !== null && priceValue !== undefined) {
const parsed = Number(priceValue)
if (!Number.isNaN(parsed)) {
price = currencyFormatter.format(parsed)
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
price = priceValue
}
}
return {
name: product.name || product.label || product.reference || product.productName || null,
reference: product.reference || null,
category: product.typeProduct?.name || product.category || null,
suppliers,
price,
}
}
export interface EntityProductDisplayDeps {
entity: () => any
selectedProduct?: Ref<any>
}
export function useEntityProductDisplay(deps: EntityProductDisplayDeps) {
const { entity, selectedProduct } = deps
const displayProduct = computed(() => {
// Priority: selectedProduct (for PieceItem) → entity.product → entity.__productDisplay
if (selectedProduct?.value) {
const normalized = buildProductDisplay(selectedProduct.value)
if (normalized) return normalized
}
const explicit = entity().product || null
const normalized = buildProductDisplay(explicit)
if (normalized) return normalized
const fallback = entity().__productDisplay
if (fallback) {
return {
name: fallback.name || null,
reference: fallback.reference || null,
category: fallback.category || null,
suppliers: fallback.suppliers || null,
price: fallback.price || null,
}
}
return null
})
const displayProductName = computed(() => {
if (displayProduct.value?.name) return displayProduct.value.name
const e = entity()
return e.product?.name || e.productName || e.productLabel || null
})
const productInfoRows = computed(() => {
if (!displayProduct.value) return []
const rows: { label: string; value: string }[] = []
if (displayProduct.value.reference) rows.push({ label: 'Référence', value: displayProduct.value.reference })
if (displayProduct.value.price) rows.push({ label: 'Prix indicatif', value: displayProduct.value.price })
if (displayProduct.value.suppliers) rows.push({ label: 'Fournisseur(s)', value: displayProduct.value.suppliers })
if (displayProduct.value.category) rows.push({ label: 'Catégorie', value: displayProduct.value.category })
return rows
})
const productDocuments = computed(() => {
const product = selectedProduct?.value || entity().product || null
return Array.isArray(product?.documents) ? product.documents : []
})
return {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
}
}

View File

@@ -0,0 +1,192 @@
/**
* Generic entity types composable.
*
* Replaces useComponentTypes, usePieceTypes, useProductTypes which were
* 95%+ identical (only the category string and labels differed).
*/
import { ref, type Ref } from 'vue'
import { useToast } from './useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import {
listModelTypes,
createModelType,
updateModelType,
deleteModelType,
type ModelType,
type ModelCategory,
} from '~/services/modelTypes'
export interface EntityType extends ModelType {
description?: string | null
}
interface EntityTypePayload {
name: string
code?: string
description?: string | null
notes?: string | null
structure?: any
}
interface EntityTypeResult {
success: boolean
data?: EntityType | EntityType[]
error?: string
}
interface EntityTypeConfig {
category: ModelCategory
label: string // e.g. 'composant', 'pièce', 'produit'
}
const generateCodeFromName = (name: string): string => {
return (name || '')
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
// Shared state per category (module-level singletons)
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean>; loaded: Ref<boolean> }> = {}
function getOrCreateState(category: ModelCategory) {
if (!stateByCategory[category]) {
stateByCategory[category] = {
types: ref<EntityType[]>([]),
loading: ref(false),
loaded: ref(false),
}
}
return stateByCategory[category]
}
/**
* Marks the cache for a given category as stale.
* Next call to `loadTypes()` (without `force`) will refetch from the API.
* Safe to call from event handlers (no setup context required).
*/
export function invalidateEntityTypeCache(category: ModelCategory) {
const state = stateByCategory[category]
if (state) {
state.loaded.value = false
}
}
export function useEntityTypes(config: EntityTypeConfig) {
const { category, label } = config
const { showSuccess, showError } = useToast()
const state = getOrCreateState(category)
const normalizeItem = (item: ModelType): EntityType => ({
...item,
description: item.description ?? item.notes ?? null,
})
const loadTypes = async (options: { force?: boolean } = {}): Promise<EntityTypeResult> => {
if (!options.force && state.loaded.value) {
return { success: true, data: state.types.value }
}
state.loading.value = true
try {
const data = await listModelTypes({
category,
sort: 'name',
dir: 'asc',
limit: 200,
})
state.types.value = data.items.map(normalizeItem)
state.loaded.value = true
return { success: true, data: state.types.value }
} catch (error) {
const err = error as Error & { message?: string }
const message = humanizeError(err?.message)
showError(`Impossible de charger les types de ${label}.`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
const createType = async (payload: EntityTypePayload): Promise<EntityTypeResult> => {
state.loading.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category,
notes: payload.description ?? payload.notes ?? undefined,
description: payload.description ?? undefined,
structure: payload.structure ?? undefined,
})
const normalized = normalizeItem(data)
state.types.value.push(normalized)
showSuccess(`Type de ${label} "${data.name}" créé`)
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de créer le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
const updateType = async (id: string, payload: EntityTypePayload): Promise<EntityTypeResult> => {
state.loading.value = true
try {
const data = await updateModelType(id, {
name: payload.name,
description: payload.description ?? undefined,
notes: payload.notes ?? undefined,
code: payload.code,
structure: payload.structure ?? undefined,
})
const normalized = normalizeItem(data)
const index = state.types.value.findIndex((t) => t.id === id)
if (index !== -1) state.types.value[index] = normalized
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
const deleteType = async (id: string): Promise<EntityTypeResult> => {
state.loading.value = true
try {
await deleteModelType(id)
state.types.value = state.types.value.filter((t) => t.id !== id)
showSuccess(`Type de ${label} supprimé`)
return { success: true }
} catch (error) {
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de supprimer le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
return {
types: state.types,
loading: state.loading,
loadTypes,
createType,
updateType,
deleteType,
}
}

View 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 }
}

View File

@@ -0,0 +1,123 @@
/**
* Machine creation page orchestration composable.
*
* Simplified: no more TypeMachine / skeleton system.
* Supports direct creation or cloning from an existing machine.
*/
import { ref, reactive, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
export function useMachineCreatePage() {
// ---------------------------------------------------------------------------
// Composable calls
// ---------------------------------------------------------------------------
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
const { sites, loadSites } = useSites()
const toast = useToast()
// ---------------------------------------------------------------------------
// Local state
// ---------------------------------------------------------------------------
const submitting = ref(false)
const loading = ref(true)
const newMachine = reactive({
name: '',
siteId: '',
reference: '',
cloneFromMachineId: '',
})
// ---------------------------------------------------------------------------
// Machine creation
// ---------------------------------------------------------------------------
const finalizeMachineCreation = async () => {
if (submitting.value) return
if (!newMachine.name?.trim()) {
toast.showError('Merci de renseigner un nom pour la machine')
return
}
submitting.value = true
try {
let result: any
if (newMachine.cloneFromMachineId) {
result = await cloneMachine(newMachine.cloneFromMachineId, {
name: newMachine.name,
siteId: newMachine.siteId,
...(newMachine.reference ? { reference: newMachine.reference } : {}),
})
} else {
result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId || undefined,
reference: newMachine.reference || undefined,
} as any)
}
if (result.success) {
const machineId = result.data?.id
|| (result.data?.machine as any)?.id
|| null
newMachine.name = ''
newMachine.siteId = ''
newMachine.reference = ''
newMachine.cloneFromMachineId = ''
if (machineId) {
await navigateTo(`/machine/${machineId}`)
} else {
await navigateTo('/machines')
}
} else if (result.error) {
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
}
} catch (error: any) {
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
} finally {
submitting.value = false
}
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
onMounted(async () => {
loading.value = true
try {
await Promise.all([
loadSites(),
loadMachines(),
])
} finally {
loading.value = false
}
})
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
// State
newMachine,
sites,
machines,
submitting,
loading,
// Actions
finalizeMachineCreation,
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -0,0 +1,519 @@
/**
* Machine detail page — core state & business logic (orchestrator).
*
* Extracted from pages/machine/[id].vue (F1.1).
* Composes sub-composables for documents, custom fields, hierarchy and products.
*/
import { ref, computed, watch } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useSites } from '~/composables/useSites'
import { useMachinePrint } from '~/composables/useMachinePrint'
import {
resolveConstructeurs,
uniqueConstructeurIds,
formatConstructeurContact as formatConstructeurContactSummary,
parseConstructeurLinksFromApi,
constructeurIdsFromLinks,
} from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useMachineDetailDocuments } from '~/composables/useMachineDetailDocuments'
import { useMachineDetailCustomFields } from '~/composables/useMachineDetailCustomFields'
import { useMachineDetailHierarchy } from '~/composables/useMachineDetailHierarchy'
import { useMachineDetailProducts } from '~/composables/useMachineDetailProducts'
import { useMachineDetailUpdates } from '~/composables/useMachineDetailUpdates'
import { downloadDocument as downloadDocumentHelper } from '~/shared/utils/documentDisplayUtils'
type AnyRecord = Record<string, unknown>
export function useMachineDetailData(machineId: string) {
// External composables
const {
updateMachine: updateMachineApi,
updateStructure: updateMachineStructure,
} = useMachines()
const { updateComposant: updateComposantApi } = useComposants()
const { updatePiece: updatePieceApi } = usePieces()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { upsertCustomFieldValue } = useCustomFields()
const { get, patch: apiPatch } = useApi()
const toast = useToast()
const { constructeurs, loadConstructeurs } = useConstructeurs()
const { sites, loadSites } = useSites()
const {
printModalOpen,
printSelection,
ensurePrintSelectionEntries: _ensurePrintEntries,
setAllPrintSelection: _setAllPrint,
openPrintModal: _openPrintModal,
closePrintModal,
handlePrintConfirm: _handlePrintConfirm,
} = useMachinePrint()
// Core state
const loading = ref(true)
const machine = ref<AnyRecord | null>(null)
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
const printAreaRef = ref<HTMLElement | null>(null)
const saving = ref(false)
// Constructeur links
const { fetchLinks, syncLinks } = useConstructeurLinks()
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
// Machine fields
const machineName = ref('')
const machineReference = ref('')
const machineSiteId = ref('')
const machineConstructeurIds = ref<string[]>([])
const machineConstructeurId = computed({
get: () => machineConstructeurIds.value[0] || null,
set: (value: string | null) => {
machineConstructeurIds.value = value ? [value] : []
},
})
const machineConstructeursDisplay = computed(() => {
const ids = machineConstructeurIds.value
if (!ids.length) return [] as any[]
// Extract nested constructeur objects from link entries as candidate pool
const linkConstructeurs = constructeurLinks.value
.filter(l => l.constructeur && l.constructeur.id)
.map(l => l.constructeur!) as any[]
return resolveConstructeurs(
ids,
linkConstructeurs,
constructeurs.value as any,
) as any[]
})
const machineConstructeurContact = computed(() =>
machineConstructeursDisplay.value
.map((c: any) => formatConstructeurContactSummary(c))
.filter(Boolean)
.join(' • '),
)
const hasMachineConstructeur = computed(
() => machineConstructeursDisplay.value.length > 0,
)
// UI state
const isEditMode = ref(false)
const canSubmit = computed(() => {
if (!machine.value) return false
if (saving.value) return false
if (!machineName.value.trim()) return false
return true
})
const debug = ref(false)
const componentsCollapsed = ref(true)
const collapseToggleToken = ref(0)
const piecesCollapsed = ref(true)
const pieceCollapseToggleToken = ref(0)
// Sub-composables: Products (init first — hierarchy needs findProductById)
// Products needs machineProductLinks from hierarchy, but hierarchy needs
// findProductById from products. We break the cycle by passing a lazy
// computed that reads from the hierarchy ref once it exists.
const _machineProductLinksProxy = ref<AnyRecord[]>([])
const {
productInventory,
productById,
machineDirectProducts,
findProductById,
resolveProductReference,
getProductDisplay,
loadProducts,
} = useMachineDetailProducts({
machineProductLinks: _machineProductLinksProxy,
productDocumentsMap,
constructeurs,
})
// Sub-composables: Custom fields
const {
machineCustomFields,
visibleMachineCustomFields,
transformCustomFields,
transformComponentCustomFields,
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
saveAllMachineCustomFields,
} = useMachineDetailCustomFields({
machine,
isEditMode,
constructeurs,
resolveProductReference,
getProductDisplay,
})
// Sub-composables: Hierarchy (includes structure link CRUD)
const hierarchy = useMachineDetailHierarchy({
machineId,
machine,
constructeurs,
findProductById,
transformComponentCustomFields,
transformCustomFields,
syncMachineCustomFields,
})
const {
components,
pieces,
machineComponentLinks,
machinePieceLinks,
machineProductLinks,
flattenedComponents,
machinePieces,
applyMachineLinks,
reloadMachineStructure,
addComponentLink,
removeComponentLink,
addPieceLink,
removePieceLink,
addProductLink,
removeProductLink,
} = hierarchy
// Keep the product links proxy in sync with the hierarchy's machineProductLinks
watch(machineProductLinks, (val) => { _machineProductLinksProxy.value = val }, { immediate: true })
// Sub-composables: Documents
const {
machineDocumentFiles,
machineDocumentsUploading,
machineDocumentsLoaded,
previewDocument,
previewVisible,
machineDocumentsList,
refreshMachineDocuments,
handleMachineFilesAdded,
removeMachineDocument,
openPreview,
closePreview,
loadProductDocuments: _loadProductDocuments,
} = useMachineDetailDocuments({ machine })
// Type helpers
const componentTypeOptions = computed(() => componentTypes.value || [])
const pieceTypeOptions = computed(() => pieceTypes.value || [])
const componentTypeLabelMap = computed(() => {
const map = new Map<string, string>()
componentTypeOptions.value.forEach((type) => {
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
return map
})
const pieceTypeLabelMap = computed(() => {
const map = new Map<string, string>()
pieceTypeOptions.value.forEach((type) => {
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
return map
})
// Machine field methods
const initMachineFields = () => {
if (machine.value) {
machineName.value = (machine.value.name as string) || ''
machineReference.value = (machine.value.reference as string) || ''
// Parse constructeur links from structure response
const rawLinks = Array.isArray(machine.value.constructeurs) ? machine.value.constructeurs as any[] : []
const parsed = parseConstructeurLinksFromApi(rawLinks)
constructeurLinks.value = parsed
originalConstructeurLinks.value = parsed.map(l => ({ ...l }))
machineConstructeurIds.value = constructeurIdsFromLinks(parsed)
machineSiteId.value = (machine.value.siteId as string) || (machine.value.site as AnyRecord)?.id as string || ''
}
}
const getMachineFieldId = (fieldName: string): string => {
return machine.value ? `machine-${fieldName}-${machine.value.id}` : `machine-${fieldName}`
}
// Product documents wrapper
const loadProductDocuments = async () => {
const map = await _loadProductDocuments(machineProductLinks.value)
productDocumentsMap.value = map
}
// Update methods (delegated to useMachineDetailUpdates)
const {
updateMachineInfo,
updateComponent,
updatePieceFromComponent,
updatePieceInfo,
handleMachineConstructeurChange,
editComponent,
editPiece,
} = useMachineDetailUpdates({
machine,
machineName,
machineReference,
machineSiteId,
machineConstructeurIds,
constructeurLinks,
originalConstructeurLinks,
machineDocumentsLoaded,
machineComponentLinks,
machinePieceLinks,
machineProductLinks,
applyMachineLinks,
refreshMachineDocuments,
transformComponentCustomFields,
transformCustomFields,
loadProductDocuments,
upsertCustomFieldValue,
updateMachineApi,
updateComposantApi: updateComposantApi,
updatePieceApi,
apiPatch,
toast,
syncLinks,
})
// UI methods
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
debug.value = !debug.value
if (isEditMode.value && !machineDocumentsLoaded.value) {
refreshMachineDocuments()
}
}
const toggleAllComponents = () => {
componentsCollapsed.value = !componentsCollapsed.value
collapseToggleToken.value += 1
}
const collapseAllComponents = () => {
componentsCollapsed.value = true
collapseToggleToken.value += 1
}
const toggleAllPieces = () => {
piecesCollapsed.value = !piecesCollapsed.value
pieceCollapseToggleToken.value += 1
}
const submitEdition = async () => {
if (!machine.value || saving.value) return
saving.value = true
try {
// 1. Save machine info (name, reference, site, constructeurs)
await updateMachineInfo()
// 2. Save all custom field values
await saveAllMachineCustomFields()
// 3. Reload machine data to get fresh state
await loadMachineData()
// 4. Exit edit mode
isEditMode.value = false
toast.showSuccess('Machine mise à jour avec succès')
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
toast.showError('Erreur lors de la sauvegarde de la machine')
} finally {
saving.value = false
}
}
const cancelEdition = () => {
initMachineFields()
syncMachineCustomFields()
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
isEditMode.value = false
}
// Print wrappers
const ensurePrintSelectionEntries = () =>
_ensurePrintEntries(components.value, machinePieces.value)
const setAllPrintSelection = (value: boolean) =>
_setAllPrint(value, components.value, machinePieces.value)
const openPrintModal = () =>
_openPrintModal(components.value, machinePieces.value)
const handlePrintConfirm = () =>
_handlePrintConfirm(
machine.value as any,
machineName.value,
machineReference.value,
machinePieces.value as any,
components.value as any,
)
// Data loading
const loadMachineData = async () => {
loading.value = true
try {
const machineResult: any = await get(`/machines/${machineId}/structure`)
if (!machineResult.success) {
console.error('Machine non trouvée:', machineId, machineResult.error)
machine.value = null
components.value = []
pieces.value = []
return
}
const machinePayload =
machineResult.data?.machine && typeof machineResult.data.machine === 'object'
? machineResult.data.machine
: machineResult.data
if (!machinePayload || typeof machinePayload !== 'object') {
console.error('Réponse machine invalide pour', machineId)
machine.value = null
components.value = []
pieces.value = []
return
}
machine.value = {
...machinePayload,
documents: machinePayload.documents || [],
customFieldValues: machinePayload.customFieldValues || [],
}
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
syncMachineCustomFields()
initMachineFields()
// Start document loading early (independent of products/links)
const documentPromise = !machineDocumentsLoaded.value
? refreshMachineDocuments()
: Promise.resolve()
// Load products in parallel — don't block hierarchy rendering
const productsPromise = !(productInventory.value as AnyRecord[]).length
? loadProducts().catch((error: unknown) => {
console.error('Erreur lors du chargement des produits:', error)
})
: Promise.resolve()
await productsPromise
const linksApplied = applyMachineLinks(machineResult.data)
if (machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
if (!linksApplied) {
components.value = transformComponentCustomFields(machinePayload.components || [])
pieces.value = transformCustomFields(machinePayload.pieces || [])
machineProductLinks.value = Array.isArray(machinePayload.productLinks)
? machinePayload.productLinks
: []
}
if (machine.value) {
machine.value.productLinks = machineProductLinks.value
}
collapseAllComponents()
// Load product documents in background
loadProductDocuments().catch(() => {})
// Wait for documents if still loading
await documentPromise
} catch (error) {
console.error('Erreur lors du chargement des données:', error)
} finally {
loading.value = false
}
}
const loadInitialData = (): Promise<unknown[]> => {
return Promise.all([
loadConstructeurs(),
loadComponentTypes(),
loadPieceTypes(),
loadSites(),
])
}
// Watchers
watch(() => (machine.value as AnyRecord)?.customFieldValues, () => syncMachineCustomFields(), { deep: true })
watch(() => (machine.value as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
watch(
() => [components.value.length, machinePieces.value.length],
() => ensurePrintSelectionEntries(),
{ immediate: true },
)
return {
// State
loading, machine, components, pieces, printAreaRef,
machineComponentLinks, machinePieceLinks, machineProductLinks,
// Machine fields
machineName, machineReference, machineSiteId, machineConstructeurIds, machineConstructeurId,
machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur,
constructeurLinks, originalConstructeurLinks,
sites,
// UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
machineCustomFields, previewDocument, previewVisible,
isEditMode, debug,
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
// Computed
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
productInventory, productById, flattenedComponents, machinePieces,
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
// Methods
findProductById, resolveProductReference, getProductDisplay,
initMachineFields, getMachineFieldId,
syncMachineCustomFields, setMachineCustomFieldValue,
updateMachineCustomField, updatePieceCustomField,
refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument,
openPreview, closePreview,
updateMachineInfo, updateComponent, updatePieceFromComponent,
updatePieceInfo, handleMachineConstructeurChange, editComponent, editPiece,
toggleEditMode, toggleAllComponents, collapseAllComponents, toggleAllPieces,
saving, canSubmit, submitEdition, cancelEdition,
// Print
printModalOpen, printSelection, ensurePrintSelectionEntries,
setAllPrintSelection, openPrintModal, closePrintModal, handlePrintConfirm,
// Loading & structure
loadMachineData, loadInitialData,
addComponentLink, removeComponentLink, addPieceLink, removePieceLink,
addProductLink, removeProductLink, reloadMachineStructure,
// External
constructeurs, loadProducts, updateMachineStructure, toast,
downloadDocument: downloadDocumentHelper,
}
}

View File

@@ -0,0 +1,146 @@
/**
* Machine detail — document management sub-composable.
*
* Handles document loading, upload, delete and preview state.
*/
import { ref, computed } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { canPreviewDocument } from '~/utils/documentPreview'
type AnyRecord = Record<string, unknown>
interface MachineDetailDocumentsDeps {
machine: Ref<AnyRecord | null>
}
export function useMachineDetailDocuments(deps: MachineDetailDocumentsDeps) {
const { machine } = deps
const {
uploadDocuments,
deleteDocument,
loadDocumentsByMachine,
loadDocumentsByProduct,
} = useDocuments()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const machineDocumentFiles = ref<File[]>([])
const machineDocumentsUploading = ref(false)
const machineDocumentsLoaded = ref(false)
const previewDocument = ref<AnyRecord | null>(null)
const previewVisible = ref(false)
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const machineDocumentsList = computed(
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
)
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
const refreshMachineDocuments = async () => {
if (!machine.value?.id) return
const result: any = await loadDocumentsByMachine(machine.value.id as string, { updateStore: false })
if (result.success && machine.value) {
machine.value.documents = result.data || []
machineDocumentsLoaded.value = true
}
}
const handleMachineFilesAdded = async (files: File[]) => {
if (!files.length || !machine.value?.id) return
machineDocumentsUploading.value = true
try {
const result: any = await uploadDocuments(
{ files, context: { machineId: machine.value.id } } as any,
{ updateStore: false },
)
if (result.success && machine.value) {
const newDocs = (result.data as AnyRecord[]) || []
machine.value.documents = [
...newDocs,
...((machine.value.documents as AnyRecord[]) || []),
]
machineDocumentFiles.value = []
}
} finally {
machineDocumentsUploading.value = false
}
}
const removeMachineDocument = async (documentId: string) => {
if (!documentId) return
const result: any = await deleteDocument(documentId, { updateStore: false })
if (result.success && machine.value) {
machine.value.documents = ((machine.value.documents as AnyRecord[]) || []).filter(
(doc) => doc.id !== documentId,
)
}
}
const openPreview = (doc: AnyRecord) => {
if (!canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const loadProductDocuments = async (machineProductLinks: AnyRecord[]) => {
const productIds = machineProductLinks
.map((link) => {
const p = link.product as AnyRecord | string | null
if (typeof p === 'string') return p.split('/').pop() || null
return (p as AnyRecord)?.id as string | null
})
.filter((id): id is string => !!id)
const results = await Promise.allSettled(
productIds.map(async (id) => {
const result: any = await loadDocumentsByProduct(id, { updateStore: false })
if (result.success && Array.isArray(result.data)) {
return { id, docs: result.data as AnyRecord[] }
}
return { id, docs: [] }
}),
)
const map = new Map<string, AnyRecord[]>()
results.forEach((r) => {
if (r.status === 'fulfilled' && r.value.docs.length) {
map.set(r.value.id, r.value.docs)
}
})
return map
}
return {
// State
machineDocumentFiles,
machineDocumentsUploading,
machineDocumentsLoaded,
previewDocument,
previewVisible,
// Computed
machineDocumentsList,
// Methods
refreshMachineDocuments,
handleMachineFilesAdded,
removeMachineDocument,
openPreview,
closePreview,
loadProductDocuments,
}
}

View File

@@ -0,0 +1,306 @@
/**
* Machine detail — hierarchy & link management sub-composable.
*
* Handles machine hierarchy building, component/piece tree resolution,
* flatten helpers, find-by-id utilities, and structure link CRUD.
*/
import { ref, computed } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import {
resolveIdentifier,
} from '~/shared/utils/productDisplayUtils'
import {
buildMachineHierarchyFromLinks,
resolveLinkArray,
} from '~/composables/useMachineHierarchy'
type AnyRecord = Record<string, unknown>
interface MachineDetailHierarchyDeps {
machineId: string
machine: Ref<AnyRecord | null>
constructeurs: Ref<unknown[]>
findProductById: (id: string | null | undefined) => AnyRecord | null
transformComponentCustomFields: (data: AnyRecord[]) => AnyRecord[]
transformCustomFields: (data: AnyRecord[]) => AnyRecord[]
syncMachineCustomFields: () => void
}
export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
const {
machineId,
machine,
constructeurs,
findProductById,
transformComponentCustomFields,
transformCustomFields,
syncMachineCustomFields,
} = deps
const { get, post: apiPost, delete: apiDel } = useApi()
const toast = useToast()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const components = ref<AnyRecord[]>([])
const pieces = ref<AnyRecord[]>([])
const machineComponentLinks = ref<AnyRecord[]>([])
const machinePieceLinks = ref<AnyRecord[]>([])
const machineProductLinks = ref<AnyRecord[]>([])
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const flattenComponents = (list: AnyRecord[] = []): AnyRecord[] => {
const result: AnyRecord[] = []
const traverse = (items: AnyRecord[]) => {
items.forEach((item) => {
result.push(item)
if (Array.isArray(item.subComponents) && item.subComponents.length) {
traverse(item.subComponents as AnyRecord[])
}
})
}
traverse(list)
return result
}
const findComponentById = (items: AnyRecord[] | undefined, id: string): AnyRecord | null => {
for (const item of items || []) {
if (item.id === id) return item
const found = findComponentById(item.subComponents as AnyRecord[] | undefined, id)
if (found) return found
}
return null
}
const findPieceById = (pieceId: string): AnyRecord | null => {
const direct = pieces.value.find((p) => p.id === pieceId)
if (direct) return direct
const searchInComponents = (items: AnyRecord[]): AnyRecord | null => {
for (const item of items || []) {
const match = ((item.pieces as AnyRecord[]) || []).find((p) => p.id === pieceId)
if (match) return match
const nested = searchInComponents((item.subComponents as AnyRecord[]) || [])
if (nested) return nested
}
return null
}
return searchInComponents(components.value)
}
// ---------------------------------------------------------------------------
// Hierarchy & links
// ---------------------------------------------------------------------------
const applyMachineLinks = (source: AnyRecord): boolean => {
const container = (source?.machine as AnyRecord) ?? null
const componentLinksData =
resolveLinkArray(source, ['componentLinks', 'machineComponentLinks']) ??
resolveLinkArray(container, ['componentLinks', 'machineComponentLinks'])
const pieceLinksData =
resolveLinkArray(source, ['pieceLinks', 'machinePieceLinks']) ??
resolveLinkArray(container, ['pieceLinks', 'machinePieceLinks'])
const productLinksData =
resolveLinkArray(source, ['productLinks', 'machineProductLinks']) ??
resolveLinkArray(container, ['productLinks', 'machineProductLinks'])
if (componentLinksData === null && pieceLinksData === null && productLinksData === null) {
return false
}
const normalizedComponentLinks = (componentLinksData ?? []) as AnyRecord[]
const normalizedPieceLinks = (pieceLinksData ?? []) as AnyRecord[]
const normalizedProductLinks = (productLinksData ?? []) as AnyRecord[]
machineComponentLinks.value = normalizedComponentLinks
machinePieceLinks.value = normalizedPieceLinks
machineProductLinks.value = normalizedProductLinks
const { components: hierarchy, machinePieces: machineLevelPieces } =
buildMachineHierarchyFromLinks(
normalizedComponentLinks,
normalizedPieceLinks,
findProductById as any,
constructeurs.value as any,
)
components.value = transformComponentCustomFields(hierarchy as AnyRecord[])
pieces.value = transformCustomFields(machineLevelPieces as AnyRecord[])
return true
}
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const flattenedComponents = computed(() => flattenComponents(components.value))
const machinePieces = computed(() => {
return pieces.value.filter((piece) => {
const parentLinkId = resolveIdentifier(
piece.parentComponentLinkId,
(piece.machinePieceLink as AnyRecord)?.parentComponentLinkId,
piece.parentLinkId,
)
if (parentLinkId) return false
return !piece.composantId
})
})
// ---------------------------------------------------------------------------
// Structure reload
// ---------------------------------------------------------------------------
const reloadMachineStructure = async () => {
const result: any = await get(`/machines/${machineId}/structure`)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'
? result.data.machine
: result.data
if (machinePayload && typeof machinePayload === 'object') {
machine.value = {
...machine.value,
...machinePayload,
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
}
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
syncMachineCustomFields()
}
}
}
// ---------------------------------------------------------------------------
// Structure link CRUD
// ---------------------------------------------------------------------------
const addComponentLink = async (composantId: string) => {
const result: any = await apiPost('/machine_component_links', {
machine: `/api/machines/${machineId}`,
composant: `/api/composants/${composantId}`,
})
if (result.success) {
toast.showSuccess('Composant ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du composant')
}
return result
}
const removeComponentLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_component_links/${linkId}`)
if (result.success) {
toast.showSuccess('Composant retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du composant')
}
return result
}
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
piece: `/api/pieces/${pieceId}`,
}
if (parentComponentLinkId) {
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
const result: any = await apiPost('/machine_piece_links', payload)
if (result.success) {
toast.showSuccess('Pièce ajoutée à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout de la pièce')
}
return result
}
const removePieceLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
if (result.success) {
toast.showSuccess('Pièce retirée de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression de la pièce')
}
return result
}
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
product: `/api/products/${productId}`,
}
if (parentComponentLinkId) {
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
if (parentPieceLinkId) {
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
}
const result: any = await apiPost('/machine_product_links', payload)
if (result.success) {
toast.showSuccess('Produit ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du produit')
}
return result
}
const removeProductLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_product_links/${linkId}`)
if (result.success) {
toast.showSuccess('Produit retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du produit')
}
return result
}
return {
// State
components,
pieces,
machineComponentLinks,
machinePieceLinks,
machineProductLinks,
// Computed
flattenedComponents,
machinePieces,
// Helpers
flattenComponents,
findComponentById,
findPieceById,
// Hierarchy
applyMachineLinks,
// Structure link management
reloadMachineStructure,
addComponentLink,
removeComponentLink,
addPieceLink,
removePieceLink,
addProductLink,
removeProductLink,
}
}

Some files were not shown because too many files have changed in this diff Show More