Compare commits
11 Commits
v1.7.0
...
6f1bac381d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1bac381d | ||
|
|
89dc2e93b8 | ||
|
|
8f5f25b3e7 | ||
|
|
c06c852493 | ||
|
|
41f5319b67 | ||
|
|
c7fd8328d6 | ||
|
|
55e2a4fafe | ||
|
|
e88ed5b8f2 | ||
|
|
546cc37a09 | ||
|
|
efd0fbe407 | ||
|
|
607f84fc3d |
178
README.md
178
README.md
@@ -1,75 +1,155 @@
|
|||||||
# Nuxt Minimal Starter
|
# Inventory Frontend
|
||||||
|
|
||||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
Interface web de gestion d'inventaire industriel pour **Malio**. Application SPA complète permettant la gestion du parc machines, des pièces, composants, produits, fournisseurs et documents associés.
|
||||||
|
|
||||||
## Setup
|
## Stack technique
|
||||||
|
|
||||||
Make sure to install dependencies:
|
| Technologie | Version | Rôle |
|
||||||
|
|-------------|---------|------|
|
||||||
|
| [Nuxt](https://nuxt.com) | 4 | Framework (SPA, SSR désactivé) |
|
||||||
|
| [Vue 3](https://vuejs.org) | 3.5 | Composition API + `<script setup>` |
|
||||||
|
| [TypeScript](https://www.typescriptlang.org) | 5.7 | Typage strict sur l'ensemble du projet |
|
||||||
|
| [TailwindCSS](https://tailwindcss.com) | 4 | Utility-first CSS |
|
||||||
|
| [DaisyUI](https://daisyui.com) | 5 | Composants UI (alertes, modales, badges, etc.) |
|
||||||
|
| [Lucide](https://lucide.dev) | via unplugin-icons | Icônes SVG |
|
||||||
|
| [Vitest](https://vitest.dev) | 4 | Tests unitaires |
|
||||||
|
| [Playwright](https://playwright.dev) | 1.58 | Tests E2E |
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- **Node.js** >= 20
|
||||||
|
- **npm**
|
||||||
|
- **Backend Symfony** démarré avec l'API sur `http://localhost:8081/api`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Server
|
## Développement
|
||||||
|
|
||||||
Start the development server on `http://localhost:3000`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn dev
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production
|
L'application est accessible sur **http://localhost:3001**.
|
||||||
|
|
||||||
Build the application for production:
|
## Commandes disponibles
|
||||||
|
|
||||||
```bash
|
| Commande | Description |
|
||||||
# npm
|
|----------|-------------|
|
||||||
npm run build
|
| `npm run dev` | Serveur de développement avec HMR |
|
||||||
|
| `npm run build` | Build de production |
|
||||||
|
| `npm run lint:fix` | Correction automatique ESLint |
|
||||||
|
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
|
||||||
|
| `npm run test` | Tests unitaires Vitest |
|
||||||
|
| `npm run test:watch` | Tests unitaires en mode watch |
|
||||||
|
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
|
||||||
|
|
||||||
# pnpm
|
## Fonctionnalités
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# yarn
|
### Gestion du parc
|
||||||
yarn build
|
|
||||||
|
|
||||||
# bun
|
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
|
||||||
bun run build
|
- **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
|
||||||
|
- **Sites** : gestion multi-sites avec coordonnées de contact
|
||||||
|
|
||||||
|
### Catalogues
|
||||||
|
|
||||||
|
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
|
||||||
|
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
|
||||||
|
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
|
||||||
|
|
||||||
|
### Documents et traçabilité
|
||||||
|
|
||||||
|
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
|
||||||
|
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
|
||||||
|
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
|
||||||
|
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
|
||||||
|
- **Profils** : gestion des utilisateurs et attribution des rôles
|
||||||
|
- **Notifications** : badge compteur de commentaires ouverts avec polling
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── pages/ # 36 pages (file-based routing)
|
||||||
|
├── components/ # 57 composants Vue (auto-imported par Nuxt)
|
||||||
|
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
|
||||||
|
│ ├── form/ # Champs de formulaire (email, téléphone)
|
||||||
|
│ ├── layout/ # Navbar principale
|
||||||
|
│ ├── machine/ # Vue détail et création de machines
|
||||||
|
│ │ └── create/ # Wizard de création machine
|
||||||
|
│ ├── model-types/ # Gestion des types/catégories
|
||||||
|
│ └── sites/ # Modales site (création, édition)
|
||||||
|
├── composables/ # 45 composables (logique métier)
|
||||||
|
├── shared/ # Types, utilitaires, validation
|
||||||
|
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
|
||||||
|
│ ├── validation/ # Validation email, téléphone
|
||||||
|
│ └── model/ # Définitions de structures
|
||||||
|
├── services/ # Service layer (wrappers API spécialisés)
|
||||||
|
├── middleware/ # Middleware d'auth global (session cookie)
|
||||||
|
└── utils/ # Formatage dates, montants, événements
|
||||||
```
|
```
|
||||||
|
|
||||||
Locally preview production build:
|
## Conventions de code
|
||||||
|
|
||||||
```bash
|
### Composables
|
||||||
# npm
|
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# pnpm
|
Pattern avec injection de dépendances explicite :
|
||||||
pnpm preview
|
|
||||||
|
|
||||||
# yarn
|
```typescript
|
||||||
yarn preview
|
interface Deps {
|
||||||
|
machineId: Ref<string>
|
||||||
|
onSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
# bun
|
export function useMachineDetail(deps: Deps) {
|
||||||
bun run preview
|
// ...
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
### Communication entre composants
|
||||||
|
|
||||||
|
**Props + Events uniquement** — pas de `provide/inject` dans le projet.
|
||||||
|
|
||||||
|
### Appels API
|
||||||
|
|
||||||
|
Le composable `useApi.ts` centralise tous les appels HTTP :
|
||||||
|
- Cookies de session inclus automatiquement (`credentials: 'include'`)
|
||||||
|
- `application/ld+json` pour POST/PUT
|
||||||
|
- `application/merge-patch+json` pour PATCH
|
||||||
|
- Gestion d'erreurs centralisée avec traduction des messages backend en français
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
Classes DaisyUI standard :
|
||||||
|
- Input : `input input-bordered input-sm md:input-md`
|
||||||
|
- Select : `select select-bordered select-sm md:select-md`
|
||||||
|
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
L'application utilise une **authentification par session (cookies)**, pas de JWT.
|
||||||
|
|
||||||
|
Le middleware global `profile.global.ts` vérifie la session à chaque navigation :
|
||||||
|
- Utilisateur non connecté → redirection vers `/profiles`
|
||||||
|
- Route `/admin/*` → accès restreint à `ROLE_ADMIN`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **13 tests unitaires** (Vitest + happy-dom) couvrant composables, utils et composants
|
||||||
|
- **3 specs E2E** (Playwright + Chrome) avec setup d'authentification
|
||||||
|
|
||||||
|
## Submodule Git
|
||||||
|
|
||||||
|
Ce repo est un **submodule** du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory).
|
||||||
|
|
||||||
|
Workflow de commit :
|
||||||
|
1. Commiter dans ce repo (frontend) en premier
|
||||||
|
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
|
||||||
|
3. Pousser les deux repos
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="componentDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -174,8 +175,8 @@
|
|||||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
|
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
@@ -332,8 +333,8 @@
|
|||||||
:class="documentThumbnailClass(document)"
|
:class="documentThumbnailClass(document)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="font-bold text-xl truncate">
|
<h3 class="font-bold text-xl truncate">
|
||||||
Prévisualisation
|
Prévisualisation
|
||||||
|
<span v-if="navTotal > 1" class="text-base font-normal text-gray-500">
|
||||||
|
{{ activeIndex + 1 }} / {{ navTotal }}
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-500 truncate">
|
<p class="text-sm text-gray-500 truncate">
|
||||||
{{ document?.name || document?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
|
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
|
||||||
@@ -20,15 +23,35 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden">
|
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
|
||||||
|
<button
|
||||||
|
v-if="hasPrev"
|
||||||
|
type="button"
|
||||||
|
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||||
|
title="Document précédent (←)"
|
||||||
|
@click="goToPrev"
|
||||||
|
>
|
||||||
|
❮
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasNext"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||||
|
title="Document suivant (→)"
|
||||||
|
@click="goToNext"
|
||||||
|
>
|
||||||
|
❯
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
|
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
|
||||||
<template v-if="previewType === 'image'">
|
<template v-if="previewType === 'image'">
|
||||||
<img :src="document?.path" alt="preview" class="max-h-full max-w-full object-contain">
|
<img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'pdf'">
|
<template v-else-if="previewType === 'pdf'">
|
||||||
<iframe
|
<iframe
|
||||||
:src="document?.path"
|
:src="documentSrc"
|
||||||
class="w-full h-full bg-white"
|
class="w-full h-full bg-white"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
title="Aperçu PDF"
|
title="Aperçu PDF"
|
||||||
@@ -36,11 +59,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'audio'">
|
<template v-else-if="previewType === 'audio'">
|
||||||
<audio :src="document?.path" controls class="w-full" />
|
<audio :src="documentSrc" controls class="w-full" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'video'">
|
<template v-else-if="previewType === 'video'">
|
||||||
<video :src="document?.path" controls class="w-full h-full bg-black" />
|
<video :src="documentSrc" controls class="w-full h-full bg-black" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'text'">
|
<template v-else-if="previewType === 'text'">
|
||||||
@@ -80,31 +103,110 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import { getPreviewType, describeDocument } from '~/utils/documentPreview'
|
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
document: {
|
document: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null,
|
||||||
},
|
},
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
|
documents: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
const previewType = computed(() => getPreviewType(props.document))
|
// --- Carousel navigation ---
|
||||||
const documentDescription = computed(() => describeDocument(props.document))
|
|
||||||
|
const previewableDocuments = computed(() => {
|
||||||
|
if (!props.documents?.length) return []
|
||||||
|
return props.documents.filter((doc) => canPreviewDocument(doc))
|
||||||
|
})
|
||||||
|
|
||||||
|
const navTotal = computed(() => previewableDocuments.value.length)
|
||||||
|
|
||||||
|
const activeIndex = ref(0)
|
||||||
|
|
||||||
|
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
|
||||||
|
watch(
|
||||||
|
() => props.document,
|
||||||
|
(doc) => {
|
||||||
|
if (!doc || !previewableDocuments.value.length) {
|
||||||
|
activeIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
|
||||||
|
activeIndex.value = idx >= 0 ? idx : 0
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeDoc = computed(() => {
|
||||||
|
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
|
||||||
|
return previewableDocuments.value[activeIndex.value]
|
||||||
|
}
|
||||||
|
return props.document
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
|
||||||
|
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
|
||||||
|
|
||||||
|
const goToPrev = () => {
|
||||||
|
if (hasPrev.value) activeIndex.value--
|
||||||
|
}
|
||||||
|
const goToNext = () => {
|
||||||
|
if (hasNext.value) activeIndex.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
if (!props.visible) return
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
goToPrev()
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
goToNext()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Preview logic (uses activeDoc) ---
|
||||||
|
|
||||||
|
const previewType = computed(() => getPreviewType(activeDoc.value))
|
||||||
|
const documentDescription = computed(() => describeDocument(activeDoc.value))
|
||||||
|
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
|
||||||
|
|
||||||
const textContent = ref('')
|
const textContent = ref('')
|
||||||
const textLoading = ref(false)
|
const textLoading = ref(false)
|
||||||
const textError = ref('')
|
const textError = ref('')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.document,
|
activeDoc,
|
||||||
async (doc) => {
|
async (doc) => {
|
||||||
textContent.value = ''
|
textContent.value = ''
|
||||||
textError.value = ''
|
textError.value = ''
|
||||||
@@ -115,22 +217,17 @@ watch(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
textLoading.value = true
|
textLoading.value = true
|
||||||
const path = doc.path || ''
|
const url = doc.fileUrl || doc.path || ''
|
||||||
if (path.startsWith('data:')) {
|
if (!url) {
|
||||||
const base64Part = path.split(',')[1] || ''
|
textError.value = 'Aucune URL de document disponible.'
|
||||||
if (!base64Part) {
|
return
|
||||||
textError.value = 'Impossible de lire ce document texte.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const decoded = atob(base64Part)
|
|
||||||
textContent.value = decodeURIComponent(escape(decoded))
|
|
||||||
} else {
|
|
||||||
const response = await fetch(path)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Téléchargement du document impossible')
|
|
||||||
}
|
|
||||||
textContent.value = await response.text()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { credentials: 'include' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Téléchargement du document impossible')
|
||||||
|
}
|
||||||
|
textContent.value = await response.text()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement du texte:', error)
|
console.error('Erreur lors du chargement du texte:', error)
|
||||||
textError.value = error.message || 'Impossible de lire ce document.'
|
textError.value = error.message || 'Impossible de lire ce document.'
|
||||||
@@ -138,7 +235,7 @@ watch(
|
|||||||
textLoading.value = false
|
textLoading.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -146,11 +243,8 @@ const close = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const download = () => {
|
const download = () => {
|
||||||
if (!props.document?.path) { return }
|
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
|
||||||
const link = document.createElement('a')
|
if (!url) { return }
|
||||||
link.href = props.document.path
|
window.open(url, '_blank')
|
||||||
link.download = props.document.filename || props.document.name || 'document'
|
|
||||||
link.target = '_blank'
|
|
||||||
link.click()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ type GenericDocument = {
|
|||||||
filename?: string | null;
|
filename?: string | null;
|
||||||
mimeType?: string | null;
|
mimeType?: string | null;
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
|
fileUrl?: string | null;
|
||||||
|
downloadUrl?: string | null;
|
||||||
size?: number | null;
|
size?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ const normalizedDocument = computed(() => props.document ?? null);
|
|||||||
|
|
||||||
const canRenderImage = computed(() => {
|
const canRenderImage = computed(() => {
|
||||||
const doc = normalizedDocument.value;
|
const doc = normalizedDocument.value;
|
||||||
return !!(doc && isImageDocument(doc) && doc.path);
|
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
|
||||||
});
|
});
|
||||||
|
|
||||||
const canRenderPdf = computed(() => {
|
const canRenderPdf = computed(() => {
|
||||||
@@ -73,13 +75,14 @@ const appendPdfViewerParams = (src: string) => {
|
|||||||
|
|
||||||
const previewSrc = computed(() => {
|
const previewSrc = computed(() => {
|
||||||
const doc = normalizedDocument.value;
|
const doc = normalizedDocument.value;
|
||||||
if (!doc || !doc.path) {
|
const url = doc?.fileUrl || doc?.path;
|
||||||
|
if (!doc || !url) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (isPdfDocument(doc)) {
|
if (isPdfDocument(doc)) {
|
||||||
return appendPdfViewerParams(doc.path);
|
return appendPdfViewerParams(url);
|
||||||
}
|
}
|
||||||
return doc.path;
|
return url;
|
||||||
});
|
});
|
||||||
|
|
||||||
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
|
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="pieceDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -184,8 +185,8 @@
|
|||||||
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"
|
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
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
@@ -413,8 +414,8 @@
|
|||||||
:class="documentThumbnailClass(document)"
|
:class="documentThumbnailClass(document)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<IconLucideX
|
<IconLucideCircleX
|
||||||
v-else-if="toast.type === 'error'"
|
v-else-if="toast.type === 'error'"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x'
|
||||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||||
import IconLucideInfo from '~icons/lucide/info'
|
import IconLucideInfo from '~icons/lucide/info'
|
||||||
|
|
||||||
|
|||||||
308
app/components/common/DataTable.vue
Normal file
308
app/components/common/DataTable.vue
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Toolbar + counter row -->
|
||||||
|
<div
|
||||||
|
v-if="$slots.toolbar || showCounter || showPerPage"
|
||||||
|
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<slot name="toolbar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div v-if="showPerPage && pagination?.perPageOptions?.length" class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="dt-per-page"
|
||||||
|
>
|
||||||
|
Par page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="dt-per-page"
|
||||||
|
:value="pagination.perPage"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
|
||||||
|
>
|
||||||
|
<option v-for="opt in pagination.perPageOptions" :key="opt" :value="opt">
|
||||||
|
{{ opt }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="showCounter && pagination" class="text-xs text-base-content/50 whitespace-nowrap">
|
||||||
|
{{ pagination.pageItems }} / {{ pagination.totalItems }}
|
||||||
|
résultat{{ pagination.totalItems > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state (full spinner only when no filterable columns to keep visible) -->
|
||||||
|
<div v-if="loading && !hasFilterableColumns" class="flex justify-center py-8">
|
||||||
|
<slot name="loading">
|
||||||
|
<span class="loading loading-spinner" aria-hidden="true" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (no data at all, no filterable columns to keep visible) -->
|
||||||
|
<template v-else-if="isEmpty && !hasFilterableColumns">
|
||||||
|
<slot name="empty">
|
||||||
|
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||||
|
{{ emptyMessage }}
|
||||||
|
</p>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No results without filterable columns -->
|
||||||
|
<template v-else-if="rows.length === 0 && !hasFilterableColumns">
|
||||||
|
<slot name="no-results">
|
||||||
|
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||||
|
{{ noResultsMessage }}
|
||||||
|
</p>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="overflow-x-auto relative">
|
||||||
|
<!-- Loading overlay (keeps table & filter inputs visible) -->
|
||||||
|
<div
|
||||||
|
v-if="loading && hasFilterableColumns"
|
||||||
|
class="absolute inset-0 bg-base-100/50 z-10 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="loading loading-spinner" 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/50 p-4">
|
||||||
|
<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>
|
||||||
@@ -24,9 +24,10 @@
|
|||||||
<li v-for="link in simpleLinks" :key="link.to">
|
<li v-for="link in simpleLinks" :key="link.to">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
class="rounded-md px-2 py-1 transition-colors"
|
class="rounded-md px-2 py-1 transition-colors flex items-center gap-2"
|
||||||
:class="linkClass(link)"
|
:class="linkClass(link)"
|
||||||
>
|
>
|
||||||
|
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -46,7 +47,10 @@
|
|||||||
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
||||||
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
|
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
|
||||||
>
|
>
|
||||||
<span>{{ group.label }}</span>
|
<span class="flex items-center gap-2">
|
||||||
|
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||||
|
{{ group.label }}
|
||||||
|
</span>
|
||||||
<IconLucideChevronRight
|
<IconLucideChevronRight
|
||||||
class="h-4 w-4 transition-transform"
|
class="h-4 w-4 transition-transform"
|
||||||
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
||||||
@@ -100,9 +104,10 @@
|
|||||||
<li v-for="link in simpleLinks" :key="link.to">
|
<li v-for="link in simpleLinks" :key="link.to">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
class="transition-colors px-3 py-2 rounded-md"
|
class="transition-colors px-3 py-2 rounded-md flex items-center gap-1.5"
|
||||||
:class="linkClass(link)"
|
:class="linkClass(link)"
|
||||||
>
|
>
|
||||||
|
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -119,13 +124,14 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 transition-colors"
|
||||||
:class="groupClass(group)"
|
:class="groupClass(group)"
|
||||||
:aria-expanded="openDropdown === group.id + '-desktop'"
|
:aria-expanded="openDropdown === group.id + '-desktop'"
|
||||||
@click="toggleDropdown(group.id + '-desktop')"
|
@click="toggleDropdown(group.id + '-desktop')"
|
||||||
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
||||||
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
|
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
|
||||||
>
|
>
|
||||||
|
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||||
{{ group.label }}
|
{{ group.label }}
|
||||||
<IconLucideChevronRight
|
<IconLucideChevronRight
|
||||||
class="h-4 w-4 transition-transform"
|
class="h-4 w-4 transition-transform"
|
||||||
@@ -233,7 +239,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
|
||||||
import { useRoute } from '#imports'
|
import { useRoute } from '#imports'
|
||||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||||
import { usePermissions } from '~/composables/usePermissions'
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
@@ -243,6 +249,13 @@ import IconLucideMenu from '~icons/lucide/menu'
|
|||||||
import IconLucideSettings from '~icons/lucide/settings'
|
import IconLucideSettings from '~icons/lucide/settings'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||||
|
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||||
|
import IconLucideFactory from '~icons/lucide/factory'
|
||||||
|
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||||
|
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 logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -253,25 +266,38 @@ defineEmits<{
|
|||||||
interface NavLink {
|
interface NavLink {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
|
icon?: Component
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
icon?: Component
|
||||||
activePaths: string[]
|
activePaths: string[]
|
||||||
children: NavLink[]
|
children: NavLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const simpleLinks: NavLink[] = [
|
const simpleLinks: NavLink[] = [
|
||||||
{ to: '/', label: 'Vue d\'ensemble' },
|
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||||
{ to: '/machines', label: 'Parc Machines' },
|
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||||
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
|
{ to: '/machine-skeleton', label: 'Squelettes', icon: IconLucideClipboardList },
|
||||||
]
|
]
|
||||||
|
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
id: 'component',
|
||||||
|
label: 'Composants',
|
||||||
|
icon: IconLucideCpu,
|
||||||
|
activePaths: ['/component-category', '/component-catalog'],
|
||||||
|
children: [
|
||||||
|
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||||
|
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pieces',
|
id: 'pieces',
|
||||||
label: 'Pièces',
|
label: 'Pièces',
|
||||||
|
icon: IconLucidePuzzle,
|
||||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||||
@@ -281,24 +307,17 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: 'products',
|
id: 'products',
|
||||||
label: 'Produits',
|
label: 'Produits',
|
||||||
|
icon: IconLucidePackage,
|
||||||
activePaths: ['/product-category', '/product-catalog'],
|
activePaths: ['/product-category', '/product-catalog'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'component',
|
|
||||||
label: 'Composant',
|
|
||||||
activePaths: ['/component-category', '/component-catalog'],
|
|
||||||
children: [
|
|
||||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
|
||||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'resources',
|
id: 'resources',
|
||||||
label: 'Ressources liées',
|
label: 'Ressources liées',
|
||||||
|
icon: IconLucideLink,
|
||||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/sites', label: 'Sites' },
|
{ to: '/sites', label: 'Sites' },
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
:class="documentThumbnailClass(doc)"
|
:class="documentThumbnailClass(doc)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(doc) && doc.path"
|
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
|
||||||
:src="doc.path"
|
:src="doc.fileUrl || doc.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${doc.name}`"
|
:alt="`Aperçu de ${doc.name}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,35 +9,95 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ModelTypesToolbar
|
<nav
|
||||||
:category="selectedCategory"
|
v-if="allowCategorySwitch"
|
||||||
:search="searchInput"
|
class="tabs tabs-boxed inline-flex"
|
||||||
:sort="sort"
|
role="tablist"
|
||||||
:dir="dir"
|
aria-label="Catégories"
|
||||||
:loading="loading"
|
>
|
||||||
:show-category-tabs="allowCategorySwitch"
|
<button
|
||||||
:can-edit="canEdit"
|
v-for="option in categories"
|
||||||
@update:category="onCategoryChange"
|
:key="option.value"
|
||||||
@update:search="onSearchInput"
|
type="button"
|
||||||
@update:sort="onSortChange"
|
class="tab"
|
||||||
@update:dir="onDirChange"
|
:class="{ 'tab-active': option.value === selectedCategory }"
|
||||||
@create="openCreatePage"
|
role="tab"
|
||||||
/>
|
:aria-selected="option.value === selectedCategory"
|
||||||
|
@click="onCategoryChange(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<ModelTypesTable
|
<DataTable
|
||||||
:items="items"
|
:columns="columns"
|
||||||
|
:rows="items"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:total="total"
|
:sort="currentSort"
|
||||||
:limit="limit"
|
:pagination="paginationState"
|
||||||
:offset="offset"
|
:show-per-page="true"
|
||||||
:category="selectedCategory"
|
row-key="id"
|
||||||
:can-edit="canEdit"
|
empty-message="Aucune catégorie trouvée."
|
||||||
@related="openRelatedModal"
|
no-results-message="Aucune catégorie ne correspond à votre recherche."
|
||||||
@edit="openEditPage"
|
@sort="handleSort"
|
||||||
@delete="confirmDelete"
|
@update:current-page="handlePageChange"
|
||||||
@convert="openConversionModal"
|
@update:per-page="handlePerPageChange"
|
||||||
@update:offset="onOffsetChange"
|
>
|
||||||
/>
|
<template #toolbar>
|
||||||
|
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||||
|
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
v-model="searchInput"
|
||||||
|
type="search"
|
||||||
|
class="grow min-w-0"
|
||||||
|
placeholder="Rechercher par nom…"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<ModelTypesConversionModal
|
<ModelTypesConversionModal
|
||||||
:open="conversionModalOpen"
|
:open="conversionModalOpen"
|
||||||
@@ -57,7 +117,7 @@
|
|||||||
|
|
||||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||||
Chargement des éléments liés…
|
Chargement des éléments liés…
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,43 +163,46 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from '#imports'
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||||
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useApi } from "~/composables/useApi";
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import { useUrlState } from "~/composables/useUrlState";
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
type ModelCategory,
|
type ModelCategory,
|
||||||
type ModelType,
|
type ModelType,
|
||||||
type ModelTypeListResponse,
|
type ModelTypeListResponse,
|
||||||
} from "~/services/modelTypes";
|
} from '~/services/modelTypes'
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from '~/composables/useToast'
|
||||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
|
import IconLucideSearch from '~icons/lucide/search'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
const DEFAULT_DESCRIPTION =
|
const DEFAULT_DESCRIPTION
|
||||||
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
category: ModelCategory;
|
category: ModelCategory
|
||||||
heading: string;
|
heading: string
|
||||||
description?: string;
|
description?: string
|
||||||
allowCategorySwitch?: boolean;
|
allowCategorySwitch?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
allowCategorySwitch: false,
|
allowCategorySwitch: false,
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
const selectedCategory = ref<ModelCategory>(props.category);
|
const selectedCategory = ref<ModelCategory>(props.category)
|
||||||
const searchInput = ref("");
|
const searchInput = ref('')
|
||||||
|
|
||||||
// State synced with URL query params (preserved on back/forward navigation)
|
// State synced with URL query params
|
||||||
const urlState = useUrlState({
|
const urlState = useUrlState({
|
||||||
q: { default: '' },
|
q: { default: '' },
|
||||||
sort: { default: 'name' },
|
sort: { default: 'name' },
|
||||||
@@ -148,81 +211,126 @@ const urlState = useUrlState({
|
|||||||
offset: { default: 0, type: 'number' },
|
offset: { default: 0, type: 'number' },
|
||||||
}, {
|
}, {
|
||||||
onRestore: () => {
|
onRestore: () => {
|
||||||
searchInput.value = urlState.q.value;
|
searchInput.value = urlState.q.value
|
||||||
refresh();
|
doRefresh()
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
const searchTerm = urlState.q;
|
const searchTerm = urlState.q
|
||||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
|
const sort = urlState.sort as Ref<'name' | 'createdAt'>
|
||||||
const dir = urlState.dir as Ref<'asc' | 'desc'>;
|
const dir = urlState.dir as Ref<'asc' | 'desc'>
|
||||||
const limit = urlState.limit;
|
const limit = urlState.limit
|
||||||
const offset = urlState.offset;
|
const offset = urlState.offset
|
||||||
|
|
||||||
// Initialize searchInput from URL (for direct navigation with ?q=...)
|
// Initialize searchInput from URL
|
||||||
searchInput.value = searchTerm.value;
|
searchInput.value = searchTerm.value
|
||||||
|
|
||||||
const items = ref<ModelType[]>([]);
|
const items = ref<ModelType[]>([])
|
||||||
const total = ref(0);
|
const total = ref(0)
|
||||||
const loading = ref(false);
|
const loading = ref(false)
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let activeController: AbortController | null = null;
|
let activeController: AbortController | null = null
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast()
|
||||||
const { get } = useApi();
|
const { get } = useApi()
|
||||||
const { canEdit } = usePermissions();
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading)
|
||||||
const descriptionText = computed(
|
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
|
||||||
() => props.description ?? DEFAULT_DESCRIPTION
|
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
|
||||||
);
|
|
||||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
|
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({ title: headingText.value }))
|
||||||
title: headingText.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown) => {
|
const columns = [
|
||||||
if (error && typeof error === "object") {
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const showConvertButton = computed(() =>
|
||||||
|
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||||
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
|
{ label: 'Pièces', value: 'PIECE' },
|
||||||
|
{ label: 'Produits', value: 'PRODUCT' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Sort state for DataTable
|
||||||
|
const currentSort = computed<DataTableSort>(() => ({
|
||||||
|
field: sort.value,
|
||||||
|
direction: dir.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSort = (newSort: DataTableSort) => {
|
||||||
|
sort.value = newSort.field as 'name' | 'createdAt'
|
||||||
|
dir.value = newSort.direction as 'asc' | 'desc'
|
||||||
|
offset.value = 0
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination: convert offset/limit to page-based for DataTable
|
||||||
|
const currentPage = computed(() => {
|
||||||
|
if (limit.value <= 0) return 1
|
||||||
|
return Math.floor(offset.value / limit.value) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (limit.value <= 0) return 1
|
||||||
|
return Math.max(1, Math.ceil(total.value / limit.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginationState = computed(() => ({
|
||||||
|
currentPage: currentPage.value,
|
||||||
|
totalPages: totalPages.value,
|
||||||
|
totalItems: total.value,
|
||||||
|
pageItems: items.value.length,
|
||||||
|
perPageOptions: [20, 50, 100],
|
||||||
|
perPage: limit.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
offset.value = (page - 1) * limit.value
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerPageChange = (perPage: number) => {
|
||||||
|
limit.value = perPage
|
||||||
|
offset.value = 0
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractErrorMessage = (error: unknown): string => {
|
||||||
|
let raw: string | null = null
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
const maybeFetchError = error as {
|
const maybeFetchError = error as {
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>
|
||||||
statusMessage?: string;
|
statusMessage?: string
|
||||||
message?: string;
|
message?: string
|
||||||
};
|
}
|
||||||
if (maybeFetchError.data) {
|
if (maybeFetchError.data) {
|
||||||
const data = maybeFetchError.data;
|
const data = maybeFetchError.data
|
||||||
if (typeof data.message === "string") {
|
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||||
return data.message;
|
else if (typeof data.detail === 'string') raw = data.detail
|
||||||
}
|
else if (typeof data.message === 'string') raw = data.message
|
||||||
if (Array.isArray(data.message) && data.message.length > 0) {
|
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
|
||||||
return data.message[0];
|
else if (typeof data.error === 'string') raw = data.error
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.statusMessage === "string") {
|
|
||||||
return maybeFetchError.statusMessage;
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.message === "string") {
|
|
||||||
return maybeFetchError.message;
|
|
||||||
}
|
}
|
||||||
|
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
|
||||||
|
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
|
||||||
}
|
}
|
||||||
return "Une erreur est survenue lors de la communication avec le serveur.";
|
return humanizeError(raw)
|
||||||
};
|
}
|
||||||
|
|
||||||
const refresh = async ({
|
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
|
||||||
resetOffset = false,
|
if (resetOffset) offset.value = 0
|
||||||
}: { resetOffset?: boolean } = {}) => {
|
|
||||||
if (resetOffset) {
|
|
||||||
offset.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeController) {
|
if (activeController) activeController.abort()
|
||||||
activeController.abort();
|
const controller = new AbortController()
|
||||||
}
|
activeController = controller
|
||||||
|
loading.value = true
|
||||||
const controller = new AbortController();
|
|
||||||
activeController = controller;
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: ModelTypeListResponse = await listModelTypes(
|
const response: ModelTypeListResponse = await listModelTypes(
|
||||||
@@ -234,312 +342,236 @@ const refresh = async ({
|
|||||||
limit: limit.value,
|
limit: limit.value,
|
||||||
offset: offset.value,
|
offset: offset.value,
|
||||||
},
|
},
|
||||||
{ signal: controller.signal }
|
{ signal: controller.signal },
|
||||||
);
|
)
|
||||||
|
items.value = response.items
|
||||||
items.value = response.items;
|
total.value = response.total
|
||||||
total.value = response.total;
|
offset.value = response.offset
|
||||||
offset.value = response.offset;
|
limit.value = response.limit
|
||||||
limit.value = response.limit;
|
}
|
||||||
} catch (error: unknown) {
|
catch (error: unknown) {
|
||||||
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
|
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||||
return;
|
showError(extractErrorMessage(error))
|
||||||
}
|
}
|
||||||
showError(extractErrorMessage(error));
|
finally {
|
||||||
} finally {
|
|
||||||
if (activeController === controller) {
|
if (activeController === controller) {
|
||||||
loading.value = false;
|
loading.value = false
|
||||||
activeController = null;
|
activeController = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.category,
|
() => props.category,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value !== selectedCategory.value) {
|
if (value !== selectedCategory.value) {
|
||||||
selectedCategory.value = value;
|
selectedCategory.value = value
|
||||||
refresh({ resetOffset: true });
|
doRefresh({ resetOffset: true })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
const onSearchInput = (value: string) => {
|
|
||||||
searchInput.value = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCategoryChange = (value: ModelCategory) => {
|
const onCategoryChange = (value: ModelCategory) => {
|
||||||
if (!allowCategorySwitch.value) {
|
if (!props.allowCategorySwitch) return
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedCategory.value !== value) {
|
if (selectedCategory.value !== value) {
|
||||||
selectedCategory.value = value;
|
selectedCategory.value = value
|
||||||
refresh({ resetOffset: true });
|
doRefresh({ resetOffset: true })
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onSortChange = (value: "name" | "createdAt") => {
|
|
||||||
if (sort.value !== value) {
|
|
||||||
sort.value = value;
|
|
||||||
refresh({ resetOffset: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDirChange = (value: "asc" | "desc") => {
|
|
||||||
if (dir.value !== value) {
|
|
||||||
dir.value = value;
|
|
||||||
refresh({ resetOffset: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOffsetChange = (value: number) => {
|
|
||||||
const nextOffset = Math.max(0, value);
|
|
||||||
if (nextOffset !== offset.value) {
|
|
||||||
offset.value = nextOffset;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveCategoryBasePath = (category: ModelCategory) => {
|
const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||||
if (category === "COMPONENT") {
|
if (category === 'COMPONENT') return '/component-category'
|
||||||
return "/component-category";
|
if (category === 'PIECE') return '/piece-category'
|
||||||
}
|
return '/product-category'
|
||||||
if (category === "PIECE") {
|
}
|
||||||
return "/piece-category";
|
|
||||||
}
|
|
||||||
return "/product-category";
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreatePage = () => {
|
const openCreatePage = () => {
|
||||||
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
||||||
router.push(`${basePath}/new`).catch(() => {
|
router.push(`${basePath}/new`).catch(() => {
|
||||||
showError("Navigation impossible vers la page de création.");
|
showError('Navigation impossible vers la page de création.')
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const openEditPage = (item: ModelType) => {
|
const openEditPage = (item: ModelType) => {
|
||||||
const category = item.category ?? selectedCategory.value;
|
const category = item.category ?? selectedCategory.value
|
||||||
const basePath = resolveCategoryBasePath(category);
|
const basePath = resolveCategoryBasePath(category)
|
||||||
router.push(`${basePath}/${item.id}/edit`).catch(() => {
|
router.push(`${basePath}/${item.id}/edit`).catch(() => {
|
||||||
showError("Navigation impossible vers la page d'édition.");
|
showError("Navigation impossible vers la page d'édition.")
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
const confirmDelete = async (item: ModelType) => {
|
const confirmDelete = async (item: ModelType) => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||||
});
|
})
|
||||||
if (!confirmed) {
|
if (!confirmed) return
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteModelType(item.id);
|
await deleteModelType(item.id)
|
||||||
invalidateEntityTypeCache(item.category);
|
invalidateEntityTypeCache(item.category)
|
||||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
showSuccess(`Type « ${item.name} » supprimé avec succès.`)
|
||||||
|
|
||||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||||
offset.value = Math.max(0, offset.value - limit.value);
|
offset.value = Math.max(0, offset.value - limit.value)
|
||||||
}
|
}
|
||||||
|
await doRefresh()
|
||||||
await refresh();
|
|
||||||
} catch (error) {
|
|
||||||
showError(extractErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
catch (error) {
|
||||||
|
showError(extractErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type RelatedEntry = {
|
type RelatedEntry = {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
reference?: string | null;
|
reference?: string | null
|
||||||
};
|
}
|
||||||
|
|
||||||
const relatedModalOpen = ref(false);
|
const relatedModalOpen = ref(false)
|
||||||
const relatedLoading = ref(false);
|
const relatedLoading = ref(false)
|
||||||
const relatedError = ref<string | null>(null);
|
const relatedError = ref<string | null>(null)
|
||||||
const relatedItems = ref<RelatedEntry[]>([]);
|
const relatedItems = ref<RelatedEntry[]>([])
|
||||||
const relatedType = ref<ModelType | null>(null);
|
const relatedType = ref<ModelType | null>(null)
|
||||||
|
|
||||||
const relatedCategoryLabels: Record<
|
const relatedCategoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
|
||||||
ModelCategory,
|
COMPONENT: { plural: 'composants', singular: 'composant' },
|
||||||
{ plural: string; singular: string }
|
PIECE: { plural: 'pièces', singular: 'pièce' },
|
||||||
> = {
|
PRODUCT: { plural: 'produits', singular: 'produit' },
|
||||||
COMPONENT: { plural: "composants", singular: "composant" },
|
}
|
||||||
PIECE: { plural: "pièces", singular: "pièce" },
|
|
||||||
PRODUCT: { plural: "produits", singular: "produit" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const relatedModalTitle = computed(() => {
|
const relatedModalTitle = computed(() => {
|
||||||
const current = relatedType.value;
|
const current = relatedType.value
|
||||||
if (!current) {
|
if (!current) return 'Éléments liés'
|
||||||
return "Éléments liés";
|
return `Éléments liés à « ${current.name} »`
|
||||||
}
|
})
|
||||||
return `Éléments liés à « ${current.name} »`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const relatedModalSubtitle = computed(() => {
|
const relatedModalSubtitle = computed(() => {
|
||||||
const current = relatedType.value;
|
const current = relatedType.value
|
||||||
if (!current) {
|
if (!current) return ''
|
||||||
return "";
|
const labels = relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT
|
||||||
}
|
const count = relatedItems.value.length
|
||||||
const labels =
|
if (relatedLoading.value) return `Chargement des ${labels.plural}…`
|
||||||
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
|
if (count === 0) return `Aucun ${labels.singular} lié.`
|
||||||
const count = relatedItems.value.length;
|
if (count === 1) return `1 ${labels.singular} lié.`
|
||||||
if (relatedLoading.value) {
|
return `${count} ${labels.plural} liés.`
|
||||||
return `Chargement des ${labels.plural}…`;
|
})
|
||||||
}
|
|
||||||
if (count === 0) {
|
|
||||||
return `Aucun ${labels.singular} lié.`;
|
|
||||||
}
|
|
||||||
if (count === 1) {
|
|
||||||
return `1 ${labels.singular} lié.`;
|
|
||||||
}
|
|
||||||
return `${count} ${labels.plural} liés.`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
|
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`
|
||||||
|
|
||||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||||
if (category === "COMPONENT") {
|
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
||||||
return { endpoint: "/composants", filterKey: "typeComposant" };
|
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
||||||
}
|
return { endpoint: '/products', filterKey: 'typeProduct' }
|
||||||
if (category === "PIECE") {
|
}
|
||||||
return { endpoint: "/pieces", filterKey: "typePiece" };
|
|
||||||
}
|
|
||||||
return { endpoint: "/products", filterKey: "typeProduct" };
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||||
if (category === "COMPONENT") {
|
if (category === 'COMPONENT') return '/component'
|
||||||
return "/component";
|
if (category === 'PIECE') return '/pieces'
|
||||||
}
|
return '/product'
|
||||||
if (category === "PIECE") {
|
}
|
||||||
return "/pieces";
|
|
||||||
}
|
|
||||||
return "/product";
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||||
if (!item || typeof item !== "object") {
|
if (!item || typeof item !== 'object') return null
|
||||||
return null;
|
const record = item as Record<string, unknown>
|
||||||
}
|
if (typeof record.id !== 'string') return null
|
||||||
const record = item as Record<string, unknown>;
|
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
||||||
if (typeof record.id !== "string") {
|
const reference
|
||||||
return null;
|
= typeof record.reference === 'string' && record.reference.trim()
|
||||||
}
|
|
||||||
const name =
|
|
||||||
typeof record.name === "string" && record.name.trim()
|
|
||||||
? record.name
|
|
||||||
: "Sans nom";
|
|
||||||
const reference =
|
|
||||||
typeof record.reference === "string" && record.reference.trim()
|
|
||||||
? record.reference
|
? record.reference
|
||||||
: typeof record.code === "string" && record.code.trim()
|
: typeof record.code === 'string' && record.code.trim()
|
||||||
? record.code
|
? record.code
|
||||||
: null;
|
: null
|
||||||
return {
|
return { id: record.id, name, reference }
|
||||||
id: record.id,
|
}
|
||||||
name,
|
|
||||||
reference,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadRelatedItems = async (item: ModelType) => {
|
const loadRelatedItems = async (item: ModelType) => {
|
||||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
|
const { endpoint, filterKey } = resolveRelatedConfig(item.category)
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams()
|
||||||
params.set("itemsPerPage", "200");
|
params.set('itemsPerPage', '200')
|
||||||
params.set(filterKey, buildModelTypeIri(item.id));
|
params.set(filterKey, buildModelTypeIri(item.id))
|
||||||
params.set("order[name]", "asc");
|
params.set('order[name]', 'asc')
|
||||||
|
|
||||||
relatedLoading.value = true;
|
relatedLoading.value = true
|
||||||
relatedError.value = null;
|
relatedError.value = null
|
||||||
relatedItems.value = [];
|
relatedItems.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await get(`${endpoint}?${params.toString()}`);
|
const result = await get(`${endpoint}?${params.toString()}`)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
relatedError.value =
|
relatedError.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||||
result.error ?? "Impossible de charger les éléments liés.";
|
return
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const collection = extractCollection(result.data);
|
const collection = extractCollection(result.data)
|
||||||
relatedItems.value = collection
|
relatedItems.value = collection
|
||||||
.map(mapRelatedEntry)
|
.map(mapRelatedEntry)
|
||||||
.filter((entry): entry is RelatedEntry => Boolean(entry));
|
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
||||||
} catch (error) {
|
|
||||||
relatedError.value = extractErrorMessage(error);
|
|
||||||
} finally {
|
|
||||||
relatedLoading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
catch (error) {
|
||||||
|
relatedError.value = extractErrorMessage(error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
relatedLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openRelatedModal = (item: ModelType) => {
|
const openRelatedModal = (item: ModelType) => {
|
||||||
relatedType.value = item;
|
relatedType.value = item
|
||||||
relatedModalOpen.value = true;
|
relatedModalOpen.value = true
|
||||||
void loadRelatedItems(item);
|
void loadRelatedItems(item)
|
||||||
};
|
}
|
||||||
|
|
||||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||||
const current = relatedType.value;
|
const current = relatedType.value
|
||||||
if (!current) {
|
if (!current) return
|
||||||
return;
|
const basePath = resolveRelatedEditBasePath(current.category)
|
||||||
}
|
relatedModalOpen.value = false
|
||||||
const basePath = resolveRelatedEditBasePath(current.category);
|
|
||||||
relatedModalOpen.value = false;
|
|
||||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||||
showError("Navigation impossible vers la fiche d'édition.");
|
showError("Navigation impossible vers la fiche d'édition.")
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeRelatedModal = () => {
|
const closeRelatedModal = () => {
|
||||||
relatedModalOpen.value = false;
|
relatedModalOpen.value = false
|
||||||
};
|
}
|
||||||
|
|
||||||
const conversionModalOpen = ref(false);
|
const conversionModalOpen = ref(false)
|
||||||
const conversionTarget = ref<ModelType | null>(null);
|
const conversionTarget = ref<ModelType | null>(null)
|
||||||
|
|
||||||
const openConversionModal = (item: ModelType) => {
|
const openConversionModal = (item: ModelType) => {
|
||||||
conversionTarget.value = item;
|
conversionTarget.value = item
|
||||||
conversionModalOpen.value = true;
|
conversionModalOpen.value = true
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeConversionModal = () => {
|
const closeConversionModal = () => {
|
||||||
conversionModalOpen.value = false;
|
conversionModalOpen.value = false
|
||||||
};
|
}
|
||||||
|
|
||||||
const onConverted = () => {
|
const onConverted = () => {
|
||||||
conversionModalOpen.value = false;
|
conversionModalOpen.value = false
|
||||||
invalidateEntityTypeCache("PIECE");
|
invalidateEntityTypeCache('PIECE')
|
||||||
invalidateEntityTypeCache("COMPONENT");
|
invalidateEntityTypeCache('COMPONENT')
|
||||||
showSuccess("Catégorie convertie avec succès.");
|
showSuccess('Catégorie convertie avec succès.')
|
||||||
refresh();
|
doRefresh()
|
||||||
};
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
}
|
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
searchTerm.value = value.trim();
|
searchTerm.value = value.trim()
|
||||||
refresh({ resetOffset: true });
|
doRefresh({ resetOffset: true })
|
||||||
}, 300);
|
}, 300)
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refresh();
|
doRefresh()
|
||||||
});
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
clearTimeout(debounceTimer);
|
if (activeController) activeController.abort()
|
||||||
}
|
})
|
||||||
if (activeController) {
|
|
||||||
activeController.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -57,8 +57,8 @@
|
|||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="flex items-center gap-3 text-sm">
|
||||||
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
|
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, toRefs } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { isImageDocument } from '~/utils/documentPreview'
|
import { isImageDocument } from '~/utils/documentPreview'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||||
@@ -173,8 +173,6 @@ const emit = defineEmits([
|
|||||||
'update:selectedFiles'
|
'update:selectedFiles'
|
||||||
])
|
])
|
||||||
|
|
||||||
const form = toRefs(props.form)
|
|
||||||
|
|
||||||
const selectedFilesModel = computed({
|
const selectedFilesModel = computed({
|
||||||
get: () => props.selectedFiles,
|
get: () => props.selectedFiles,
|
||||||
set: value => emit('update:selectedFiles', value)
|
set: value => emit('update:selectedFiles', value)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -20,11 +21,10 @@ export function useApi() {
|
|||||||
|
|
||||||
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
||||||
const url = `${API_BASE_URL}${endpoint}`
|
const url = `${API_BASE_URL}${endpoint}`
|
||||||
|
const isFormData = options.body instanceof FormData
|
||||||
const defaultOptions: ApiCallOptions = {
|
const defaultOptions: ApiCallOptions = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter un timeout à la requête
|
// Ajouter un timeout à la requête
|
||||||
@@ -60,23 +60,26 @@ export function useApi() {
|
|||||||
} else {
|
} else {
|
||||||
const contentType = response.headers.get('content-type') || ''
|
const contentType = response.headers.get('content-type') || ''
|
||||||
let errorData: Record<string, unknown> = {}
|
let errorData: Record<string, unknown> = {}
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('json')) {
|
||||||
errorData = await response.json().catch(() => ({}))
|
errorData = await response.json().catch(() => ({}))
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text().catch(() => '')
|
const text = await response.text().catch(() => '')
|
||||||
errorData = text ? { message: text } : {}
|
errorData = text ? { message: text } : {}
|
||||||
}
|
}
|
||||||
const errorMessage = response.status === 403
|
const rawMessage = response.status === 403
|
||||||
? 'Permissions insuffisantes pour cette action.'
|
? 'Permissions insuffisantes pour cette action.'
|
||||||
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
|
||||||
|
const errorMessage = humanizeError(rawMessage)
|
||||||
showError(errorMessage)
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage, status: response.status }
|
return { success: false, error: errorMessage, status: response.status }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
const err = error as Error & { name?: string }
|
const err = error as Error & { name?: string }
|
||||||
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
|
const errorMessage = err.name === 'AbortError'
|
||||||
showError(`Erreur réseau: ${errorMessage}`)
|
? 'La requête a pris trop de temps. Veuillez réessayer.'
|
||||||
|
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
|
||||||
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage }
|
return { success: false, error: errorMessage }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,13 @@ export function useApi() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
|
||||||
|
return apiCall<T>(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||||
return apiCall<T>(endpoint, { method: 'DELETE' })
|
return apiCall<T>(endpoint, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -123,6 +133,7 @@ export function useApi() {
|
|||||||
apiCall,
|
apiCall,
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
|
postFormData,
|
||||||
patch,
|
patch,
|
||||||
put,
|
put,
|
||||||
delete: del,
|
delete: del,
|
||||||
|
|||||||
@@ -68,15 +68,21 @@ export function useComments() {
|
|||||||
const fetchAllComments = async (options: {
|
const fetchAllComments = async (options: {
|
||||||
status?: string
|
status?: string
|
||||||
entityType?: string
|
entityType?: string
|
||||||
|
entityName?: string
|
||||||
page?: number
|
page?: number
|
||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
|
orderBy?: string
|
||||||
|
orderDir?: string
|
||||||
} = {}): Promise<CommentListResult> => {
|
} = {}): Promise<CommentListResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (options.status) params.set('status', options.status)
|
if (options.status) params.set('status', options.status)
|
||||||
if (options.entityType) params.set('entityType', options.entityType)
|
if (options.entityType) params.set('entityType', options.entityType)
|
||||||
params.set('order[createdAt]', 'desc')
|
if (options.entityName) params.set('entityName', options.entityName)
|
||||||
|
const sortField = options.orderBy || 'createdAt'
|
||||||
|
const sortDir = options.orderDir || 'desc'
|
||||||
|
params.set(`order[${sortField}]`, sortDir)
|
||||||
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||||
params.set('page', String(options.page || 1))
|
params.set('page', String(options.page || 1))
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Composant {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
|
description?: string | null
|
||||||
typeComposantId?: string | null
|
typeComposantId?: string | null
|
||||||
typeComposant?: { id: string; name?: string } | null
|
typeComposant?: { id: string; name?: string } | null
|
||||||
productId?: string | null
|
productId?: string | null
|
||||||
@@ -40,6 +41,7 @@ interface LoadComposantsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
typeName?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +108,11 @@ export function useComposants() {
|
|||||||
itemsPerPage = 30,
|
itemsPerPage = 30,
|
||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
|
typeName,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1) {
|
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -134,6 +137,10 @@ export function useComposants() {
|
|||||||
params.set('name', search.trim())
|
params.set('name', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeName && typeName.trim()) {
|
||||||
|
params.set('typeComposant.name', typeName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/composants?${params.toString()}`)
|
const result = await get(`/composants?${params.toString()}`)
|
||||||
|
|||||||
186
app/composables/useDataTable.ts
Normal file
186
app/composables/useDataTable.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
||||||
|
import { useUrlState } from './useUrlState'
|
||||||
|
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
|
||||||
|
|
||||||
|
export interface UseDataTableDeps {
|
||||||
|
/** Called whenever sort/page/search/perPage/filter changes. The composable does NOT fetch data itself. */
|
||||||
|
fetchData: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDataTableOptions {
|
||||||
|
/** Default sort field */
|
||||||
|
defaultSort?: string
|
||||||
|
/** Default sort direction */
|
||||||
|
defaultDirection?: SortDirection
|
||||||
|
/** Default items per page */
|
||||||
|
defaultPerPage?: number
|
||||||
|
/** Available per-page options */
|
||||||
|
perPageOptions?: number[]
|
||||||
|
/** Search debounce in ms. Default: 300 */
|
||||||
|
searchDebounceMs?: number
|
||||||
|
/** Whether to persist state to URL. Default: true */
|
||||||
|
persistToUrl?: boolean
|
||||||
|
/** Extra URL state params for page-specific filters */
|
||||||
|
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDataTableReturn {
|
||||||
|
searchTerm: Ref<string>
|
||||||
|
sortField: Ref<string>
|
||||||
|
sortDirection: Ref<SortDirection>
|
||||||
|
currentPage: Ref<number>
|
||||||
|
itemsPerPage: Ref<number>
|
||||||
|
columnFilters: Ref<DataTableColumnFilters>
|
||||||
|
filters: Record<string, Ref<string | number>>
|
||||||
|
sort: ComputedRef<DataTableSort>
|
||||||
|
pagination: (total: Ref<number>, pageItems: Ref<number>) => ComputedRef<DataTablePagination>
|
||||||
|
handleSort: (newSort: DataTableSort) => void
|
||||||
|
handlePageChange: (page: number) => void
|
||||||
|
handlePerPageChange: (perPage: number) => void
|
||||||
|
handleFilterChange: () => void
|
||||||
|
handleColumnFiltersChange: (filters: DataTableColumnFilters) => void
|
||||||
|
debouncedSearch: () => void
|
||||||
|
refresh: () => void
|
||||||
|
perPageOptions: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataTable(
|
||||||
|
deps: UseDataTableDeps,
|
||||||
|
options: UseDataTableOptions = {},
|
||||||
|
): UseDataTableReturn {
|
||||||
|
const {
|
||||||
|
defaultSort = 'name',
|
||||||
|
defaultDirection = 'asc',
|
||||||
|
defaultPerPage = 20,
|
||||||
|
perPageOptions = [20, 50, 100],
|
||||||
|
searchDebounceMs = 300,
|
||||||
|
persistToUrl = true,
|
||||||
|
extraParams = {},
|
||||||
|
} = options
|
||||||
|
|
||||||
|
let searchTerm: Ref<string>
|
||||||
|
let sortField: Ref<string>
|
||||||
|
let sortDirection: Ref<SortDirection>
|
||||||
|
let currentPage: Ref<number>
|
||||||
|
let itemsPerPage: Ref<number>
|
||||||
|
const filters: Record<string, Ref<string | number>> = {}
|
||||||
|
|
||||||
|
if (persistToUrl) {
|
||||||
|
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
|
||||||
|
page: { default: 1, type: 'number' },
|
||||||
|
perPage: { default: defaultPerPage, type: 'number' },
|
||||||
|
q: { default: '', debounce: searchDebounceMs },
|
||||||
|
sort: { default: defaultSort },
|
||||||
|
dir: { default: defaultDirection },
|
||||||
|
...extraParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = useUrlState(paramDefs, {
|
||||||
|
onRestore: () => deps.fetchData(),
|
||||||
|
})
|
||||||
|
|
||||||
|
searchTerm = state.q as Ref<string>
|
||||||
|
sortField = state.sort as Ref<string>
|
||||||
|
sortDirection = state.dir as unknown as Ref<SortDirection>
|
||||||
|
currentPage = state.page as unknown as Ref<number>
|
||||||
|
itemsPerPage = state.perPage as unknown as Ref<number>
|
||||||
|
|
||||||
|
for (const key of Object.keys(extraParams)) {
|
||||||
|
filters[key] = (state as Record<string, Ref<string | number>>)[key]!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
searchTerm = ref('')
|
||||||
|
sortField = ref(defaultSort)
|
||||||
|
sortDirection = ref(defaultDirection) as Ref<SortDirection>
|
||||||
|
currentPage = ref(1)
|
||||||
|
itemsPerPage = ref(defaultPerPage)
|
||||||
|
|
||||||
|
for (const [key, def] of Object.entries(extraParams)) {
|
||||||
|
filters[key] = ref(def.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search debounce
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const debouncedSearch = () => {
|
||||||
|
if (searchTimeout) clearTimeout(searchTimeout)
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentPage.value = 1
|
||||||
|
deps.fetchData()
|
||||||
|
}, searchDebounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const sort = computed<DataTableSort>(() => ({
|
||||||
|
field: sortField.value,
|
||||||
|
direction: sortDirection.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSort = (newSort: DataTableSort) => {
|
||||||
|
sortField.value = newSort.field
|
||||||
|
sortDirection.value = newSort.direction
|
||||||
|
currentPage.value = 1
|
||||||
|
deps.fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
deps.fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerPageChange = (perPage: number) => {
|
||||||
|
itemsPerPage.value = perPage
|
||||||
|
currentPage.value = 1
|
||||||
|
deps.fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column filters
|
||||||
|
const columnFilters = ref<DataTableColumnFilters>({})
|
||||||
|
|
||||||
|
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
|
||||||
|
columnFilters.value = newFilters
|
||||||
|
currentPage.value = 1
|
||||||
|
deps.fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic filter change handler (resets page and refetches)
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
deps.fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = (total: Ref<number>, pageItems: Ref<number>): ComputedRef<DataTablePagination> =>
|
||||||
|
computed(() => ({
|
||||||
|
currentPage: currentPage.value,
|
||||||
|
totalPages: Math.ceil(total.value / itemsPerPage.value) || 1,
|
||||||
|
totalItems: total.value,
|
||||||
|
pageItems: pageItems.value,
|
||||||
|
perPageOptions,
|
||||||
|
perPage: itemsPerPage.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const refresh = () => deps.fetchData()
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchTerm,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
columnFilters,
|
||||||
|
filters,
|
||||||
|
sort,
|
||||||
|
pagination,
|
||||||
|
handleSort,
|
||||||
|
handlePageChange,
|
||||||
|
handlePerPageChange,
|
||||||
|
handleFilterChange,
|
||||||
|
handleColumnFiltersChange,
|
||||||
|
debouncedSearch,
|
||||||
|
refresh,
|
||||||
|
perPageOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { normalizeRelationIds } from '~/shared/apiRelations'
|
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
@@ -10,12 +9,21 @@ export interface Document {
|
|||||||
filename: string
|
filename: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
size: number
|
size: number
|
||||||
path: string
|
fileUrl: string
|
||||||
|
downloadUrl: string
|
||||||
|
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||||
|
path?: string
|
||||||
|
createdAt?: string
|
||||||
siteId?: string
|
siteId?: string
|
||||||
machineId?: string
|
machineId?: string
|
||||||
composantId?: string
|
composantId?: string
|
||||||
productId?: string
|
productId?: string
|
||||||
pieceId?: string
|
pieceId?: string
|
||||||
|
site?: { id: string; name?: string } | null
|
||||||
|
machine?: { id: string; name?: string } | null
|
||||||
|
composant?: { id: string; name?: string } | null
|
||||||
|
piece?: { id: string; name?: string } | null
|
||||||
|
product?: { id: string; name?: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadContext {
|
export interface UploadContext {
|
||||||
@@ -32,19 +40,30 @@ export interface DocumentResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const documents = ref<Document[]>([])
|
interface LoadDocumentsOptions {
|
||||||
const loading = ref(false)
|
search?: string
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
orderBy?: string
|
||||||
|
orderDir?: 'asc' | 'desc'
|
||||||
|
attachmentFilter?: string
|
||||||
|
force?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const fileToBase64 = (file: File): Promise<string> =>
|
const documents = ref<Document[]>([])
|
||||||
new Promise((resolve, reject) => {
|
const total = ref(0)
|
||||||
const reader = new FileReader()
|
const loading = ref(false)
|
||||||
reader.onload = () => resolve(reader.result as string)
|
const loaded = ref(false)
|
||||||
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
|
|
||||||
reader.readAsDataURL(file)
|
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||||
})
|
const p = payload as Record<string, unknown> | null
|
||||||
|
if (typeof p?.totalItems === 'number') return p.totalItems
|
||||||
|
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
|
||||||
|
return fallbackLength
|
||||||
|
}
|
||||||
|
|
||||||
export function useDocuments() {
|
export function useDocuments() {
|
||||||
const { get, post, delete: del } = useApi()
|
const { get, postFormData, delete: del } = useApi()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
const loadFromEndpoint = async (
|
const loadFromEndpoint = async (
|
||||||
@@ -76,10 +95,61 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocuments = async (
|
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
|
||||||
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
const {
|
||||||
): Promise<DocumentResult> => {
|
search = '',
|
||||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'createdAt',
|
||||||
|
orderDir = 'desc',
|
||||||
|
attachmentFilter = 'all',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') {
|
||||||
|
return { success: true, data: documents.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return { success: true, data: documents.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
|
params.set('page', String(page))
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
params.set('name', search.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentFilter && attachmentFilter !== 'all') {
|
||||||
|
params.set(`exists[${attachmentFilter}]`, 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
|
const result = await get(`/documents?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection(result.data)
|
||||||
|
documents.value = items
|
||||||
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
|
return { success: true, data: items }
|
||||||
|
}
|
||||||
|
if (result.error) {
|
||||||
|
showError(result.error)
|
||||||
|
}
|
||||||
|
return result as DocumentResult
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
console.error('Erreur lors du chargement des documents:', error)
|
||||||
|
showError('Impossible de charger les documents')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocumentsBySite = async (
|
const loadDocumentsBySite = async (
|
||||||
@@ -145,18 +215,17 @@ export function useDocuments() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const dataUrl = await fileToBase64(file)
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('name', file.name)
|
||||||
|
|
||||||
const payload = normalizeRelationIds({
|
if (context.siteId) formData.append('siteId', context.siteId)
|
||||||
name: file.name,
|
if (context.machineId) formData.append('machineId', context.machineId)
|
||||||
filename: file.name,
|
if (context.composantId) formData.append('composantId', context.composantId)
|
||||||
mimeType: file.type || 'application/octet-stream',
|
if (context.productId) formData.append('productId', context.productId)
|
||||||
size: file.size,
|
if (context.pieceId) formData.append('pieceId', context.pieceId)
|
||||||
path: dataUrl,
|
|
||||||
...context,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await post('/documents', payload)
|
const result = await postFormData('/documents', formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
created.push(result.data as Document)
|
created.push(result.data as Document)
|
||||||
showSuccess(`Document "${file.name}" ajouté`)
|
showSuccess(`Document "${file.name}" ajouté`)
|
||||||
@@ -213,7 +282,9 @@ export function useDocuments() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadDocuments,
|
loadDocuments,
|
||||||
loadDocumentsBySite,
|
loadDocumentsBySite,
|
||||||
loadDocumentsByMachine,
|
loadDocumentsByMachine,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import {
|
import {
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
createModelType,
|
createModelType,
|
||||||
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: state.types.value }
|
return { success: true, data: state.types.value }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { message?: string }
|
const err = error as Error & { message?: string }
|
||||||
const message = err?.message || 'Erreur inconnue'
|
const message = humanizeError(err?.message)
|
||||||
showError(`Impossible de charger les types de ${label}: ${message}`)
|
showError(`Impossible de charger les types de ${label}.`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la création du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { usePieces } from '~/composables/usePieces'
|
|||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
||||||
import {
|
import {
|
||||||
useMachineCreatePreview,
|
useMachineCreatePreview,
|
||||||
@@ -365,10 +366,10 @@ export function useMachineCreatePage() {
|
|||||||
clearRequirementSelections()
|
clearRequirementSelections()
|
||||||
await navigateTo('/machines')
|
await navigateTo('/machines')
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.showError(`Impossible de créer la machine: ${result.error}`)
|
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(`Erreur lors de la création: ${error.message}`)
|
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Piece {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
|
description?: string | null
|
||||||
typePieceId?: string | null
|
typePieceId?: string | null
|
||||||
typePiece?: { id: string; name?: string } | null
|
typePiece?: { id: string; name?: string } | null
|
||||||
productId?: string | null
|
productId?: string | null
|
||||||
@@ -41,6 +42,7 @@ interface LoadPiecesOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
typeName?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,10 +118,11 @@ export function usePieces() {
|
|||||||
itemsPerPage = 30,
|
itemsPerPage = 30,
|
||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
|
typeName,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1) {
|
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -144,6 +147,10 @@ export function usePieces() {
|
|||||||
params.set('name', search.trim())
|
params.set('name', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeName && typeName.trim()) {
|
||||||
|
params.set('typePiece.name', typeName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/pieces?${params.toString()}`)
|
const result = await get(`/pieces?${params.toString()}`)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
@@ -39,6 +40,7 @@ interface LoadProductsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
typeName?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +117,11 @@ export function useProducts() {
|
|||||||
itemsPerPage = 30,
|
itemsPerPage = 30,
|
||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
|
typeName,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1) {
|
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -143,6 +146,10 @@ export function useProducts() {
|
|||||||
params.set('name', search.trim())
|
params.set('name', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeName && typeName.trim()) {
|
||||||
|
params.set('typeProduct.name', typeName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/products?${params.toString()}`)
|
const result = await get(`/products?${params.toString()}`)
|
||||||
@@ -168,9 +175,9 @@ export function useProducts() {
|
|||||||
return result as ProductListResult
|
return result as ProductListResult
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors du chargement des produits:', err)
|
console.error('Erreur lors du chargement des produits:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(`Impossible de charger les produits: ${message}`)
|
showError(`Impossible de charger les produits.`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -197,9 +204,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la création du produit:', err)
|
console.error('Erreur lors de la création du produit:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de créer le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -223,9 +230,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la mise à jour du produit:', err)
|
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de mettre à jour le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -248,9 +255,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la suppression du produit:', err)
|
console.error('Erreur lors de la suppression du produit:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de supprimer le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { navigateTo, useRoute } from '#imports'
|
import { navigateTo, useRoute } from '#imports'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
@@ -23,6 +24,8 @@ type SiteDocument = {
|
|||||||
mimeType?: string
|
mimeType?: string
|
||||||
size?: number
|
size?: number
|
||||||
path?: string
|
path?: string
|
||||||
|
fileUrl?: string
|
||||||
|
downloadUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteWithDocuments = {
|
type SiteWithDocuments = {
|
||||||
@@ -209,17 +212,23 @@ export function useSiteManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const downloadDocument = (doc: SiteDocument) => {
|
const downloadDocument = (doc: SiteDocument) => {
|
||||||
if (!doc?.path) return
|
if (doc?.downloadUrl) {
|
||||||
|
window.open(doc.downloadUrl, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (doc.path.startsWith('data:')) {
|
const url = doc?.fileUrl || doc?.path
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
if (url.startsWith('data:')) {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = doc.path
|
link.href = url
|
||||||
link.download = doc.filename || doc.name || 'document'
|
link.download = doc.filename || doc.name || 'document'
|
||||||
link.click()
|
link.click()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(doc.path, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPreview = (doc: SiteDocument) => {
|
const openPreview = (doc: SiteDocument) => {
|
||||||
@@ -266,10 +275,10 @@ export function useSiteManagement() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
showError(`Impossible de supprimer le site : ${humanizeError(error.message)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,19 @@ const toasts = ref<Toast[]>([])
|
|||||||
const MAX_TOASTS = 3
|
const MAX_TOASTS = 3
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
|
// Anti-doublon : ignore un toast identique affiché dans les 2 dernières secondes
|
||||||
|
const recentMessages = new Map<string, number>()
|
||||||
|
const DEDUP_WINDOW = 2000
|
||||||
|
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
||||||
|
const dedupKey = `${type}::${message}`
|
||||||
|
const lastShown = recentMessages.get(dedupKey)
|
||||||
|
if (lastShown && Date.now() - lastShown < DEDUP_WINDOW) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
recentMessages.set(dedupKey, Date.now())
|
||||||
|
|
||||||
const id = nextId++
|
const id = nextId++
|
||||||
const toast: Toast = {
|
const toast: Toast = {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -9,8 +9,24 @@
|
|||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<DataTable
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
:columns="columns"
|
||||||
|
:rows="entries"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:show-per-page="true"
|
||||||
|
:show-counter="true"
|
||||||
|
:expandable="true"
|
||||||
|
:expanded-keys="expandedIds"
|
||||||
|
:can-expand="canExpandRow"
|
||||||
|
row-key="id"
|
||||||
|
empty-message="Aucune activité enregistrée."
|
||||||
|
no-results-message="Aucune activité ne correspond à vos filtres."
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@toggle-expand="toggleExpanded"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label
|
<label
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
@@ -22,7 +38,7 @@
|
|||||||
id="activity-entity-type"
|
id="activity-entity-type"
|
||||||
v-model="entityTypeFilter"
|
v-model="entityTypeFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="">Tous</option>
|
<option value="">Tous</option>
|
||||||
<option value="piece">Pièce</option>
|
<option value="piece">Pièce</option>
|
||||||
@@ -42,7 +58,7 @@
|
|||||||
id="activity-action"
|
id="activity-action"
|
||||||
v-model="actionFilter"
|
v-model="actionFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="">Toutes</option>
|
<option value="">Toutes</option>
|
||||||
<option value="create">Création</option>
|
<option value="create">Création</option>
|
||||||
@@ -50,157 +66,111 @@
|
|||||||
<option value="delete">Suppression</option>
|
<option value="delete">Suppression</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<template #cell-createdAt="{ row }">
|
||||||
<label
|
<span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
</template>
|
||||||
for="activity-per-page"
|
|
||||||
|
<template #cell-action="{ row }">
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="actionBadgeClass(row.action)"
|
||||||
|
>
|
||||||
|
{{ historyActionLabel(row.action) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-entityType="{ row }">
|
||||||
|
<span class="badge badge-ghost badge-sm">
|
||||||
|
{{ entityTypeLabel(row.entityType) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-entity="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.action !== 'delete'"
|
||||||
|
:to="entityEditLink(row)"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ row.entityName || 'Sans nom' }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-base-content/50 line-through">
|
||||||
|
{{ row.entityName || 'Sans nom' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="row.entityRef"
|
||||||
|
class="text-xs text-base-content/50 ml-1"
|
||||||
|
>
|
||||||
|
({{ row.entityRef }})
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-author="{ row }">
|
||||||
|
{{ row.actor?.label || '—' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #row-expanded="{ row }">
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div
|
||||||
|
v-for="diffEntry in historyDiffEntries(row, globalFieldLabels)"
|
||||||
|
:key="diffEntry.field"
|
||||||
|
class="flex gap-2"
|
||||||
>
|
>
|
||||||
Par page
|
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||||
</label>
|
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||||
<select
|
<span>→</span>
|
||||||
id="activity-per-page"
|
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||||
v-model.number="itemsPerPage"
|
</div>
|
||||||
class="select select-bordered select-sm"
|
|
||||||
@change="handleFilterChange"
|
|
||||||
>
|
|
||||||
<option :value="20">20</option>
|
|
||||||
<option :value="50">50</option>
|
|
||||||
<option :value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</DataTable>
|
||||||
<p class="text-xs text-base-content/50 lg:text-right">
|
|
||||||
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center py-8">
|
|
||||||
<span class="loading loading-spinner" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-else-if="!total" class="text-sm text-base-content/70">
|
|
||||||
Aucune activité enregistrée.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
|
|
||||||
Aucune activité ne correspond à vos filtres.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-sm md:table-md">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Entité</th>
|
|
||||||
<th>Auteur</th>
|
|
||||||
<th>Détails</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template v-for="entry in entries" :key="entry.id">
|
|
||||||
<tr>
|
|
||||||
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge badge-sm"
|
|
||||||
:class="actionBadgeClass(entry.action)"
|
|
||||||
>
|
|
||||||
{{ historyActionLabel(entry.action) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-ghost badge-sm">
|
|
||||||
{{ entityTypeLabel(entry.entityType) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="entry.action !== 'delete'"
|
|
||||||
:to="entityEditLink(entry)"
|
|
||||||
class="link link-hover link-primary"
|
|
||||||
>
|
|
||||||
{{ entry.entityName || 'Sans nom' }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-else class="text-base-content/50 line-through">
|
|
||||||
{{ entry.entityName || 'Sans nom' }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="entry.entityRef"
|
|
||||||
class="text-xs text-base-content/50 ml-1"
|
|
||||||
>
|
|
||||||
({{ entry.entityRef }})
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ entry.actor?.label || '—' }}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
v-if="hasDiff(entry)"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
@click="toggleExpanded(entry.id)"
|
|
||||||
>
|
|
||||||
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="text-xs text-base-content/50">—</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr v-if="expandedIds.has(entry.id)">
|
|
||||||
<td colspan="6" class="bg-base-200/50 p-4">
|
|
||||||
<div class="space-y-1 text-sm">
|
|
||||||
<div
|
|
||||||
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
|
|
||||||
:key="diffEntry.field"
|
|
||||||
class="flex gap-2"
|
|
||||||
>
|
|
||||||
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
|
||||||
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
|
||||||
<span>→</span>
|
|
||||||
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
:current-page="currentPage"
|
|
||||||
:total-pages="totalPages"
|
|
||||||
@update:current-page="handlePageChange"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, type Ref } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useActivityLog } from '~/composables/useActivityLog'
|
import { useActivityLog } from '~/composables/useActivityLog'
|
||||||
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import {
|
import {
|
||||||
historyActionLabel,
|
historyActionLabel,
|
||||||
formatHistoryDate,
|
formatHistoryDate,
|
||||||
historyDiffEntries,
|
historyDiffEntries,
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
} from '~/shared/utils/historyDisplayUtils'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
|
||||||
|
|
||||||
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
||||||
|
|
||||||
const currentPage = ref(1)
|
const table = useDataTable(
|
||||||
const itemsPerPage = ref(50)
|
{ fetchData: fetchLog },
|
||||||
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
|
{
|
||||||
|
defaultSort: 'createdAt',
|
||||||
|
defaultDirection: 'desc',
|
||||||
|
defaultPerPage: 50,
|
||||||
|
persistToUrl: true,
|
||||||
|
extraParams: {
|
||||||
|
entityType: { default: '' },
|
||||||
|
action: { default: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const entityTypeFilter = ref('')
|
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||||
const actionFilter = ref('')
|
const actionFilter = table.filters.action as Ref<string>
|
||||||
|
|
||||||
|
const entriesOnPage = computed(() => entries.value.length)
|
||||||
|
const paginationState = table.pagination(total, entriesOnPage)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'createdAt', label: 'Date' },
|
||||||
|
{ key: 'action', label: 'Action' },
|
||||||
|
{ key: 'entityType', label: 'Type' },
|
||||||
|
{ key: 'entity', label: 'Entité' },
|
||||||
|
{ key: 'author', label: 'Auteur' },
|
||||||
|
]
|
||||||
|
|
||||||
const expandedIds = reactive(new Set<string>())
|
const expandedIds = reactive(new Set<string>())
|
||||||
|
|
||||||
@@ -209,28 +179,18 @@ const toggleExpanded = (id: string) => {
|
|||||||
else expandedIds.add(id)
|
else expandedIds.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDiff = (entry: ActivityLogEntry) =>
|
const canExpandRow = (row: any) =>
|
||||||
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
|
row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
|
||||||
|
|
||||||
const fetchLog = () => {
|
function fetchLog() {
|
||||||
loadActivityLog({
|
loadActivityLog({
|
||||||
page: currentPage.value,
|
page: table.currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
entityType: entityTypeFilter.value || undefined,
|
entityType: entityTypeFilter.value || undefined,
|
||||||
action: actionFilter.value || undefined,
|
action: actionFilter.value || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFilterChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchLog()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchLog()
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
piece: 'Pièce',
|
piece: 'Pièce',
|
||||||
product: 'Produit',
|
product: 'Produit',
|
||||||
|
|||||||
@@ -9,82 +9,66 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center py-12">
|
<DataTable
|
||||||
<span class="loading loading-spinner loading-lg" />
|
:columns="columns"
|
||||||
</div>
|
:rows="sortedProfiles"
|
||||||
|
:loading="isLoading"
|
||||||
|
:sort="sortState"
|
||||||
|
:show-counter="false"
|
||||||
|
table-class="table-zebra"
|
||||||
|
empty-message="Aucun profil."
|
||||||
|
@sort="handleSort"
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-medium">{{ row.firstName }} {{ row.lastName }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-else-if="profiles.length" class="overflow-x-auto">
|
<template #cell-email="{ row }">
|
||||||
<table class="table table-zebra w-full">
|
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
|
||||||
<thead>
|
</template>
|
||||||
<tr>
|
|
||||||
<th>Nom</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th>Mot de passe</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="profile in profiles" :key="profile.id">
|
|
||||||
<td class="font-medium">
|
|
||||||
{{ profile.firstName }} {{ profile.lastName }}
|
|
||||||
</td>
|
|
||||||
<td class="text-sm text-base-content/70">
|
|
||||||
{{ profile.email || '-' }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
class="select select-bordered select-xs"
|
|
||||||
:value="primaryRole(profile)"
|
|
||||||
@change="handleRoleChange(profile.id, $event.target.value)"
|
|
||||||
>
|
|
||||||
<option value="ROLE_ADMIN">
|
|
||||||
Admin
|
|
||||||
</option>
|
|
||||||
<option value="ROLE_GESTIONNAIRE">
|
|
||||||
Gestionnaire
|
|
||||||
</option>
|
|
||||||
<option value="ROLE_VIEWER">
|
|
||||||
Viewer
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
|
||||||
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs ml-1"
|
|
||||||
@click="openPasswordDialog(profile.id)"
|
|
||||||
>
|
|
||||||
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge badge-sm"
|
|
||||||
:class="profile.isActive ? 'badge-success' : 'badge-error'"
|
|
||||||
>
|
|
||||||
{{ profile.isActive ? 'Actif' : 'Inactif' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
v-if="profile.isActive"
|
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
@click="handleDeactivate(profile.id)"
|
|
||||||
>
|
|
||||||
Desactiver
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="text-center py-12 text-base-content/60">
|
<template #cell-role="{ row }">
|
||||||
Aucun profil.
|
<select
|
||||||
</div>
|
class="select select-bordered select-xs"
|
||||||
|
:value="primaryRole(row)"
|
||||||
|
@change="handleRoleChange(row.id, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="ROLE_ADMIN">Admin</option>
|
||||||
|
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||||
|
<option value="ROLE_VIEWER">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-password="{ row }">
|
||||||
|
<span v-if="row.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||||
|
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs ml-1"
|
||||||
|
@click="openPasswordDialog(row.id)"
|
||||||
|
>
|
||||||
|
{{ row.hasPassword ? 'Changer' : 'Definir' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="row.isActive ? 'badge-success' : 'badge-error'"
|
||||||
|
>
|
||||||
|
{{ row.isActive ? 'Actif' : 'Inactif' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<button
|
||||||
|
v-if="row.isActive"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="handleDeactivate(row.id)"
|
||||||
|
>
|
||||||
|
Desactiver
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
<!-- Create Profile Dialog -->
|
<!-- Create Profile Dialog -->
|
||||||
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||||
@@ -112,15 +96,9 @@
|
|||||||
<div class="form-control mb-3">
|
<div class="form-control mb-3">
|
||||||
<label class="label"><span class="label-text">Role</span></label>
|
<label class="label"><span class="label-text">Role</span></label>
|
||||||
<select v-model="createForm.role" class="select select-bordered">
|
<select v-model="createForm.role" class="select select-bordered">
|
||||||
<option value="ROLE_ADMIN">
|
<option value="ROLE_ADMIN">Admin</option>
|
||||||
Admin
|
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||||
</option>
|
<option value="ROLE_VIEWER">Viewer</option>
|
||||||
<option value="ROLE_GESTIONNAIRE">
|
|
||||||
Gestionnaire
|
|
||||||
</option>
|
|
||||||
<option value="ROLE_VIEWER">
|
|
||||||
Viewer
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
@@ -173,11 +151,55 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useAdminProfiles } from '#imports'
|
import { useAdminProfiles } from '#imports'
|
||||||
|
|
||||||
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||||
|
|
||||||
|
const loaded = ref(false)
|
||||||
|
const isLoading = computed(() => loading.value || !loaded.value)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'email', label: 'Email', sortable: true },
|
||||||
|
{ key: 'role', label: 'Role', sortable: true },
|
||||||
|
{ key: 'password', label: 'Mot de passe' },
|
||||||
|
{ key: 'status', label: 'Statut', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortState = ref({ field: 'name', direction: 'asc' })
|
||||||
|
|
||||||
|
const handleSort = (sort) => {
|
||||||
|
sortState.value = sort
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedProfiles = computed(() => {
|
||||||
|
const { field, direction } = sortState.value
|
||||||
|
const dir = direction === 'desc' ? -1 : 1
|
||||||
|
return [...profiles.value].sort((a, b) => {
|
||||||
|
let valA, valB
|
||||||
|
if (field === 'name') {
|
||||||
|
valA = `${a.firstName} ${a.lastName}`.toLowerCase()
|
||||||
|
valB = `${b.firstName} ${b.lastName}`.toLowerCase()
|
||||||
|
}
|
||||||
|
else if (field === 'role') {
|
||||||
|
valA = primaryRole(a)
|
||||||
|
valB = primaryRole(b)
|
||||||
|
}
|
||||||
|
else if (field === 'status') {
|
||||||
|
valA = a.isActive ? '1' : '0'
|
||||||
|
valB = b.isActive ? '1' : '0'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
valA = (a[field] || '').toLowerCase()
|
||||||
|
valB = (b[field] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
return dir * valA.localeCompare(valB)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const showCreateDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
const showPasswordDialog = ref(false)
|
const showPasswordDialog = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
@@ -209,7 +231,8 @@ const handleCreate = async () => {
|
|||||||
await createProfile(data)
|
await createProfile(data)
|
||||||
showCreateDialog.value = false
|
showCreateDialog.value = false
|
||||||
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
creating.value = false
|
creating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +253,8 @@ const handleSetPassword = async () => {
|
|||||||
try {
|
try {
|
||||||
await setPassword(passwordProfileId.value, newPassword.value)
|
await setPassword(passwordProfileId.value, newPassword.value)
|
||||||
showPasswordDialog.value = false
|
showPasswordDialog.value = false
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
settingPassword.value = false
|
settingPassword.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +263,8 @@ const handleDeactivate = async (profileId) => {
|
|||||||
await deactivateProfile(profileId)
|
await deactivateProfile(profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
fetchAll()
|
await fetchAll()
|
||||||
|
loaded.value = true
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -69,6 +69,37 @@ const badgeClass = (type: ChangeType) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const releases: Release[] = [
|
const releases: Release[] = [
|
||||||
|
{
|
||||||
|
version: 'v1.8.0',
|
||||||
|
date: '2026-03-03',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
|
||||||
|
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
|
||||||
|
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
|
||||||
|
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
|
||||||
|
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
|
||||||
|
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
|
||||||
|
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
|
||||||
|
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
|
||||||
|
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.7.0',
|
||||||
|
date: '2026-03-02',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
|
||||||
|
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
|
||||||
|
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
|
||||||
|
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
|
||||||
|
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
|
||||||
|
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
|
||||||
|
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
|
||||||
|
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
|
||||||
|
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
|
||||||
|
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: 'v1.6.1',
|
version: 'v1.6.1',
|
||||||
date: '2026-02-12',
|
date: '2026-02-12',
|
||||||
@@ -171,7 +202,7 @@ const releases: Release[] = [
|
|||||||
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
||||||
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
||||||
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
||||||
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
|
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
|
||||||
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
||||||
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
||||||
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
||||||
|
|||||||
@@ -11,9 +11,22 @@
|
|||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<!-- Filtres -->
|
<DataTable
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
:columns="columns"
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
:rows="comments"
|
||||||
|
:loading="loadingList"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucun commentaire trouvé."
|
||||||
|
no-results-message="Aucun commentaire trouvé."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label
|
<label
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
@@ -25,17 +38,11 @@
|
|||||||
id="comment-status"
|
id="comment-status"
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="open">
|
<option value="open">Ouverts</option>
|
||||||
Ouverts
|
<option value="resolved">Résolus</option>
|
||||||
</option>
|
<option value="">Tous</option>
|
||||||
<option value="resolved">
|
|
||||||
Résolus
|
|
||||||
</option>
|
|
||||||
<option value="">
|
|
||||||
Tous
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,186 +57,84 @@
|
|||||||
id="comment-entity-type"
|
id="comment-entity-type"
|
||||||
v-model="entityTypeFilter"
|
v-model="entityTypeFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">Tous</option>
|
||||||
Tous
|
<option value="machine">Machine</option>
|
||||||
</option>
|
<option value="piece">Pièce</option>
|
||||||
<option value="machine">
|
<option value="composant">Composant</option>
|
||||||
Machine
|
<option value="product">Produit</option>
|
||||||
</option>
|
<option value="piece_category">Catégorie pièce</option>
|
||||||
<option value="piece">
|
<option value="component_category">Catégorie composant</option>
|
||||||
Pièce
|
<option value="product_category">Catégorie produit</option>
|
||||||
</option>
|
<option value="machine_skeleton">Squelette machine</option>
|
||||||
<option value="composant">
|
|
||||||
Composant
|
|
||||||
</option>
|
|
||||||
<option value="product">
|
|
||||||
Produit
|
|
||||||
</option>
|
|
||||||
<option value="piece_category">
|
|
||||||
Catégorie pièce
|
|
||||||
</option>
|
|
||||||
<option value="component_category">
|
|
||||||
Catégorie composant
|
|
||||||
</option>
|
|
||||||
<option value="product_category">
|
|
||||||
Catégorie produit
|
|
||||||
</option>
|
|
||||||
<option value="machine_skeleton">
|
|
||||||
Squelette machine
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<template #cell-content="{ row }">
|
||||||
<label
|
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
</template>
|
||||||
for="comment-per-page"
|
|
||||||
>
|
|
||||||
Par page
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="comment-per-page"
|
|
||||||
v-model.number="itemsPerPage"
|
|
||||||
class="select select-bordered select-sm"
|
|
||||||
@change="handleFilterChange"
|
|
||||||
>
|
|
||||||
<option :value="20">
|
|
||||||
20
|
|
||||||
</option>
|
|
||||||
<option :value="50">
|
|
||||||
50
|
|
||||||
</option>
|
|
||||||
<option :value="100">
|
|
||||||
100
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-base-content/50 lg:text-right">
|
<template #cell-entityType="{ row }">
|
||||||
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
<span class="badge badge-outline badge-sm">
|
||||||
</p>
|
{{ entityTypeLabel(row.entityType) }}
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="loadingList" class="flex justify-center py-8">
|
|
||||||
<span class="loading loading-spinner" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty states -->
|
|
||||||
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
|
|
||||||
Aucun commentaire trouvé.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-sm md:table-md">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Contenu</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Item</th>
|
|
||||||
<th>Auteur</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th v-if="canEdit">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="comment in comments"
|
|
||||||
:key="comment.id"
|
|
||||||
class="hover"
|
|
||||||
>
|
|
||||||
<td class="max-w-xs">
|
|
||||||
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-outline badge-sm">
|
|
||||||
{{ entityTypeLabel(comment.entityType) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="getEntityRoute(comment)"
|
|
||||||
:to="getEntityRoute(comment)!"
|
|
||||||
class="link link-primary text-sm font-medium"
|
|
||||||
>
|
|
||||||
{{ comment.entityName || comment.entityId }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-else class="text-sm">
|
|
||||||
{{ comment.entityName || comment.entityId }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-sm">
|
|
||||||
{{ comment.authorName }}
|
|
||||||
</td>
|
|
||||||
<td class="text-sm whitespace-nowrap">
|
|
||||||
{{ formatCommentDate(comment.createdAt) }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge badge-sm"
|
|
||||||
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
|
|
||||||
>
|
|
||||||
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td v-if="canEdit" @click.stop>
|
|
||||||
<button
|
|
||||||
v-if="comment.status === 'open'"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-success btn-xs gap-1"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="handleResolve(comment.id)"
|
|
||||||
>
|
|
||||||
<IconLucideCheck class="w-3 h-3" />
|
|
||||||
Résoudre
|
|
||||||
</button>
|
|
||||||
<span v-else class="text-xs text-base-content/50">
|
|
||||||
{{ comment.resolvedByName }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm"
|
|
||||||
:disabled="page <= 1"
|
|
||||||
@click="goToPage(page - 1)"
|
|
||||||
>
|
|
||||||
Précédent
|
|
||||||
</button>
|
|
||||||
<span class="flex items-center text-sm text-base-content/70">
|
|
||||||
Page {{ page }} / {{ totalPages }}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
</template>
|
||||||
class="btn btn-sm"
|
|
||||||
:disabled="page >= totalPages"
|
<template #cell-entity="{ row }">
|
||||||
@click="goToPage(page + 1)"
|
<NuxtLink
|
||||||
|
v-if="getEntityRoute(row)"
|
||||||
|
:to="getEntityRoute(row)!"
|
||||||
|
class="link link-primary text-sm font-medium"
|
||||||
>
|
>
|
||||||
Suivant
|
{{ row.entityName || row.entityId }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-sm">
|
||||||
|
{{ row.entityName || row.entityId }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatCommentDate(row.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="row.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||||
|
>
|
||||||
|
{{ row.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="canEdit" #cell-actions="{ row }">
|
||||||
|
<button
|
||||||
|
v-if="row.status === 'open'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-xs gap-1"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleResolve(row.id)"
|
||||||
|
>
|
||||||
|
<IconLucideCheck class="w-3 h-3" />
|
||||||
|
Résoudre
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span v-else class="text-xs text-base-content/50">
|
||||||
</template>
|
{{ row.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, type Ref } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useComments, type Comment } from '~/composables/useComments'
|
import { useComments, type Comment } from '~/composables/useComments'
|
||||||
import { usePermissions } from '~/composables/usePermissions'
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
@@ -241,16 +146,43 @@ const {
|
|||||||
|
|
||||||
const comments = ref<Comment[]>([])
|
const comments = ref<Comment[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const loadingList = ref(true)
|
||||||
const itemsPerPage = ref(20)
|
|
||||||
const statusFilter = ref('open')
|
|
||||||
const entityTypeFilter = ref('')
|
|
||||||
const loadingList = ref(false)
|
|
||||||
|
|
||||||
const totalPages = computed(() =>
|
const table = useDataTable(
|
||||||
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
{ fetchData: loadComments },
|
||||||
|
{
|
||||||
|
defaultSort: 'createdAt',
|
||||||
|
defaultDirection: 'desc',
|
||||||
|
defaultPerPage: 20,
|
||||||
|
persistToUrl: true,
|
||||||
|
extraParams: {
|
||||||
|
status: { default: 'open' },
|
||||||
|
entityType: { default: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const statusFilter = table.filters.status as Ref<string>
|
||||||
|
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||||
|
|
||||||
|
const commentsOnPage = computed(() => comments.value.length)
|
||||||
|
const paginationState = table.pagination(total, commentsOnPage)
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
const cols = [
|
||||||
|
{ key: 'content', label: 'Contenu', class: 'max-w-xs' },
|
||||||
|
{ key: 'entityType', label: 'Type' },
|
||||||
|
{ key: 'entity', label: 'Item', filterable: true, filterPlaceholder: 'Rechercher…' },
|
||||||
|
{ key: 'authorName', label: 'Auteur', sortable: true, sortKey: 'authorName' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||||
|
{ key: 'status', label: 'Statut', sortable: true, sortKey: 'status' },
|
||||||
|
]
|
||||||
|
if (canEdit.value) {
|
||||||
|
cols.push({ key: 'actions', label: 'Actions' })
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
})
|
||||||
|
|
||||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
machine: 'Machine',
|
machine: 'Machine',
|
||||||
piece: 'Pièce',
|
piece: 'Pièce',
|
||||||
@@ -277,13 +209,16 @@ const formatCommentDate = (dateStr: string): string => {
|
|||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadComments = async () => {
|
async function loadComments() {
|
||||||
loadingList.value = true
|
loadingList.value = true
|
||||||
const result = await fetchAllComments({
|
const result = await fetchAllComments({
|
||||||
status: statusFilter.value || undefined,
|
status: statusFilter.value || undefined,
|
||||||
entityType: entityTypeFilter.value || undefined,
|
entityType: entityTypeFilter.value || undefined,
|
||||||
page: page.value,
|
entityName: table.columnFilters.value.entity || undefined,
|
||||||
itemsPerPage: itemsPerPage.value,
|
page: table.currentPage.value,
|
||||||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
|
orderBy: table.sortField.value,
|
||||||
|
orderDir: table.sortDirection.value,
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
comments.value = result.data ?? []
|
comments.value = result.data ?? []
|
||||||
@@ -292,16 +227,6 @@ const loadComments = async () => {
|
|||||||
loadingList.value = false
|
loadingList.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFilterChange = () => {
|
|
||||||
page.value = 1
|
|
||||||
loadComments()
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToPage = (p: number) => {
|
|
||||||
page.value = p
|
|
||||||
loadComments()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResolve = async (commentId: string) => {
|
const handleResolve = async (commentId: string) => {
|
||||||
const result = await resolveComment(commentId)
|
const result = await resolveComment(commentId)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -26,164 +26,107 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<DataTable
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
:columns="columns"
|
||||||
|
:rows="componentRows"
|
||||||
|
:loading="loadingComposants"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucun composant n'a encore été créé."
|
||||||
|
no-results-message="Aucun composant ne correspond à votre recherche."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
<label class="w-full sm:w-72">
|
<label class="w-full sm:w-72">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
<input
|
<input
|
||||||
v-model="searchTerm"
|
v-model="table.searchTerm.value"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
placeholder="Nom ou référence…"
|
placeholder="Nom ou référence…"
|
||||||
@input="debouncedSearch"
|
@input="table.debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
</template>
|
||||||
<label
|
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
<template #cell-preview="{ row }">
|
||||||
for="component-catalog-sort"
|
<DocumentThumbnail
|
||||||
>
|
:document="resolvePrimaryDocument(row.component)"
|
||||||
Trier par
|
:alt="resolvePreviewAlt(row.component)"
|
||||||
</label>
|
/>
|
||||||
<select
|
</template>
|
||||||
id="component-catalog-sort"
|
|
||||||
v-model="sortField"
|
<template #cell-name="{ row }">
|
||||||
class="select select-bordered select-sm"
|
{{ row.component.name || 'Composant sans nom' }}
|
||||||
@change="handleSortChange"
|
</template>
|
||||||
>
|
|
||||||
<option value="name">Nom</option>
|
<template #cell-reference="{ row }">
|
||||||
<option value="createdAt">Date de création</option>
|
{{ row.component.reference || '—' }}
|
||||||
</select>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-description="{ row }">
|
||||||
|
<div v-if="row.component.description" class="group relative">
|
||||||
|
<span class="block cursor-help truncate">{{ row.component.description }}</span>
|
||||||
|
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||||
|
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-typeComposant="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.component.typeComposant?.id"
|
||||||
|
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolveComponentType(row.component) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label
|
<NuxtLink
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
:to="`/component/${row.component.id}/edit`"
|
||||||
for="component-catalog-dir"
|
class="btn btn-ghost btn-xs"
|
||||||
>
|
>
|
||||||
Ordre
|
Modifier
|
||||||
</label>
|
</NuxtLink>
|
||||||
<select
|
<button
|
||||||
id="component-catalog-dir"
|
v-if="canEdit"
|
||||||
v-model="sortDirection"
|
type="button"
|
||||||
class="select select-bordered select-sm"
|
class="btn btn-error btn-xs"
|
||||||
@change="handleSortChange"
|
:disabled="loadingComposants"
|
||||||
|
@click="handleDeleteComponent(row.component)"
|
||||||
>
|
>
|
||||||
<option value="asc">Ascendant</option>
|
Supprimer
|
||||||
<option value="desc">Descendant</option>
|
</button>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
</template>
|
||||||
<label
|
</DataTable>
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
|
||||||
for="component-catalog-per-page"
|
|
||||||
>
|
|
||||||
Par page
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="component-catalog-per-page"
|
|
||||||
v-model.number="itemsPerPage"
|
|
||||||
class="select select-bordered select-sm"
|
|
||||||
@change="handlePerPageChange"
|
|
||||||
>
|
|
||||||
<option :value="20">20</option>
|
|
||||||
<option :value="50">50</option>
|
|
||||||
<option :value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-base-content/50 lg:text-right">
|
|
||||||
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loadingComposants" class="flex justify-center py-8">
|
|
||||||
<span class="loading loading-spinner" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-else-if="!composantsTotal" class="text-sm text-base-content/70">
|
|
||||||
Aucun composant n'a encore été créé.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
|
|
||||||
Aucun composant ne correspond à votre recherche.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-sm md:table-md">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-24">Aperçu</th>
|
|
||||||
<th>Nom</th>
|
|
||||||
<th>Référence</th>
|
|
||||||
<th>Type de composant</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="component in composantsList" :key="component.id">
|
|
||||||
<td class="align-middle">
|
|
||||||
<DocumentThumbnail
|
|
||||||
:document="resolvePrimaryDocument(component)"
|
|
||||||
:alt="resolvePreviewAlt(component)"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
|
||||||
<td>{{ component.reference || '—' }}</td>
|
|
||||||
<td>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="component.typeComposant?.id"
|
|
||||||
:to="`/component-category/${component.typeComposant.id}/edit`"
|
|
||||||
class="link link-hover link-primary"
|
|
||||||
>
|
|
||||||
{{ resolveComponentType(component) }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-else>{{ resolveComponentType(component) }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/component/${component.id}/edit`"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</NuxtLink>
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-error btn-xs"
|
|
||||||
:disabled="loadingComposants"
|
|
||||||
@click="handleDeleteComponent(component)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
:current-page="currentPage"
|
|
||||||
:total-pages="totalPages"
|
|
||||||
@update:current-page="handlePageChange"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
@@ -192,175 +135,110 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
|
|||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||||
|
|
||||||
// State synced with URL query params (preserved on back/forward navigation)
|
const table = useDataTable(
|
||||||
const {
|
{ fetchData: fetchComposants },
|
||||||
page: currentPage,
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||||
perPage: itemsPerPage,
|
)
|
||||||
q: searchTerm,
|
|
||||||
sort: sortField,
|
|
||||||
dir: sortDirection,
|
|
||||||
} = useUrlState({
|
|
||||||
page: { default: 1, type: 'number' },
|
|
||||||
perPage: { default: 20, type: 'number' },
|
|
||||||
q: { default: '', debounce: 300 },
|
|
||||||
sort: { default: 'name' },
|
|
||||||
dir: { default: 'asc' },
|
|
||||||
}, {
|
|
||||||
onRestore: () => fetchComposants(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const composantsTotal = computed(() => total.value)
|
const columns = [
|
||||||
const composantsOnPage = computed(() => composants.value.length)
|
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
// Search debounce for API calls
|
const composantsOnPage = computed(() => componentRows.value.length)
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
const paginationState = table.pagination(total, composantsOnPage)
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
// Enrich composants with full type data
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchComposants()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrichir les composants avec les types de composants complets
|
|
||||||
const composantsList = computed(() => {
|
const composantsList = computed(() => {
|
||||||
return (composants.value || []).map((composant) => {
|
return (composants.value || []).map((composant) => {
|
||||||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||||
return {
|
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||||||
...composant,
|
|
||||||
typeComposant: typeComposant || composant.typeComposant || null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchComposants = async () => {
|
const componentRows = computed(() =>
|
||||||
|
composantsList.value.map(component => ({
|
||||||
|
id: component.id,
|
||||||
|
component,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchComposants() {
|
||||||
await loadComposants({
|
await loadComposants({
|
||||||
search: searchTerm.value,
|
search: table.searchTerm.value,
|
||||||
page: currentPage.value,
|
page: table.currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: table.sortField.value,
|
||||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
force: true
|
typeName: table.columnFilters.value.typeComposant || undefined,
|
||||||
|
force: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchComposants()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchComposants()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePerPageChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchComposants()
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||||||
if (!documents.length) {
|
if (!documents.length) return null
|
||||||
return null
|
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||||
}
|
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||||
const withPath = normalized.filter((doc) => doc?.path)
|
if (pdf) return pdf
|
||||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
const image = withPath.find((doc: any) => isImageDocument(doc))
|
||||||
if (pdf) {
|
if (image) return image
|
||||||
return pdf
|
|
||||||
}
|
|
||||||
const image = withPath.find((doc) => isImageDocument(doc))
|
|
||||||
if (image) {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
return withPath[0] ?? normalized[0] ?? null
|
return withPath[0] ?? normalized[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePreviewAlt = (component: Record<string, any>) => {
|
const resolvePreviewAlt = (component: Record<string, any>) => {
|
||||||
const parts = [component?.name, component?.reference].filter(Boolean)
|
const parts = [component?.name, component?.reference].filter(Boolean)
|
||||||
if (parts.length) {
|
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||||
return `Aperçu du document de ${parts.join(' – ')}`
|
|
||||||
}
|
|
||||||
return 'Aperçu du document'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveComponentType = (component: Record<string, any>) => {
|
const resolveComponentType = (component: Record<string, any>) => {
|
||||||
const type = component?.typeComposant
|
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||||
if (type?.name) {
|
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||||
return type.name
|
|
||||||
}
|
|
||||||
if (component?.typeComposantLabel) {
|
|
||||||
return component.typeComposantLabel
|
|
||||||
}
|
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveDeleteGuard = (component: Record<string, any>) => {
|
const resolveDeleteGuard = (component: Record<string, any>) => {
|
||||||
const blockingReasons: string[] = []
|
const blockingReasons: string[] = []
|
||||||
const machineLinks = Array.isArray(component?.machineLinks)
|
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
|
||||||
? component.machineLinks.length
|
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
|
||||||
: component?.machineLinksCount ?? 0
|
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
|
||||||
const documents = Array.isArray(component?.documents)
|
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||||
? component.documents.length
|
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||||
: component?.documentsCount ?? 0
|
return { blockingReasons, hasCustomFields: customFields > 0 }
|
||||||
const customFields = Array.isArray(component?.customFieldValues)
|
|
||||||
? component.customFieldValues.length
|
|
||||||
: component?.customFieldValuesCount ?? 0
|
|
||||||
|
|
||||||
if (machineLinks > 0) {
|
|
||||||
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
|
||||||
}
|
|
||||||
if (documents > 0) {
|
|
||||||
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blockingReasons,
|
|
||||||
hasCustomFields: customFields > 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
||||||
|
|
||||||
if (blockingReasons.length) {
|
if (blockingReasons.length) {
|
||||||
showError(
|
showError(`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
|
||||||
`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(
|
|
||||||
', ',
|
|
||||||
)}. Supprimez ou détachez ces éléments avant de réessayer.`
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentName = component?.name || 'ce composant'
|
const componentName = component?.name || 'ce composant'
|
||||||
const confirmLines = [
|
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`]
|
||||||
`Voulez-vous vraiment supprimer ${componentName} ?`,
|
|
||||||
]
|
|
||||||
if (hasCustomFields) {
|
if (hasCustomFields) {
|
||||||
confirmLines.push(
|
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
|
||||||
'Les valeurs de champs personnalisés associées seront également supprimées.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
||||||
if (!confirmed) {
|
if (!confirmed) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteComposant(component.id)
|
await deleteComposant(component.id)
|
||||||
// Reload current page after deletion
|
|
||||||
fetchComposants()
|
fetchComposants()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
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' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||||
fetchComposants(),
|
|
||||||
loadComponentTypes()
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="componentDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
<main class="container mx-auto px-6 py-10">
|
<main class="container mx-auto px-6 py-10">
|
||||||
@@ -79,6 +80,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Description</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editionForm.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Description du composant (optionnel)"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -373,8 +387,8 @@
|
|||||||
:class="documentThumbnailClass(document)"
|
:class="documentThumbnailClass(document)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
@@ -625,6 +639,7 @@ const historyDiffEntries = (entry: ComponentHistoryEntry) =>
|
|||||||
const selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
|
description: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
@@ -805,6 +820,7 @@ watch(
|
|||||||
selectedTypeId.value = resolvedTypeId
|
selectedTypeId.value = resolvedTypeId
|
||||||
|
|
||||||
editionForm.name = currentComponent.name || ''
|
editionForm.name = currentComponent.name || ''
|
||||||
|
editionForm.description = currentComponent.description || ''
|
||||||
editionForm.reference = currentComponent.reference || ''
|
editionForm.reference = currentComponent.reference || ''
|
||||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||||
currentComponent,
|
currentComponent,
|
||||||
@@ -845,6 +861,7 @@ const submitEdition = async () => {
|
|||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
name: editionForm.name.trim(),
|
name: editionForm.name.trim(),
|
||||||
|
description: editionForm.description.trim() || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = editionForm.reference.trim()
|
const reference = editionForm.reference.trim()
|
||||||
|
|||||||
@@ -52,6 +52,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Description</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="creationForm.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Description du composant (optionnel)"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -357,6 +370,7 @@ import { useProducts } from '~/composables/useProducts'
|
|||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
@@ -408,6 +422,7 @@ const selectedTypeId = ref<string>(initialTypeId.value)
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const creationForm = reactive({
|
const creationForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
|
description: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
@@ -889,6 +904,7 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
|||||||
|
|
||||||
const clearCreationForm = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
|
creationForm.description = ''
|
||||||
creationForm.reference = ''
|
creationForm.reference = ''
|
||||||
creationForm.constructeurIds = []
|
creationForm.constructeurIds = []
|
||||||
creationForm.prix = ''
|
creationForm.prix = ''
|
||||||
@@ -906,6 +922,11 @@ const submitCreation = async () => {
|
|||||||
typeComposantId: selectedType.value.id,
|
typeComposantId: selectedType.value.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const description = creationForm.description.trim()
|
||||||
|
if (description) {
|
||||||
|
payload.description = description
|
||||||
|
}
|
||||||
|
|
||||||
const reference = creationForm.reference.trim()
|
const reference = creationForm.reference.trim()
|
||||||
if (reference) {
|
if (reference) {
|
||||||
payload.reference = reference
|
payload.reference = reference
|
||||||
@@ -978,7 +999,7 @@ const submitCreation = async () => {
|
|||||||
toast.showError(result.error)
|
toast.showError(result.error)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(error?.message || 'Erreur lors de la création du composant')
|
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
uploadingDocuments.value = false
|
uploadingDocuments.value = false
|
||||||
|
|||||||
@@ -17,73 +17,48 @@
|
|||||||
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
<DataTable
|
||||||
<div class="flex-1">
|
:columns="columns"
|
||||||
<label class="label"><span class="label-text">Recherche</span></label>
|
:rows="filteredConstructeurs"
|
||||||
<input
|
:loading="loading"
|
||||||
v-model="searchTerm"
|
:sort="currentSort"
|
||||||
type="search"
|
:show-counter="false"
|
||||||
class="input input-bordered w-full"
|
empty-message="Aucun fournisseur trouvé."
|
||||||
placeholder="Nom, email ou téléphone"
|
no-results-message="Aucun fournisseur trouvé."
|
||||||
@input="debouncedSearch"
|
@sort="handleSort"
|
||||||
>
|
>
|
||||||
</div>
|
<template #toolbar>
|
||||||
<div class="md:w-1/3">
|
<label class="w-full sm:w-72">
|
||||||
<label class="label"><span class="label-text">Tri</span></label>
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
<select v-model="sortKey" class="select select-bordered w-full">
|
<input
|
||||||
<option value="name">
|
v-model="searchTerm"
|
||||||
Nom
|
type="search"
|
||||||
</option>
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
<option value="email">
|
placeholder="Nom, email ou téléphone"
|
||||||
Email
|
@input="debouncedSearch"
|
||||||
</option>
|
/>
|
||||||
<option value="phone">
|
</label>
|
||||||
Téléphone
|
</template>
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="py-16 text-center text-sm text-gray-500">
|
<template #cell-phone="{ row }">
|
||||||
<span class="loading loading-spinner loading-lg mb-2" />
|
{{ formatPhoneDisplay(row.phone) }}
|
||||||
Chargement des fournisseurs...
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500">
|
<template #cell-createdAt="{ row }">
|
||||||
Aucun fournisseur trouvé.
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<template #cell-actions="{ row }">
|
||||||
<table class="table">
|
<div class="flex justify-end gap-2">
|
||||||
<thead>
|
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
|
||||||
<tr class="text-xs uppercase">
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||||
<th>Nom</th>
|
</button>
|
||||||
<th>Email</th>
|
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)">
|
||||||
<th>Téléphone</th>
|
Supprimer
|
||||||
<th class="text-right">
|
</button>
|
||||||
Actions
|
</div>
|
||||||
</th>
|
</template>
|
||||||
</tr>
|
</DataTable>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
|
|
||||||
<td>{{ constructeur.name }}</td>
|
|
||||||
<td>{{ constructeur.email || '—' }}</td>
|
|
||||||
<td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
|
||||||
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
|
||||||
</button>
|
|
||||||
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,6 +93,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
@@ -130,22 +106,46 @@ const { canEdit } = usePermissions()
|
|||||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'email', label: 'Email', sortable: true },
|
||||||
|
{ key: 'phone', label: 'Téléphone', sortable: true },
|
||||||
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||||
|
const sortDir = ref('asc')
|
||||||
|
|
||||||
|
const currentSort = computed(() => ({
|
||||||
|
field: sortKey.value,
|
||||||
|
direction: sortDir.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSort = (sort) => {
|
||||||
|
sortKey.value = sort.field
|
||||||
|
sortDir.value = sort.direction
|
||||||
|
}
|
||||||
|
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editingConstructeur = ref(null)
|
const editingConstructeur = ref(null)
|
||||||
const form = ref({ name: '', email: '', phone: '' })
|
const form = ref({ name: '', email: '', phone: '' })
|
||||||
|
|
||||||
const filteredConstructeurs = computed(() => {
|
const filteredConstructeurs = computed(() => {
|
||||||
|
const key = sortKey.value
|
||||||
|
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||||
const sorted = [...constructeurs.value].sort((a, b) => {
|
const sorted = [...constructeurs.value].sort((a, b) => {
|
||||||
const key = sortKey.value
|
if (key === 'createdAt') {
|
||||||
return (a[key] || '').localeCompare(b[key] || '')
|
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
||||||
|
}
|
||||||
|
return dir * (a[key] || '').localeCompare(b[key] || '')
|
||||||
})
|
})
|
||||||
if (!searchTerm.value) { return sorted }
|
if (!searchTerm.value) { return sorted }
|
||||||
const term = searchTerm.value.toLowerCase()
|
const term = searchTerm.value.toLowerCase()
|
||||||
return sorted.filter(item =>
|
return sorted.filter(item =>
|
||||||
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term))
|
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -153,6 +153,17 @@ const debouncedSearch = debounce(async () => {
|
|||||||
await searchConstructeurs(searchTerm.value)
|
await searchConstructeurs(searchTerm.value)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
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',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
const formatPhoneDisplay = (value) => {
|
const formatPhoneDisplay = (value) => {
|
||||||
const formatted = formatPhone(value)
|
const formatted = formatPhone(value)
|
||||||
if (formatted) {
|
if (formatted) {
|
||||||
@@ -161,7 +172,7 @@ const formatPhoneDisplay = (value) => {
|
|||||||
return value || '—'
|
return value || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
function debounce (fn, delay) {
|
function debounce(fn, delay) {
|
||||||
let timeout
|
let timeout
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
@@ -184,7 +195,7 @@ const openEditModal = (constructeur) => {
|
|||||||
form.value = {
|
form.value = {
|
||||||
name: constructeur.name,
|
name: constructeur.name,
|
||||||
email: constructeur.email || '',
|
email: constructeur.email || '',
|
||||||
phone: constructeur.phone || ''
|
phone: constructeur.phone || '',
|
||||||
}
|
}
|
||||||
modalOpen.value = true
|
modalOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -197,7 +208,7 @@ const closeModal = () => {
|
|||||||
const saveConstructeur = async () => {
|
const saveConstructeur = async () => {
|
||||||
const trimmedName = form.value.name.trim()
|
const trimmedName = form.value.name.trim()
|
||||||
const duplicate = constructeurs.value.find(
|
const duplicate = constructeurs.value.find(
|
||||||
(c) => c.name.toLowerCase() === trimmedName.toLowerCase()
|
c => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||||
&& c.id !== editingConstructeur.value?.id,
|
&& c.id !== editingConstructeur.value?.id,
|
||||||
)
|
)
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
@@ -212,7 +223,8 @@ const saveConstructeur = async () => {
|
|||||||
let result
|
let result
|
||||||
if (editingConstructeur.value) {
|
if (editingConstructeur.value) {
|
||||||
result = await updateConstructeur(editingConstructeur.value.id, payload)
|
result = await updateConstructeur(editingConstructeur.value.id, payload)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
result = await createConstructeur(payload)
|
result = await createConstructeur(payload)
|
||||||
}
|
}
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -234,6 +246,3 @@ const confirmDelete = async (constructeur) => {
|
|||||||
|
|
||||||
onMounted(() => loadConstructeurs())
|
onMounted(() => loadConstructeurs())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,230 +3,193 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="documentRows"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="card bg-base-100 shadow-lg">
|
<section class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<DataTable
|
||||||
<div class="w-full md:w-2/3">
|
:columns="columns"
|
||||||
<label class="label">
|
:rows="documentRows"
|
||||||
<span class="label-text">Recherche</span>
|
:loading="loading"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucun document n'a encore été ajouté."
|
||||||
|
no-results-message="Aucun document ne correspond à votre recherche."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="table.searchTerm.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom du document..."
|
||||||
|
@input="table.debouncedSearch"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
v-model="searchTerm"
|
|
||||||
type="search"
|
|
||||||
placeholder="Nom du document, type, site, machine..."
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full md:w-1/3">
|
<div class="flex items-center gap-2">
|
||||||
<label class="label">
|
<label
|
||||||
<span class="label-text">Filtrer par rattachement</span>
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
</label>
|
for="doc-filter"
|
||||||
<select v-model="attachmentFilter" class="select select-bordered w-full">
|
>
|
||||||
<option value="all">
|
Rattachement
|
||||||
Tous
|
</label>
|
||||||
</option>
|
<select
|
||||||
<option value="site">
|
id="doc-filter"
|
||||||
Sites
|
v-model="attachmentFilter"
|
||||||
</option>
|
class="select select-bordered select-sm"
|
||||||
<option value="machine">
|
@change="table.handleFilterChange"
|
||||||
Machines
|
>
|
||||||
</option>
|
<option value="all">Tous</option>
|
||||||
<option value="composant">
|
<option value="site">Sites</option>
|
||||||
Composants
|
<option value="machine">Machines</option>
|
||||||
</option>
|
<option value="composant">Composants</option>
|
||||||
<option value="piece">
|
<option value="piece">Pièces</option>
|
||||||
Pièces
|
<option value="product">Produits</option>
|
||||||
</option>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider my-0" />
|
<template #cell-name="{ row }">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xl" :class="documentIcon(row).colorClass">
|
||||||
|
<component
|
||||||
|
:is="documentIcon(row).component"
|
||||||
|
class="h-6 w-6"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">{{ row.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ row.filename }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="loading" class="flex flex-col items-center justify-center py-16 text-sm text-gray-500">
|
<template #cell-mimeType="{ row }">
|
||||||
<span class="loading loading-spinner loading-lg mb-3" />
|
{{ row.mimeType || 'Inconnu' }}
|
||||||
Chargement des documents...
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="filteredDocuments.length === 0" class="text-center py-16 text-sm text-gray-500">
|
<template #cell-size="{ row }">
|
||||||
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
|
{{ formatSize(row.size) }}
|
||||||
Aucun document ne correspond à votre recherche pour l'instant.
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<template #cell-attachment="{ row }">
|
||||||
<table class="table">
|
<div class="flex flex-col text-xs">
|
||||||
<thead>
|
<span v-if="row.site">Site · {{ row.site.name }}</span>
|
||||||
<tr class="text-xs uppercase">
|
<span v-else-if="row.machine">Machine · {{ row.machine.name }}</span>
|
||||||
<th>Nom</th>
|
<span v-else-if="row.composant">Composant · {{ row.composant.name }}</span>
|
||||||
<th>Type</th>
|
<span v-else-if="row.piece">Pièce · {{ row.piece.name }}</span>
|
||||||
<th>Taille</th>
|
<span v-else-if="row.product">Produit · {{ row.product.name }}</span>
|
||||||
<th>Rattaché à</th>
|
<span v-else class="text-gray-400">Non défini</span>
|
||||||
<th>Date</th>
|
</div>
|
||||||
<th class="text-right">
|
</template>
|
||||||
Actions
|
|
||||||
</th>
|
<template #cell-createdAt="{ row }">
|
||||||
</tr>
|
{{ formatFrenchDate(row.createdAt) }}
|
||||||
</thead>
|
</template>
|
||||||
<tbody>
|
|
||||||
<tr v-for="document in filteredDocuments" :key="document.id" class="text-sm">
|
<template #cell-actions="{ row }">
|
||||||
<td>
|
<div class="flex justify-end gap-2">
|
||||||
<div class="flex items-center gap-3">
|
<button
|
||||||
<span class="text-xl" :class="documentIcon(document).colorClass">
|
class="btn btn-ghost btn-xs"
|
||||||
<component
|
type="button"
|
||||||
:is="documentIcon(document).component"
|
:disabled="!canPreviewDocument(row)"
|
||||||
class="h-6 w-6"
|
:title="canPreviewDocument(row) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
|
||||||
aria-hidden="true"
|
@click="openPreview(row)"
|
||||||
/>
|
>
|
||||||
</span>
|
Consulter
|
||||||
<div>
|
</button>
|
||||||
<div class="font-semibold">
|
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(row)">
|
||||||
{{ document.name }}
|
Télécharger
|
||||||
</div>
|
</button>
|
||||||
<div class="text-xs text-gray-500">
|
</div>
|
||||||
{{ document.filename }}
|
</template>
|
||||||
</div>
|
</DataTable>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ document.mimeType || 'Inconnu' }}</td>
|
|
||||||
<td>{{ formatSize(document.size) }}</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex flex-col text-xs">
|
|
||||||
<span v-if="document.site">Site · {{ document.site.name }}</span>
|
|
||||||
<span v-else-if="document.machine">Machine · {{ document.machine.name }}</span>
|
|
||||||
<span v-else-if="document.composant">Composant · {{ document.composant.name }}</span>
|
|
||||||
<span v-else-if="document.piece">Pièce · {{ document.piece.name }}</span>
|
|
||||||
<span v-else class="text-gray-400">Non défini</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ formatFrenchDate(document.createdAt) }}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
type="button"
|
|
||||||
:disabled="!canPreviewDocument(document)"
|
|
||||||
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
|
||||||
@click="openPreview(document)"
|
|
||||||
>
|
|
||||||
Consulter
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(document)">
|
|
||||||
Télécharger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, onMounted, ref, type Ref } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
|
||||||
|
|
||||||
const { documents, loading, loadDocuments } = useDocuments()
|
const { documents, total, loading, loadDocuments } = useDocuments()
|
||||||
const { get } = useApi()
|
|
||||||
|
|
||||||
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
const table = useDataTable(
|
||||||
q: { default: '', debounce: 300 },
|
{ fetchData: fetchDocuments },
|
||||||
filter: { default: 'all' },
|
{
|
||||||
})
|
defaultSort: 'createdAt',
|
||||||
const previewDocument = ref(null)
|
defaultDirection: 'desc',
|
||||||
|
defaultPerPage: 30,
|
||||||
|
persistToUrl: true,
|
||||||
|
extraParams: {
|
||||||
|
filter: { default: 'all' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const attachmentFilter = table.filters.filter as Ref<string>
|
||||||
|
|
||||||
|
const previewDocument = ref<any>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
const documentRows = computed(() => documents.value)
|
||||||
loadDocuments({ itemsPerPage: 200 })
|
const documentsOnPage = computed(() => documents.value.length)
|
||||||
})
|
const paginationState = table.pagination(total, documentsOnPage)
|
||||||
|
|
||||||
const filteredDocuments = computed(() => {
|
const columns = [
|
||||||
const term = searchTerm.value.trim().toLowerCase()
|
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
|
||||||
const filter = attachmentFilter.value
|
{ key: 'mimeType', label: 'Type' },
|
||||||
|
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
|
||||||
|
{ key: 'attachment', label: 'Rattaché à' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' as const },
|
||||||
|
]
|
||||||
|
|
||||||
return documents.value.filter((document) => {
|
async function fetchDocuments() {
|
||||||
const matchesFilter =
|
await loadDocuments({
|
||||||
filter === 'all' ||
|
search: table.searchTerm.value,
|
||||||
(filter === 'site' && document.site) ||
|
page: table.currentPage.value,
|
||||||
(filter === 'machine' && document.machine) ||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
(filter === 'composant' && document.composant) ||
|
orderBy: table.sortField.value,
|
||||||
(filter === 'piece' && document.piece)
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
|
attachmentFilter: attachmentFilter.value,
|
||||||
if (!matchesFilter) { return false }
|
force: true,
|
||||||
|
|
||||||
if (!term) { return true }
|
|
||||||
|
|
||||||
const searchable = [
|
|
||||||
document.name,
|
|
||||||
document.filename,
|
|
||||||
document.mimeType,
|
|
||||||
document.site?.name,
|
|
||||||
document.machine?.name,
|
|
||||||
document.composant?.name,
|
|
||||||
document.piece?.name
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(value => value.toLowerCase())
|
|
||||||
|
|
||||||
return searchable.some(value => value.includes(term))
|
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
const formatSize = (size) => {
|
const formatSize = (size: number | undefined | null) => {
|
||||||
if (size === undefined || size === null) { return '—' }
|
if (size === undefined || size === null) return '\u2014'
|
||||||
if (size === 0) { return '0 B' }
|
if (size === 0) return '0 B'
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||||
const formatted = size / Math.pow(1024, index)
|
const formatted = size / Math.pow(1024, index)
|
||||||
return `${formatted.toFixed(1)} ${units[index]}`
|
return `${formatted.toFixed(1)} ${units[index]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||||
|
|
||||||
/** Fetch the full document (with path) from the API on demand. */
|
const downloadDocument = (doc: any) => {
|
||||||
const fetchDocumentPath = async (doc) => {
|
if (doc?.downloadUrl) window.open(doc.downloadUrl, '_blank')
|
||||||
if (doc?.path) { return doc.path }
|
|
||||||
if (!doc?.id) { return null }
|
|
||||||
const result = await get(`/documents/${doc.id}`)
|
|
||||||
if (result.success && result.data?.path) {
|
|
||||||
doc.path = result.data.path
|
|
||||||
return result.data.path
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadDocument = async (doc) => {
|
const openPreview = (doc: any) => {
|
||||||
const path = await fetchDocumentPath(doc)
|
if (!canPreviewDocument(doc)) return
|
||||||
if (!path) { return }
|
|
||||||
|
|
||||||
if (path.startsWith('data:')) {
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = path
|
|
||||||
link.download = doc.filename || doc.name || 'document'
|
|
||||||
link.click()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(path, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPreview = async (doc) => {
|
|
||||||
if (!canPreviewDocument(doc)) { return }
|
|
||||||
await fetchDocumentPath(doc)
|
|
||||||
previewDocument.value = doc
|
previewDocument.value = doc
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -235,4 +198,8 @@ const closePreview = () => {
|
|||||||
previewVisible.value = false
|
previewVisible.value = false
|
||||||
previewDocument.value = null
|
previewDocument.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDocuments()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ import { useSites } from '~/composables/useSites'
|
|||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
import IconLucideFactory from '~icons/lucide/factory'
|
||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||||
import IconLucideUser from '~icons/lucide/user'
|
import IconLucideUser from '~icons/lucide/user'
|
||||||
@@ -731,10 +732,10 @@ const confirmDeleteMachine = async (machine) => {
|
|||||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||||
await loadMachines()
|
await loadMachines()
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Impossible de supprimer la machine : ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
|
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from "~/composables/useToast";
|
||||||
|
import { humanizeError } from "~/shared/utils/errorMessages";
|
||||||
import IconLucidePlus from "~icons/lucide/plus";
|
import IconLucidePlus from "~icons/lucide/plus";
|
||||||
import IconLucidePackage from "~icons/lucide/package";
|
import IconLucidePackage from "~icons/lucide/package";
|
||||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||||
@@ -148,10 +149,10 @@ const confirmDeleteType = async (type) => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Type "${type.name}" supprimé avec succès`);
|
showSuccess(`Type "${type.name}" supprimé avec succès`);
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`);
|
showError(`Impossible de supprimer le type : ${result.error}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`);
|
showError(`Impossible de supprimer le type : ${humanizeError(error.message)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="d.previewDocument.value"
|
:document="d.previewDocument.value"
|
||||||
:visible="d.previewVisible.value"
|
:visible="d.previewVisible.value"
|
||||||
|
:documents="d.machineDocumentsList.value"
|
||||||
@close="d.closePreview"
|
@close="d.closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ import { useMachines } from '~/composables/useMachines'
|
|||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
import IconLucideFactory from '~icons/lucide/factory'
|
||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||||
@@ -214,10 +215,10 @@ const confirmDeleteMachine = async (machine) => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Impossible de supprimer la machine : ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
@@ -25,187 +26,130 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<DataTable
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
:columns="columns"
|
||||||
|
:rows="pieceRows"
|
||||||
|
:loading="loadingPieces"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucune pièce n'a encore été créée."
|
||||||
|
no-results-message="Aucune pièce ne correspond à votre recherche."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
<label class="w-full sm:w-72">
|
<label class="w-full sm:w-72">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
<input
|
<input
|
||||||
v-model="searchTerm"
|
v-model="table.searchTerm.value"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
placeholder="Nom ou référence…"
|
placeholder="Nom ou référence…"
|
||||||
@input="debouncedSearch"
|
@input="table.debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
</template>
|
||||||
<label
|
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
<template #cell-preview="{ row }">
|
||||||
for="piece-catalog-sort"
|
<DocumentThumbnail
|
||||||
>
|
:document="resolvePrimaryDocument(row.piece)"
|
||||||
Trier par
|
:alt="resolvePreviewAlt(row.piece)"
|
||||||
</label>
|
/>
|
||||||
<select
|
</template>
|
||||||
id="piece-catalog-sort"
|
|
||||||
v-model="sortField"
|
<template #cell-name="{ row }">
|
||||||
class="select select-bordered select-sm"
|
{{ row.piece.name || 'Pièce sans nom' }}
|
||||||
@change="handleSortChange"
|
</template>
|
||||||
>
|
|
||||||
<option value="name">Nom</option>
|
<template #cell-reference="{ row }">
|
||||||
<option value="createdAt">Date de création</option>
|
{{ row.piece.reference || '—' }}
|
||||||
</select>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-description="{ row }">
|
||||||
|
<div v-if="row.piece.description" class="group relative">
|
||||||
|
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
||||||
|
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||||
|
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<span v-else>—</span>
|
||||||
<label
|
</template>
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
|
||||||
for="piece-catalog-dir"
|
<template #cell-suppliers="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="row.suppliers.visible.length"
|
||||||
|
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||||||
|
:title="row.suppliers.tooltip"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="supplier in row.suppliers.visible"
|
||||||
|
:key="supplier"
|
||||||
|
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Ordre
|
{{ supplier }}
|
||||||
</label>
|
</span>
|
||||||
<select
|
<span
|
||||||
id="piece-catalog-dir"
|
v-if="row.suppliers.overflow"
|
||||||
v-model="sortDirection"
|
class="badge badge-outline badge-sm"
|
||||||
class="select select-bordered select-sm"
|
|
||||||
@change="handleSortChange"
|
|
||||||
>
|
>
|
||||||
<option value="asc">Ascendant</option>
|
+{{ row.suppliers.overflow }}
|
||||||
<option value="desc">Descendant</option>
|
</span>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-typePiece="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.piece.typePiece?.id"
|
||||||
|
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolvePieceType(row.piece) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label
|
<NuxtLink
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
:to="`/pieces/${row.piece.id}/edit`"
|
||||||
for="piece-catalog-per-page"
|
class="btn btn-ghost btn-xs"
|
||||||
>
|
>
|
||||||
Par page
|
Modifier
|
||||||
</label>
|
</NuxtLink>
|
||||||
<select
|
<button
|
||||||
id="piece-catalog-per-page"
|
v-if="canEdit"
|
||||||
v-model.number="itemsPerPage"
|
type="button"
|
||||||
class="select select-bordered select-sm"
|
class="btn btn-error btn-xs"
|
||||||
@change="handlePerPageChange"
|
:disabled="loadingPieces"
|
||||||
|
@click="handleDeletePiece(row.piece)"
|
||||||
>
|
>
|
||||||
<option :value="20">20</option>
|
Supprimer
|
||||||
<option :value="50">50</option>
|
</button>
|
||||||
<option :value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<p class="text-xs text-base-content/50 lg:text-right">
|
</DataTable>
|
||||||
{{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loadingPieces" class="flex justify-center py-8">
|
|
||||||
<span class="loading loading-spinner" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-else-if="!piecesTotal" class="text-sm text-base-content/70">
|
|
||||||
Aucune pièce n'a encore été créée.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
|
|
||||||
Aucune pièce ne correspond à votre recherche.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-sm md:table-md">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-24">Aperçu</th>
|
|
||||||
<th>Nom</th>
|
|
||||||
<th>Référence</th>
|
|
||||||
<th>Fournisseurs</th>
|
|
||||||
<th>Type de pièce</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in pieceRows" :key="row.piece.id">
|
|
||||||
<td class="align-middle">
|
|
||||||
<DocumentThumbnail
|
|
||||||
:document="resolvePrimaryDocument(row.piece)"
|
|
||||||
:alt="resolvePreviewAlt(row.piece)"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
|
|
||||||
<td>{{ row.piece.reference || '—' }}</td>
|
|
||||||
<td>
|
|
||||||
<div
|
|
||||||
v-if="row.suppliers.visible.length"
|
|
||||||
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
|
||||||
:title="row.suppliers.tooltip"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="supplier in row.suppliers.visible"
|
|
||||||
:key="supplier"
|
|
||||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ supplier }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="row.suppliers.overflow"
|
|
||||||
class="badge badge-outline badge-sm"
|
|
||||||
>
|
|
||||||
+{{ row.suppliers.overflow }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="row.piece.typePiece?.id"
|
|
||||||
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
|
||||||
class="link link-hover link-primary"
|
|
||||||
>
|
|
||||||
{{ resolvePieceType(row.piece) }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/pieces/${row.piece.id}/edit`"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</NuxtLink>
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-error btn-xs"
|
|
||||||
:disabled="loadingPieces"
|
|
||||||
@click="handleDeletePiece(row.piece)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
:current-page="currentPage"
|
|
||||||
:total-pages="totalPages"
|
|
||||||
@update:current-page="handlePageChange"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
@@ -214,114 +158,73 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
|
|||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||||
|
|
||||||
// State synced with URL query params (preserved on back/forward navigation)
|
const table = useDataTable(
|
||||||
const {
|
{ fetchData: fetchPieces },
|
||||||
page: currentPage,
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||||
perPage: itemsPerPage,
|
)
|
||||||
q: searchTerm,
|
|
||||||
sort: sortField,
|
|
||||||
dir: sortDirection,
|
|
||||||
} = useUrlState({
|
|
||||||
page: { default: 1, type: 'number' },
|
|
||||||
perPage: { default: 20, type: 'number' },
|
|
||||||
q: { default: '', debounce: 300 },
|
|
||||||
sort: { default: 'name' },
|
|
||||||
dir: { default: 'asc' },
|
|
||||||
}, {
|
|
||||||
onRestore: () => fetchPieces(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const piecesTotal = computed(() => total.value)
|
const columns = [
|
||||||
const piecesOnPage = computed(() => pieces.value.length)
|
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||||
|
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
// Search debounce for API calls
|
const piecesOnPage = computed(() => pieceRows.value.length)
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
const paginationState = table.pagination(total, piecesOnPage)
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
// Enrich pieces with full type data
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchPieces()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrichir les pièces avec les types de pièces complets
|
|
||||||
const piecesList = computed(() => {
|
const piecesList = computed(() => {
|
||||||
return (pieces.value || []).map((piece) => {
|
return (pieces.value || []).map((piece) => {
|
||||||
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
|
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
|
||||||
return {
|
return { ...piece, typePiece: typePiece || piece.typePiece || null }
|
||||||
...piece,
|
|
||||||
typePiece: typePiece || piece.typePiece || null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchPieces = async () => {
|
const pieceRows = computed(() =>
|
||||||
|
piecesList.value.map(piece => ({
|
||||||
|
id: piece.id,
|
||||||
|
piece,
|
||||||
|
suppliers: buildPieceSuppliersDisplay(piece),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchPieces() {
|
||||||
await loadPieces({
|
await loadPieces({
|
||||||
search: searchTerm.value,
|
search: table.searchTerm.value,
|
||||||
page: currentPage.value,
|
page: table.currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: table.sortField.value,
|
||||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
force: true
|
typeName: table.columnFilters.value.typePiece || undefined,
|
||||||
|
force: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchPieces()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchPieces()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePerPageChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchPieces()
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||||||
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
||||||
if (!documents.length) {
|
if (!documents.length) return null
|
||||||
return null
|
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||||
}
|
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||||
const withPath = normalized.filter((doc) => doc?.path)
|
if (pdf) return pdf
|
||||||
|
const image = withPath.find((doc: any) => isImageDocument(doc))
|
||||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
if (image) return image
|
||||||
if (pdf) {
|
|
||||||
return pdf
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = withPath.find((doc) => isImageDocument(doc))
|
|
||||||
if (image) {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
return withPath[0] ?? normalized[0] ?? null
|
return withPath[0] ?? normalized[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePreviewAlt = (piece: Record<string, any>) => {
|
const resolvePreviewAlt = (piece: Record<string, any>) => {
|
||||||
const parts = [piece?.name, piece?.reference].filter(Boolean)
|
const parts = [piece?.name, piece?.reference].filter(Boolean)
|
||||||
if (parts.length) {
|
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||||
return `Aperçu du document de ${parts.join(' – ')}`
|
|
||||||
}
|
|
||||||
return 'Aperçu du document'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePieceType = (piece: Record<string, any>) => {
|
const resolvePieceType = (piece: Record<string, any>) => {
|
||||||
const type = piece?.typePiece
|
if (piece?.typePiece?.name) return piece.typePiece.name
|
||||||
if (type?.name) {
|
if (piece?.typePieceLabel) return piece.typePieceLabel
|
||||||
return type.name
|
|
||||||
}
|
|
||||||
if (piece?.typePieceLabel) {
|
|
||||||
return piece.typePieceLabel
|
|
||||||
}
|
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,61 +235,36 @@ const resolvePieceSuppliers = (piece: Record<string, any>) => {
|
|||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|
||||||
const pushName = (maybeName: unknown) => {
|
const pushName = (maybeName: unknown) => {
|
||||||
if (typeof maybeName !== 'string') {
|
if (typeof maybeName !== 'string') return
|
||||||
return
|
|
||||||
}
|
|
||||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||||
if (!normalized.length) {
|
if (!normalized.length) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const key = normalized.toLowerCase()
|
const key = normalized.toLowerCase()
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
names.push(normalized)
|
names.push(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectConstructeurs = (value: unknown): void => {
|
const collectConstructeurs = (value: unknown): void => {
|
||||||
if (!value) {
|
if (!value) return
|
||||||
return
|
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||||
}
|
if (typeof value === 'string') { pushName(value); return }
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(collectConstructeurs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
pushName(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
const record = value as Record<string, any>
|
const record = value as Record<string, any>
|
||||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||||
if (record?.constructeur) {
|
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||||
collectConstructeurs(record.constructeur)
|
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||||
}
|
|
||||||
if (Array.isArray(record?.constructeurs)) {
|
|
||||||
collectConstructeurs(record.constructeurs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectFromLabel = (value: unknown): void => {
|
const collectFromLabel = (value: unknown): void => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') return
|
||||||
return
|
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||||
}
|
|
||||||
value
|
|
||||||
.split(/[,;\\/•·|]+/)
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.forEach(pushName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collectConstructeurs(piece?.constructeurs)
|
collectConstructeurs(piece?.constructeurs)
|
||||||
collectConstructeurs(piece?.constructeur)
|
collectConstructeurs(piece?.constructeur)
|
||||||
collectConstructeurs(piece?.product?.constructeurs)
|
collectConstructeurs(piece?.product?.constructeurs)
|
||||||
collectConstructeurs(piece?.product?.constructeur)
|
collectConstructeurs(piece?.product?.constructeur)
|
||||||
|
|
||||||
collectFromLabel(piece?.constructeursLabel)
|
collectFromLabel(piece?.constructeursLabel)
|
||||||
collectFromLabel(piece?.supplierLabel)
|
collectFromLabel(piece?.supplierLabel)
|
||||||
collectFromLabel(piece?.product?.constructeursLabel)
|
collectFromLabel(piece?.product?.constructeursLabel)
|
||||||
@@ -399,83 +277,45 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
|
|||||||
const suppliers = resolvePieceSuppliers(piece)
|
const suppliers = resolvePieceSuppliers(piece)
|
||||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||||
return {
|
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||||
suppliers,
|
|
||||||
visible,
|
|
||||||
overflow,
|
|
||||||
tooltip: suppliers.length ? suppliers.join(', ') : '',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveDeleteGuard = (piece: Record<string, any>) => {
|
const resolveDeleteGuard = (piece: Record<string, any>) => {
|
||||||
const blockingReasons: string[] = []
|
const blockingReasons: string[] = []
|
||||||
const machineLinks = Array.isArray(piece?.machineLinks)
|
const machineLinks = Array.isArray(piece?.machineLinks) ? piece.machineLinks.length : piece?.machineLinksCount ?? 0
|
||||||
? piece.machineLinks.length
|
const documents = Array.isArray(piece?.documents) ? piece.documents.length : piece?.documentsCount ?? 0
|
||||||
: piece?.machineLinksCount ?? 0
|
const customFields = Array.isArray(piece?.customFieldValues) ? piece.customFieldValues.length : piece?.customFieldValuesCount ?? 0
|
||||||
const documents = Array.isArray(piece?.documents)
|
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||||
? piece.documents.length
|
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||||
: piece?.documentsCount ?? 0
|
return { blockingReasons, hasCustomFields: customFields > 0 }
|
||||||
const customFields = Array.isArray(piece?.customFieldValues)
|
|
||||||
? piece.customFieldValues.length
|
|
||||||
: piece?.customFieldValuesCount ?? 0
|
|
||||||
|
|
||||||
if (machineLinks > 0) {
|
|
||||||
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
|
||||||
}
|
|
||||||
if (documents > 0) {
|
|
||||||
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blockingReasons,
|
|
||||||
hasCustomFields: customFields > 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieceRows = computed(() =>
|
|
||||||
piecesList.value.map((piece) => ({
|
|
||||||
piece,
|
|
||||||
suppliers: buildPieceSuppliersDisplay(piece),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
|
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
|
||||||
|
|
||||||
if (blockingReasons.length) {
|
if (blockingReasons.length) {
|
||||||
showError(
|
showError(`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
|
||||||
`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(
|
|
||||||
', ',
|
|
||||||
)}. Supprimez ou détachez ces éléments avant de réessayer.`
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieceName = piece?.name || 'cette pièce'
|
const pieceName = piece?.name || 'cette pièce'
|
||||||
const confirmLines = [
|
const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`]
|
||||||
`Voulez-vous vraiment supprimer ${pieceName} ?`,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (hasCustomFields) {
|
if (hasCustomFields) {
|
||||||
confirmLines.push(
|
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
|
||||||
'Les valeurs de champs personnalisés associées seront également supprimées.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
||||||
if (!confirmed) {
|
if (!confirmed) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await deletePiece(piece.id)
|
await deletePiece(piece.id)
|
||||||
// Reload current page after deletion
|
|
||||||
fetchPieces()
|
fetchPieces()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
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' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([fetchPieces(), loadPieceTypes()])
|
||||||
fetchPieces(),
|
|
||||||
loadPieceTypes()
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="pieceDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
<main class="container mx-auto px-6 py-10">
|
<main class="container mx-auto px-6 py-10">
|
||||||
@@ -79,6 +80,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Description</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editionForm.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Description de la pièce (optionnel)"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -320,8 +334,8 @@
|
|||||||
:class="documentThumbnailClass(document)"
|
:class="documentThumbnailClass(document)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
@@ -568,6 +582,7 @@ const selectedTypeId = ref<string>('')
|
|||||||
const pieceTypeDetails = ref<any | null>(null)
|
const pieceTypeDetails = ref<any | null>(null)
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
|
description: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
@@ -823,6 +838,7 @@ watch(
|
|||||||
selectedTypeId.value = resolvedTypeId
|
selectedTypeId.value = resolvedTypeId
|
||||||
|
|
||||||
editionForm.name = currentPiece.name || ''
|
editionForm.name = currentPiece.name || ''
|
||||||
|
editionForm.description = currentPiece.description || ''
|
||||||
editionForm.reference = currentPiece.reference || ''
|
editionForm.reference = currentPiece.reference || ''
|
||||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||||
currentPiece,
|
currentPiece,
|
||||||
@@ -895,6 +911,7 @@ const submitEdition = async () => {
|
|||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
name: editionForm.name.trim(),
|
name: editionForm.name.trim(),
|
||||||
|
description: editionForm.description.trim() || null,
|
||||||
constructeurIds,
|
constructeurIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Description</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="creationForm.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Description de la pièce (optionnel)"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -302,6 +315,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
@@ -336,6 +350,7 @@ const selectedTypeId = ref<string>(initialTypeId.value)
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const creationForm = reactive({
|
const creationForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
|
description: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
@@ -490,6 +505,7 @@ const canSubmit = computed(() =>
|
|||||||
|
|
||||||
const clearCreationForm = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
|
creationForm.description = ''
|
||||||
creationForm.reference = ''
|
creationForm.reference = ''
|
||||||
creationForm.constructeurIds = []
|
creationForm.constructeurIds = []
|
||||||
creationForm.prix = ''
|
creationForm.prix = ''
|
||||||
@@ -513,6 +529,11 @@ const submitCreation = async () => {
|
|||||||
typePieceId: selectedType.value.id,
|
typePieceId: selectedType.value.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const description = creationForm.description.trim()
|
||||||
|
if (description) {
|
||||||
|
payload.description = description
|
||||||
|
}
|
||||||
|
|
||||||
const reference = creationForm.reference.trim()
|
const reference = creationForm.reference.trim()
|
||||||
if (reference) {
|
if (reference) {
|
||||||
payload.reference = reference
|
payload.reference = reference
|
||||||
@@ -579,7 +600,7 @@ const submitCreation = async () => {
|
|||||||
toast.showError(result.error)
|
toast.showError(result.error)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(error?.message || 'Erreur lors de la création de la pièce')
|
toast.showError(humanizeError(error?.message) || 'Impossible de créer la pièce')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
uploadingDocuments.value = false
|
uploadingDocuments.value = false
|
||||||
|
|||||||
@@ -19,51 +19,8 @@
|
|||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
||||||
<label class="w-full sm:w-72">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
|
||||||
<input
|
|
||||||
v-model="searchTerm"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
|
||||||
placeholder="Nom ou référence…"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
|
|
||||||
<select
|
|
||||||
id="product-sort"
|
|
||||||
v-model="sortField"
|
|
||||||
class="select select-bordered select-sm"
|
|
||||||
>
|
|
||||||
<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-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-dir">Ordre</label>
|
|
||||||
<select
|
|
||||||
id="product-dir"
|
|
||||||
v-model="sortDirection"
|
|
||||||
class="select select-bordered select-sm"
|
|
||||||
>
|
|
||||||
<option value="asc">Ascendant</option>
|
|
||||||
<option value="desc">Descendant</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-base-content/60 lg:text-right">
|
|
||||||
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center py-10">
|
|
||||||
<span class="loading loading-spinner" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="alert alert-error"
|
class="alert alert-error"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -75,96 +32,107 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
|
<DataTable
|
||||||
Chargement du catalogue…
|
v-else
|
||||||
</p>
|
:columns="columns"
|
||||||
|
:rows="productRows"
|
||||||
|
:loading="loadingProducts"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucun produit n'a encore été enregistré."
|
||||||
|
no-results-message="Aucun produit ne correspond à votre recherche."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="table.searchTerm.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence…"
|
||||||
|
@input="table.debouncedSearch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70">
|
<template #cell-preview="{ row }">
|
||||||
Aucun produit n'a encore été enregistré.
|
<DocumentThumbnail
|
||||||
</p>
|
:document="resolvePrimaryDocument(row.product)"
|
||||||
|
:alt="resolvePreviewAlt(row.product)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
|
<template #cell-name="{ row }">
|
||||||
Aucun produit ne correspond à votre recherche.
|
<span class="font-medium">{{ row.product.name }}</span>
|
||||||
</p>
|
</template>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<template #cell-reference="{ row }">
|
||||||
<table class="table table-sm md:table-md">
|
{{ row.product.reference || '—' }}
|
||||||
<thead>
|
</template>
|
||||||
<tr>
|
|
||||||
<th class="w-16">Aperçu</th>
|
<template #cell-typeProduct="{ row }">
|
||||||
<th>Nom</th>
|
<NuxtLink
|
||||||
<th>Référence</th>
|
v-if="row.product.typeProduct?.id"
|
||||||
<th>Type de produit</th>
|
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||||
<th>Fournisseurs</th>
|
class="link link-hover link-primary"
|
||||||
<th class="text-right">Prix indicatif</th>
|
>
|
||||||
<th class="w-32 text-right">Actions</th>
|
{{ row.product.typeProduct.name }}
|
||||||
</tr>
|
</NuxtLink>
|
||||||
</thead>
|
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||||
<tbody>
|
</template>
|
||||||
<tr v-for="row in productRows" :key="row.product.id">
|
|
||||||
<td class="align-middle">
|
<template #cell-suppliers="{ row }">
|
||||||
<DocumentThumbnail
|
<div
|
||||||
:document="resolvePrimaryDocument(row.product)"
|
v-if="row.suppliers.visible.length"
|
||||||
:alt="resolvePreviewAlt(row.product)"
|
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
||||||
/>
|
:title="row.suppliers.tooltip"
|
||||||
</td>
|
>
|
||||||
<td class="font-medium">{{ row.product.name }}</td>
|
<span
|
||||||
<td>{{ row.product.reference || '—' }}</td>
|
v-for="supplier in row.suppliers.visible"
|
||||||
<td>
|
:key="supplier"
|
||||||
<NuxtLink
|
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||||
v-if="row.product.typeProduct?.id"
|
>
|
||||||
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
{{ supplier }}
|
||||||
class="link link-hover link-primary"
|
</span>
|
||||||
>
|
<span
|
||||||
{{ row.product.typeProduct.name }}
|
v-if="row.suppliers.overflow"
|
||||||
</NuxtLink>
|
class="badge badge-outline badge-sm"
|
||||||
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
>
|
||||||
</td>
|
+{{ row.suppliers.overflow }}
|
||||||
<td>
|
</span>
|
||||||
<div
|
</div>
|
||||||
v-if="row.suppliers.visible.length"
|
<span v-else class="text-sm text-base-content/50">—</span>
|
||||||
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
</template>
|
||||||
:title="row.suppliers.tooltip"
|
|
||||||
>
|
<template #cell-price="{ row }">
|
||||||
<span
|
{{ formatPrice(row.product.supplierPrice) }}
|
||||||
v-for="supplier in row.suppliers.visible"
|
</template>
|
||||||
:key="supplier"
|
|
||||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
<template #cell-actions="{ row }">
|
||||||
>
|
<div class="flex justify-end gap-2">
|
||||||
{{ supplier }}
|
<NuxtLink
|
||||||
</span>
|
:to="`/product/${row.product.id}/edit`"
|
||||||
<span
|
class="btn btn-ghost btn-xs"
|
||||||
v-if="row.suppliers.overflow"
|
>
|
||||||
class="badge badge-outline badge-sm"
|
Modifier
|
||||||
>
|
</NuxtLink>
|
||||||
+{{ row.suppliers.overflow }}
|
<button
|
||||||
</span>
|
v-if="canEdit"
|
||||||
</div>
|
type="button"
|
||||||
<span v-else class="text-sm text-base-content/50">—</span>
|
class="btn btn-ghost btn-xs text-error"
|
||||||
</td>
|
@click="confirmDelete(row.product)"
|
||||||
<td class="text-right">
|
>
|
||||||
{{ formatPrice(row.product.supplierPrice) }}
|
Supprimer
|
||||||
</td>
|
</button>
|
||||||
<td class="text-right space-x-2">
|
</div>
|
||||||
<NuxtLink
|
</template>
|
||||||
:to="`/product/${row.product.id}/edit`"
|
</DataTable>
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</NuxtLink>
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
@click="confirmDelete(row.product)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -173,24 +141,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useHead } from '#imports'
|
import { useHead } from '#imports'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({ title: 'Catalogue des produits' }))
|
||||||
title: 'Catalogue des produits',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
products,
|
products,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
loaded,
|
|
||||||
error,
|
error,
|
||||||
loadProducts,
|
loadProducts,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
@@ -198,65 +164,54 @@ const {
|
|||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
|
const table = useDataTable(
|
||||||
q: { default: '', debounce: 300 },
|
{ fetchData: fetchProducts },
|
||||||
sort: { default: 'name' },
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||||
dir: { default: 'asc' },
|
)
|
||||||
})
|
|
||||||
|
|
||||||
// Enrichir les produits avec les types de produits complets
|
const loadingProducts = computed(() => loading.value)
|
||||||
|
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||||
|
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const productsOnPage = computed(() => productRows.value.length)
|
||||||
|
const paginationState = table.pagination(total, productsOnPage)
|
||||||
|
|
||||||
|
// Enrich products with full type data
|
||||||
const normalizedProducts = computed(() => {
|
const normalizedProducts = computed(() => {
|
||||||
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
||||||
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
||||||
return {
|
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
|
||||||
...product,
|
|
||||||
typeProduct: typeProduct || product.typeProduct || null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const hasLoaded = computed(() => loaded.value)
|
|
||||||
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
|
||||||
|
|
||||||
const filteredProducts = computed(() => {
|
|
||||||
const term = searchTerm.value.trim().toLowerCase()
|
|
||||||
const items = normalizedProducts.value.slice()
|
|
||||||
|
|
||||||
const filtered = term
|
|
||||||
? items.filter((product) => {
|
|
||||||
const name = (product?.name || '').toLowerCase()
|
|
||||||
const reference = (product?.reference || '').toLowerCase()
|
|
||||||
const typeName = (product?.typeProduct?.name || '').toLowerCase()
|
|
||||||
return (
|
|
||||||
name.includes(term) ||
|
|
||||||
reference.includes(term) ||
|
|
||||||
typeName.includes(term)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: items
|
|
||||||
|
|
||||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
|
||||||
if (sortField.value === 'name') {
|
|
||||||
return (
|
|
||||||
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
|
|
||||||
) * direction
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
|
|
||||||
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
|
|
||||||
return (dateA - dateB) * direction
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredCount = computed(() => filteredProducts.value.length)
|
const productRows = computed(() =>
|
||||||
const totalCount = computed(() => {
|
normalizedProducts.value.map(product => ({
|
||||||
const reported = Number(total.value)
|
id: product.id,
|
||||||
if (!Number.isFinite(reported) || reported < 0) {
|
product,
|
||||||
return normalizedProducts.value.length
|
suppliers: buildSuppliersDisplay(product),
|
||||||
}
|
})),
|
||||||
return reported
|
)
|
||||||
})
|
|
||||||
|
async function fetchProducts() {
|
||||||
|
await loadProducts({
|
||||||
|
search: table.searchTerm.value,
|
||||||
|
page: table.currentPage.value,
|
||||||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
|
orderBy: table.sortField.value,
|
||||||
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
|
typeName: table.columnFilters.value.typeProduct || undefined,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -265,14 +220,9 @@ const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formatPrice = (value: any) => {
|
const formatPrice = (value: any) => {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
const number = Number(value)
|
const number = Number(value)
|
||||||
if (Number.isNaN(number)) {
|
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
return priceFormatter.format(number)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE_SUPPLIERS = 3
|
const MAX_VISIBLE_SUPPLIERS = 3
|
||||||
@@ -282,59 +232,34 @@ const resolveProductSuppliers = (product: Record<string, any>) => {
|
|||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|
||||||
const pushName = (maybeName: unknown) => {
|
const pushName = (maybeName: unknown) => {
|
||||||
if (typeof maybeName !== 'string') {
|
if (typeof maybeName !== 'string') return
|
||||||
return
|
|
||||||
}
|
|
||||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||||
if (!normalized.length) {
|
if (!normalized.length) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const key = normalized.toLowerCase()
|
const key = normalized.toLowerCase()
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
names.push(normalized)
|
names.push(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectConstructeurs = (value: unknown): void => {
|
const collectConstructeurs = (value: unknown): void => {
|
||||||
if (!value) {
|
if (!value) return
|
||||||
return
|
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||||
}
|
if (typeof value === 'string') { pushName(value); return }
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(collectConstructeurs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
pushName(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
const record = value as Record<string, any>
|
const record = value as Record<string, any>
|
||||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||||
if (record?.constructeur) {
|
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||||
collectConstructeurs(record.constructeur)
|
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||||
}
|
|
||||||
if (Array.isArray(record?.constructeurs)) {
|
|
||||||
collectConstructeurs(record.constructeurs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectFromLabel = (value: unknown): void => {
|
const collectFromLabel = (value: unknown): void => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') return
|
||||||
return
|
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||||
}
|
|
||||||
value
|
|
||||||
.split(/[,;\\/•·|]+/)
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.forEach(pushName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collectConstructeurs(product?.constructeurs)
|
collectConstructeurs(product?.constructeurs)
|
||||||
collectConstructeurs(product?.constructeur)
|
collectConstructeurs(product?.constructeur)
|
||||||
|
|
||||||
collectFromLabel(product?.constructeursLabel)
|
collectFromLabel(product?.constructeursLabel)
|
||||||
collectFromLabel(product?.supplierLabel)
|
collectFromLabel(product?.supplierLabel)
|
||||||
collectFromLabel(product?.suppliers)
|
collectFromLabel(product?.suppliers)
|
||||||
@@ -346,53 +271,28 @@ const buildSuppliersDisplay = (product: Record<string, any>) => {
|
|||||||
const suppliers = resolveProductSuppliers(product)
|
const suppliers = resolveProductSuppliers(product)
|
||||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||||
return {
|
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||||
suppliers,
|
|
||||||
visible,
|
|
||||||
overflow,
|
|
||||||
tooltip: suppliers.length ? suppliers.join(', ') : '',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const productRows = computed(() =>
|
|
||||||
filteredProducts.value.map((product) => ({
|
|
||||||
product,
|
|
||||||
suppliers: buildSuppliersDisplay(product),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolvePrimaryDocument = (product: Record<string, any>) => {
|
const resolvePrimaryDocument = (product: Record<string, any>) => {
|
||||||
const documents = Array.isArray(product?.documents) ? product.documents : []
|
const documents = Array.isArray(product?.documents) ? product.documents : []
|
||||||
if (!documents.length) {
|
if (!documents.length) return null
|
||||||
return null
|
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||||
}
|
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
if (!withPath.length) return normalized[0] ?? null
|
||||||
const withPath = normalized.filter((doc) => doc?.path)
|
const images = withPath.filter((doc: any) => isImageDocument(doc))
|
||||||
if (!withPath.length) {
|
if (images.length) return images[0]
|
||||||
return normalized[0] ?? null
|
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||||
}
|
if (pdf) return pdf
|
||||||
const images = withPath.filter((doc) => isImageDocument(doc))
|
|
||||||
if (images.length) {
|
|
||||||
return images[0]
|
|
||||||
}
|
|
||||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
|
||||||
if (pdf) {
|
|
||||||
return pdf
|
|
||||||
}
|
|
||||||
return withPath[0]
|
return withPath[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePreviewAlt = (product: Record<string, any>) => {
|
const resolvePreviewAlt = (product: Record<string, any>) => {
|
||||||
const parts = [product?.name, product?.reference].filter(Boolean)
|
const parts = [product?.name, product?.reference].filter(Boolean)
|
||||||
if (parts.length) {
|
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||||
return `Aperçu du document de ${parts.join(' – ')}`
|
|
||||||
}
|
|
||||||
return 'Aperçu du document'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = () => fetchProducts()
|
||||||
await loadProducts({ itemsPerPage: 200, force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
@@ -400,10 +300,7 @@ const confirmDelete = async (product: Record<string, any>) => {
|
|||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
||||||
})
|
})
|
||||||
if (!confirmed) {
|
if (!confirmed) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteProduct(product.id)
|
const result = await deleteProduct(product.id)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.showSuccess(`Produit "${product.name}" supprimé`)
|
toast.showSuccess(`Produit "${product.name}" supprimé`)
|
||||||
@@ -411,9 +308,6 @@ const confirmDelete = async (product: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([fetchProducts(), loadProductTypes()])
|
||||||
loadProducts({ itemsPerPage: 200, force: true }),
|
|
||||||
loadProductTypes()
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="productDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
<main class="container mx-auto px-6 py-10">
|
<main class="container mx-auto px-6 py-10">
|
||||||
@@ -244,8 +245,8 @@
|
|||||||
:class="documentThumbnailClass(document)"
|
:class="documentThumbnailClass(document)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
@@ -406,6 +407,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||||
@@ -699,7 +701,7 @@ const submitEdition = async () => {
|
|||||||
await router.push('/product-catalog')
|
await router.push('/product-catalog')
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
|
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="siteDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Locked: machines linked -->
|
||||||
|
<div v-else-if="type && hasMachines" class="my-8">
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="card-title text-2xl">
|
||||||
|
{{ type.name }}
|
||||||
|
</h2>
|
||||||
|
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
|
||||||
|
Retour
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<IconLucideTriangleAlert class="w-5 h-5" />
|
||||||
|
<span>Ce squelette ne peut pas être modifié car des machines y sont rattachées.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Edit Form -->
|
<!-- Edit Form -->
|
||||||
<div v-else-if="type" class="my-8">
|
<div v-else-if="type" class="my-8">
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
@@ -48,11 +68,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -63,6 +84,10 @@ const { showSuccess, showError } = useToast()
|
|||||||
const type = ref(null)
|
const type = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const hasMachines = computed(() => {
|
||||||
|
const machines = type.value?.machines
|
||||||
|
return Array.isArray(machines) && machines.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
// Données éditées du type
|
// Données éditées du type
|
||||||
const editedType = ref({
|
const editedType = ref({
|
||||||
|
|||||||
46
app/shared/types/dataTable.ts
Normal file
46
app/shared/types/dataTable.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export interface DataTableColumn {
|
||||||
|
/** Unique key — used as slot name prefix (`#cell-{key}`) and default data accessor (`row[key]`) */
|
||||||
|
key: string
|
||||||
|
/** Display label for the column header */
|
||||||
|
label: string
|
||||||
|
/** Whether clicking this column header triggers sorting. Default: false */
|
||||||
|
sortable?: boolean
|
||||||
|
/** Sort key sent to the API (may differ from `key`). Falls back to `key` if omitted. */
|
||||||
|
sortKey?: string
|
||||||
|
/** CSS class applied to both <th> and <td> */
|
||||||
|
class?: string
|
||||||
|
/** CSS class applied only to <th> */
|
||||||
|
headerClass?: string
|
||||||
|
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
|
||||||
|
width?: string
|
||||||
|
/** Text alignment: 'left' (default), 'center', 'right' */
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
/** Hide on mobile (adds 'hidden sm:table-cell') */
|
||||||
|
hiddenMobile?: boolean
|
||||||
|
/** Whether this column has a filter input under the header */
|
||||||
|
filterable?: boolean
|
||||||
|
/** Placeholder for the filter input */
|
||||||
|
filterPlaceholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataTableColumnFilters = Record<string, string>
|
||||||
|
|
||||||
|
export interface DataTableSort {
|
||||||
|
field: string
|
||||||
|
direction: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTablePagination {
|
||||||
|
currentPage: number
|
||||||
|
totalPages: number
|
||||||
|
/** Total items matching current filters */
|
||||||
|
totalItems: number
|
||||||
|
/** Items displayed on current page */
|
||||||
|
pageItems: number
|
||||||
|
/** Available per-page options. If omitted, per-page selector is hidden. */
|
||||||
|
perPageOptions?: number[]
|
||||||
|
/** Currently selected items per page */
|
||||||
|
perPage?: number
|
||||||
|
}
|
||||||
@@ -19,26 +19,32 @@ export const formatSize = (size: number | null | undefined): string => {
|
|||||||
return `${formatted.toFixed(1)} ${units[index]}`
|
return `${formatted.toFixed(1)} ${units[index]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveUrl = (doc: any): string => doc?.fileUrl || doc?.path || ''
|
||||||
|
|
||||||
export const shouldInlinePdf = (doc: any): boolean => {
|
export const shouldInlinePdf = (doc: any): boolean => {
|
||||||
if (!doc || !isPdfDocument(doc) || !doc.path) return false
|
if (!doc || !isPdfDocument(doc)) return false
|
||||||
|
const url = resolveUrl(doc)
|
||||||
|
if (!url) return false
|
||||||
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) return false
|
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appendPdfViewerParams = (src: string): string => {
|
export const appendPdfViewerParams = (src: string): string => {
|
||||||
if (!src || src.startsWith('data:')) return src || ''
|
if (!src) return ''
|
||||||
|
if (src.startsWith('data:')) return src
|
||||||
if (src.includes('#')) return `${src}&toolbar=0&navpanes=0`
|
if (src.includes('#')) return `${src}&toolbar=0&navpanes=0`
|
||||||
return `${src}#toolbar=0&navpanes=0`
|
return `${src}#toolbar=0&navpanes=0`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const documentPreviewSrc = (doc: any): string => {
|
export const documentPreviewSrc = (doc: any): string => {
|
||||||
if (!doc?.path) return ''
|
const url = resolveUrl(doc)
|
||||||
if (isPdfDocument(doc)) return appendPdfViewerParams(doc.path)
|
if (!url) return ''
|
||||||
return doc.path
|
if (isPdfDocument(doc)) return appendPdfViewerParams(url)
|
||||||
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
export const documentThumbnailClass = (doc: any): string => {
|
export const documentThumbnailClass = (doc: any): string => {
|
||||||
if (shouldInlinePdf(doc) || (isImageDocument(doc) && doc?.path)) return 'h-24 w-20'
|
if (shouldInlinePdf(doc) || (isImageDocument(doc) && resolveUrl(doc))) return 'h-24 w-20'
|
||||||
return 'h-16 w-16'
|
return 'h-16 w-16'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +58,14 @@ export const documentIcon = (doc: any): FileIconResult =>
|
|||||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||||
|
|
||||||
export const downloadDocument = (doc: any): void => {
|
export const downloadDocument = (doc: any): void => {
|
||||||
if (!doc?.path) return
|
// Prefer dedicated download endpoint
|
||||||
const target = String(doc.path)
|
if (doc?.downloadUrl) {
|
||||||
|
window.open(doc.downloadUrl, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback for legacy data: URIs during migration
|
||||||
|
const target = resolveUrl(doc)
|
||||||
|
if (!target) return
|
||||||
if (target.startsWith('data:')) {
|
if (target.startsWith('data:')) {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = target
|
link.href = target
|
||||||
|
|||||||
152
app/shared/utils/errorMessages.ts
Normal file
152
app/shared/utils/errorMessages.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Translates raw backend error messages (Symfony, Doctrine, API Platform)
|
||||||
|
* into user-friendly French messages for display in toasts/alerts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EXACT_MATCHES: Record<string, string> = {
|
||||||
|
// UniqueConstraintSubscriber (HTTP 409)
|
||||||
|
'nom duplique': 'Un élément avec ce nom existe déjà.',
|
||||||
|
|
||||||
|
// English backend messages → French
|
||||||
|
'Machine not found.': 'Machine introuvable.',
|
||||||
|
'Composant not found.': 'Composant introuvable.',
|
||||||
|
'Piece not found.': 'Pièce introuvable.',
|
||||||
|
'Product not found.': 'Produit introuvable.',
|
||||||
|
'Site not found.': 'Site introuvable.',
|
||||||
|
'Custom field not found.': 'Champ personnalisé introuvable.',
|
||||||
|
'Custom field value not found.': 'Valeur du champ personnalisé introuvable.',
|
||||||
|
'Document not found.': 'Document introuvable.',
|
||||||
|
'File not found on disk.': 'Le fichier n\'a pas été trouvé sur le serveur.',
|
||||||
|
'Invalid document data.': 'Les données du document sont invalides.',
|
||||||
|
'Invalid JSON payload.': 'Les données envoyées sont invalides.',
|
||||||
|
'Unsupported entity type.': 'Type d\'entité non supporté.',
|
||||||
|
'Entity target is missing.': 'La cible de l\'entité est manquante.',
|
||||||
|
'customFieldId or customFieldName is required.': 'L\'identifiant du champ personnalisé est requis.',
|
||||||
|
|
||||||
|
// Symfony validator messages
|
||||||
|
'This value should not be blank.': 'Ce champ ne peut pas être vide.',
|
||||||
|
'This value is not a valid email address.': 'L\'adresse email n\'est pas valide.',
|
||||||
|
'This value is already used.': 'Cette valeur est déjà utilisée.',
|
||||||
|
'This field is missing.': 'Un champ obligatoire est manquant.',
|
||||||
|
|
||||||
|
// HTTP status texts (used in "Erreur XXX: StatusText" fallback)
|
||||||
|
'Internal Server Error': 'Erreur interne du serveur. Veuillez réessayer.',
|
||||||
|
'Bad Request': 'Requête invalide.',
|
||||||
|
'Not Found': 'Ressource introuvable.',
|
||||||
|
'Conflict': 'Un élément similaire existe déjà.',
|
||||||
|
'Unprocessable Entity': 'Données invalides.',
|
||||||
|
'Unprocessable Content': 'Données invalides.',
|
||||||
|
'Service Unavailable': 'Service temporairement indisponible. Veuillez réessayer.',
|
||||||
|
'Gateway Timeout': 'Le serveur met trop de temps à répondre. Veuillez réessayer.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TECHNICAL_PATTERNS: Array<[RegExp, string]> = [
|
||||||
|
// Database / Doctrine errors
|
||||||
|
[/SQLSTATE\[/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||||
|
[/An exception occurred/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||||
|
[/Duplicate entry/i, 'Un élément avec ces données existe déjà.'],
|
||||||
|
[/unique.*constraint.*violation/i, 'Un élément avec ces données existe déjà.'],
|
||||||
|
[/foreign key constraint/i, 'Impossible de supprimer cet élément car il est utilisé ailleurs.'],
|
||||||
|
[/violates not-null constraint/i, 'Un champ obligatoire n\'a pas été renseigné.'],
|
||||||
|
[/violates check constraint/i, 'Une valeur saisie est invalide.'],
|
||||||
|
|
||||||
|
// Symfony / API Platform internal messages
|
||||||
|
[/Expected argument of type/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/Could not denormalize/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/The JSON value could not be decoded/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/Syntax error.*JSON/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/No route found/i, 'Ressource introuvable.'],
|
||||||
|
[/Access Denied/i, 'Permissions insuffisantes pour cette action.'],
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if a message contains technical jargon that should not be shown to users.
|
||||||
|
*/
|
||||||
|
function containsTechnicalJargon(message: string): boolean {
|
||||||
|
const patterns = [
|
||||||
|
/stack trace/i,
|
||||||
|
/exception/i,
|
||||||
|
/\bat\s+[\w\\]+::/,
|
||||||
|
/vendor\//,
|
||||||
|
/\.php/,
|
||||||
|
/doctrine/i,
|
||||||
|
/symfony/i,
|
||||||
|
/SQLSTATE/i,
|
||||||
|
/PDOException/i,
|
||||||
|
/DBALException/i,
|
||||||
|
/RuntimeException/i,
|
||||||
|
/TypeError/i,
|
||||||
|
/LogicException/i,
|
||||||
|
/InvalidArgumentException/i,
|
||||||
|
/UnexpectedValueException/i,
|
||||||
|
/constraint.*violation/i,
|
||||||
|
/entity.*manager/i,
|
||||||
|
/Hydra error/i,
|
||||||
|
]
|
||||||
|
return patterns.some((p) => p.test(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a raw backend error message into a user-friendly French message.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
|
* showError(humanizeError(rawMessage))
|
||||||
|
*/
|
||||||
|
export function humanizeError(rawMessage: string | undefined | null): string {
|
||||||
|
if (!rawMessage) return 'Une erreur est survenue.'
|
||||||
|
|
||||||
|
const trimmed = rawMessage.trim()
|
||||||
|
if (!trimmed) return 'Une erreur est survenue.'
|
||||||
|
|
||||||
|
// 1. Exact match
|
||||||
|
if (EXACT_MATCHES[trimmed]) return EXACT_MATCHES[trimmed]
|
||||||
|
|
||||||
|
// 2. "Erreur XXX: StatusText" pattern — translate the status text
|
||||||
|
const httpMatch = trimmed.match(/^Erreur (\d{3})\s*:\s*(.+)$/)
|
||||||
|
if (httpMatch) {
|
||||||
|
const statusText = httpMatch[2]!.trim()
|
||||||
|
if (EXACT_MATCHES[statusText]) return EXACT_MATCHES[statusText]
|
||||||
|
return `Erreur serveur (${httpMatch[1]}). Veuillez réessayer.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Regex patterns for technical errors
|
||||||
|
for (const [pattern, replacement] of TECHNICAL_PATTERNS) {
|
||||||
|
if (pattern.test(trimmed)) return replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If it contains technical jargon, replace with generic message
|
||||||
|
if (containsTechnicalJargon(trimmed)) {
|
||||||
|
return 'Une erreur est survenue. Veuillez réessayer.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Already user-friendly — return as-is
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the best error message from various backend response formats.
|
||||||
|
* Handles: { message }, { error }, { detail }, { "hydra:description" }
|
||||||
|
*/
|
||||||
|
export function extractApiErrorMessage(errorData: Record<string, unknown>): string | null {
|
||||||
|
if (!errorData || typeof errorData !== 'object') return null
|
||||||
|
|
||||||
|
// Symfony validator violations — priorité max (message propre sans préfixe champ)
|
||||||
|
if (Array.isArray(errorData.violations) && errorData.violations.length > 0) {
|
||||||
|
const first = errorData.violations[0] as Record<string, unknown>
|
||||||
|
if (typeof first?.message === 'string') return first.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueConstraintSubscriber format ({ success: false, error: "nom duplique" })
|
||||||
|
if (typeof errorData.error === 'string') return errorData.error
|
||||||
|
|
||||||
|
// Custom controllers format
|
||||||
|
if (typeof errorData.message === 'string') return errorData.message
|
||||||
|
if (Array.isArray(errorData.message) && typeof errorData.message[0] === 'string') return errorData.message[0]
|
||||||
|
|
||||||
|
// API Platform hydra format (fallback — peut contenir "propertyPath: message")
|
||||||
|
if (typeof errorData['hydra:description'] === 'string') return errorData['hydra:description']
|
||||||
|
if (typeof errorData.detail === 'string') return errorData.detail
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -3,19 +3,19 @@ import { getFileIcon } from './fileIcons'
|
|||||||
export const getPreviewType = (document) => {
|
export const getPreviewType = (document) => {
|
||||||
if (!document) { return null }
|
if (!document) { return null }
|
||||||
const mime = (document.mimeType || '').toLowerCase()
|
const mime = (document.mimeType || '').toLowerCase()
|
||||||
const path = document.path || ''
|
|
||||||
|
|
||||||
const check = prefix => mime.startsWith(prefix) || path.startsWith(`data:${prefix}`)
|
if (mime.startsWith('image/')) { return 'image' }
|
||||||
|
if (mime === 'application/pdf') { return 'pdf' }
|
||||||
if (check('image/')) { return 'image' }
|
if (mime.startsWith('audio/')) { return 'audio' }
|
||||||
if (mime === 'application/pdf' || path.startsWith('data:application/pdf')) { return 'pdf' }
|
if (mime.startsWith('video/')) { return 'video' }
|
||||||
if (check('audio/')) { return 'audio' }
|
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) { return 'text' }
|
||||||
if (check('video/')) { return 'video' }
|
|
||||||
if (check('text/') || mime.includes('json') || mime.includes('xml') || path.startsWith('data:application/json')) { return 'text' }
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const canPreviewDocument = (document = {}) => !!getPreviewType(document)
|
export const canPreviewDocument = (document = {}) => {
|
||||||
|
if (!getPreviewType(document)) return false
|
||||||
|
return !!(document.fileUrl || document.path)
|
||||||
|
}
|
||||||
|
|
||||||
export const isImageDocument = (document = {}) => getPreviewType(document) === 'image'
|
export const isImageDocument = (document = {}) => getPreviewType(document) === 'image'
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Rapport de déduplication
|
|
||||||
|
|
||||||
## DUP-001 · Score 92 · Formulaire de contact site
|
|
||||||
- **Motif** : duplication à l’identique du bloc de champs de contact (nom, téléphone, adresse…) entre les modales de création et d’édition de site.
|
|
||||||
- **Occurrences détectées** :
|
|
||||||
- `app/components/sites/SiteCreateModal.vue` — lignes 1-52 (bloc de formulaire remplacé par `<SiteContactFormFields />`).
|
|
||||||
- `app/components/sites/SiteEditModal.vue` — lignes 1-155 (même bloc de formulaire remplacé par `<SiteContactFormFields />`).
|
|
||||||
- **Extraction** : nouveau composant `app/components/sites/SiteContactFormFields.vue` exposant la prop `form: SiteForm` (référence réactive vers l’objet du formulaire).
|
|
||||||
- **Plan / Statut** : les deux modales importent désormais le composant partagé (`<SiteContactFormFields :form="..." />`), supprimant l’ancienne duplication. Aucun changement d’API publique côté modale.
|
|
||||||
|
|
||||||
## DUP-002 · Score 95 · Éditeur de contraintes (composants/pièces)
|
|
||||||
- **Motif** : logique et template identiques pour la gestion des groupes requis dans `TypeEditComponentRequirementsSection` et `TypeEditPieceRequirementsSection` (ajout/suppression, formulaires, cases à cocher).
|
|
||||||
- **Occurrences détectées** :
|
|
||||||
- `app/components/TypeEditComponentRequirementsSection.vue` — lignes 1-94 (ancien template remplacé par `<RequirementListEditor />`).
|
|
||||||
- `app/components/TypeEditPieceRequirementsSection.vue` — lignes 1-94 (même duplication remplacée).
|
|
||||||
- **Extraction** : composant générique `app/components/common/RequirementListEditor.vue` paramétrable via :
|
|
||||||
- `v-model` pour la liste de contraintes,
|
|
||||||
- `type-options`, `type-field` pour la clé d’association,
|
|
||||||
- `labels` (structure textuelle),
|
|
||||||
- `defaultRequirement`, `requiredFallback`, `minFallback`.
|
|
||||||
- **Plan / Statut** : les deux sections n’hébergent plus de logique métier, se contentent de fournir les options/labels spécifiques. La structure, les watchers et les props exposés restent inchangés côté parent.
|
|
||||||
|
|
||||||
## DUP-003 · Score 88 · Formatage de dates UI
|
|
||||||
- **Motif** : fonctions utilitaires de formatage (`toLocaleDateString`/`Intl.DateTimeFormat`) recopiées dans plusieurs pages (catalogues modèles et documents).
|
|
||||||
- **Occurrences détectées** :
|
|
||||||
- `app/pages/component-catalog.vue` — lignes 70-311 (affichage de la colonne « Modifié »).
|
|
||||||
- `app/pages/pieces-catalog.vue` — lignes 70-310.
|
|
||||||
- `app/pages/documents.vue` — lignes 90-188.
|
|
||||||
- **Extraction** : utilitaire commun `app/utils/date.ts` exposant `formatFrenchDate(value: Date | string | number | null | undefined): string` avec gestion des valeurs nulles/invalides.
|
|
||||||
- **Plan / Statut** : toutes les pages importent `formatFrenchDate` et l’utilisent directement en template. Plus de fonction locale dupliquée.
|
|
||||||
|
|
||||||
## Couverture & suites
|
|
||||||
- Les trois duplications les plus impactantes repérées ont été factorisées (>= 80 % du volume ciblé).
|
|
||||||
- Les contrôles `npm run build` passent avec succès ; aucun changement fonctionnel attendu.
|
|
||||||
- Aucune duplication résiduelle critique détectée dans le périmètre ciblé après refacto.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Micro duplication report
|
|
||||||
|
|
||||||
## MDUP-001 · Score 92 · Type form-field
|
|
||||||
- **Pattern**: Champ téléphone complet (label, input `tel`, placeholder "Ex: 06 00 00 00 00", règles de validation implicites).
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/ConstructeurSelect.vue` L70-L86 — modal de création de constructeur. 【F:app/components/ConstructeurSelect.vue†L66-L88】
|
|
||||||
- `app/pages/constructeurs.vue` L82-L92 — formulaire de création/édition. 【F:app/pages/constructeurs.vue†L80-L92】
|
|
||||||
- `app/components/sites/SiteContactFormFields.vue` L1-L57 — bloc de formulaire de contact. 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
|
|
||||||
- `app/pages/index.vue` L200-L224 — création rapide d’un site. 【F:app/pages/index.vue†L200-L224】
|
|
||||||
- **Extraction**: ✅ `app/components/form/FieldPhone.vue` (props : `modelValue`, `label`, `required`, `error`, `help`, `placeholder`, `disabled`, `normalizeOnBlur`, `validateOnBlur`). 【F:app/components/form/FieldPhone.vue†L1-L113】
|
|
||||||
- **Plan de remplacement**: Remplacer chaque bloc par `<FieldPhone v-model="..." />`. Call-sites déjà migrés ci-dessus.
|
|
||||||
|
|
||||||
## MDUP-002 · Score 90 · Type form-field
|
|
||||||
- **Pattern**: Champ email (label « Email », input `type="email"`, placeholder d’exemple, aucune validation mutualisée).
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/ConstructeurSelect.vue` L66-L84. 【F:app/components/ConstructeurSelect.vue†L66-L86】
|
|
||||||
- `app/pages/constructeurs.vue` L82-L90. 【F:app/pages/constructeurs.vue†L80-L92】
|
|
||||||
- **Extraction**: ✅ `app/components/form/FieldEmail.vue` avec normalisation et validation partagée. 【F:app/components/form/FieldEmail.vue†L1-L112】
|
|
||||||
- **Plan de remplacement**: Blocs remplacés par `<FieldEmail />` sur les deux formulaires.
|
|
||||||
|
|
||||||
## MDUP-003 · Score 88 · Type form-field
|
|
||||||
- **Pattern**: Groupe « informations de contact site » (Nom du contact, Téléphone, Adresse, Code postal, Ville) répliqué.
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/sites/SiteContactFormFields.vue` (composant existant). 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
|
|
||||||
- `app/pages/index.vue` L200-L223 — doublait le bloc dans le modal rapide. 【F:app/pages/index.vue†L200-L223】
|
|
||||||
- **Extraction**: ✅ Réutilisation directe du composant `SiteContactFormFields` sur la page index. Aucun changement d’API.
|
|
||||||
- **Plan de remplacement**: Remplacer le bloc du modal par `<SiteContactFormFields :form="newSite" />` (effectué).
|
|
||||||
|
|
||||||
## MDUP-004 · Score 86 · Type tiny-logic
|
|
||||||
- **Pattern**: Liaisons `computed({ get, set })` pour faire transiter `v-model` entre props et emits.
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/TypeEditComponentRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditComponentRequirementsSection.vue†L45-L59】
|
|
||||||
- `app/components/TypeEditPieceRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditPieceRequirementsSection.vue†L45-L59】
|
|
||||||
- `app/components/common/RequirementListEditor.vue` L198-L203. 【F:app/components/common/RequirementListEditor.vue†L198-L204】
|
|
||||||
- `app/components/TypeEditCustomFieldsSection.vue` L163-L168. 【F:app/components/TypeEditCustomFieldsSection.vue†L163-L168】
|
|
||||||
- `app/components/TypeEditBaseInfoSection.vue` L82-L102. 【F:app/components/TypeEditBaseInfoSection.vue†L82-L102】
|
|
||||||
- `app/components/sites/SiteEditModal.vue` L140-L154. 【F:app/components/sites/SiteEditModal.vue†L140-L154】
|
|
||||||
- **Extraction proposée**: `app/composables/useControlledModel.ts` retournant `{ model }` via `useVModel` maison (prop name configurable, options pour defaultValue et transform).
|
|
||||||
- **Plan**: 1) Introduire le composable, 2) remapper les computed existantes, 3) supprimer le code duplicatif.
|
|
||||||
|
|
||||||
## MDUP-005 · Score 82 · Type tiny-logic
|
|
||||||
- **Pattern**: Fonctions `createDefaultRequirement` quasi identiques (seuls champs `minCount`, `required` et `type*Id` changent).
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/TypeEditComponentRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditComponentRequirementsSection.vue†L61-L69】
|
|
||||||
- `app/components/TypeEditPieceRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditPieceRequirementsSection.vue†L61-L69】
|
|
||||||
- **Extraction proposée**: `app/shared/requirements/defaults.ts` exportant `createRequirementDefaults({ min, required, typeKey })`.
|
|
||||||
- **Plan**: Mutualiser la fonction, la paramétrer par options, adapter les deux sections.
|
|
||||||
|
|
||||||
## MDUP-006 · Score 80 · Type tiny-logic
|
|
||||||
- **Pattern**: Effet `onMounted` identique qui teste la liste et déclenche `loadX` si vide.
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/TypeEditComponentRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditComponentRequirementsSection.vue†L89-L93】
|
|
||||||
- `app/components/TypeEditPieceRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditPieceRequirementsSection.vue†L89-L93】
|
|
||||||
- **Extraction proposée**: `useEnsureOptionsLoaded(optionsRef, loader)` dans `app/composables/` pour encapsuler le check + chargement (support async/await, options pour refetch forcé).
|
|
||||||
- **Plan**: Appeler le composable dans les deux sections et supprimer le code inline.
|
|
||||||
|
|
||||||
## MDUP-007 · Score 78 · Type ui-fragment
|
|
||||||
- **Pattern**: Pieds de modale avec boutons « Annuler » + primaire + spinner optionnel.
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/ConstructeurSelect.vue` L80-L86. 【F:app/components/ConstructeurSelect.vue†L80-L86】
|
|
||||||
- `app/pages/constructeurs.vue` L86-L91. 【F:app/pages/constructeurs.vue†L86-L91】
|
|
||||||
- `app/components/sites/SiteCreateModal.vue` L21-L27. 【F:app/components/sites/SiteCreateModal.vue†L21-L27】
|
|
||||||
- `app/components/sites/SiteEditModal.vue` L82-L89. 【F:app/components/sites/SiteEditModal.vue†L82-L89】
|
|
||||||
- `app/pages/index.vue` L217-L223 & L306-L312. 【F:app/pages/index.vue†L215-L313】
|
|
||||||
- **Extraction proposée**: `app/components/common/ModalActions.vue` avec props `primaryLabel`, `primaryLoading`, `onCancel`, slots secondaires.
|
|
||||||
- **Plan**: Introduire le composant, refactorer chaque modal pour l’utiliser, garantir les mêmes classes Tailwind.
|
|
||||||
|
|
||||||
## MDUP-008 · Score 76 · Type ui-fragment
|
|
||||||
- **Pattern**: Gabarit de modale (div `.modal` + `.modal-box`, titre `<h3>`, formulaire, actions).
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/ConstructeurSelect.vue` L58-L89. 【F:app/components/ConstructeurSelect.vue†L58-L89】
|
|
||||||
- `app/components/sites/SiteCreateModal.vue` L1-L31. 【F:app/components/sites/SiteCreateModal.vue†L1-L31】
|
|
||||||
- `app/components/sites/SiteEditModal.vue` L1-L94. 【F:app/components/sites/SiteEditModal.vue†L1-L94】
|
|
||||||
- `app/pages/index.vue` L192-L315 (modales site/machine). 【F:app/pages/index.vue†L192-L315】
|
|
||||||
- **Extraction proposée**: `app/components/common/ModalShell.vue` gérant l’ouverture, le titre, le footer via slots (`header`, `default`, `footer`).
|
|
||||||
- **Plan**: Remplacer chaque squelette par le nouveau composant tout en conservant la structure DOM requise par DaisyUI.
|
|
||||||
|
|
||||||
## MDUP-009 · Score 74 · Type form-field
|
|
||||||
- **Pattern**: Champ texte simple (label, input type="text", `required`) pour les « Nom » & co.
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/ConstructeurSelect.vue` L62-L65. 【F:app/components/ConstructeurSelect.vue†L62-L66】
|
|
||||||
- `app/pages/constructeurs.vue` L78-L81. 【F:app/pages/constructeurs.vue†L78-L81】
|
|
||||||
- `app/components/sites/SiteCreateModal.vue` L6-L17. 【F:app/components/sites/SiteCreateModal.vue†L5-L17】
|
|
||||||
- `app/components/sites/SiteEditModal.vue` L8-L20. 【F:app/components/sites/SiteEditModal.vue†L8-L20】
|
|
||||||
- `app/components/TypeEditBaseInfoSection.vue` L8-L48. 【F:app/components/TypeEditBaseInfoSection.vue†L8-L48】
|
|
||||||
- **Extraction proposée**: `app/components/form/FieldText.vue` avec props `type`, `label`, `required`, `maxlength`, `placeholder`, support `modelModifiers`.
|
|
||||||
- **Plan**: Introduire le composant, migrer progressivement les champs texte, ajouter un paramètre pour afficher l’étoile obligatoire.
|
|
||||||
|
|
||||||
## MDUP-010 · Score 72 · Type ui-fragment
|
|
||||||
- **Pattern**: Bouton primaire avec indicateur de chargement inline (`<span class="loading loading-spinner loading-xs mr-2">`).
|
|
||||||
- **Occurrences**:
|
|
||||||
- `app/components/ConstructeurSelect.vue` L82-L84. 【F:app/components/ConstructeurSelect.vue†L82-L84】
|
|
||||||
- `app/pages/constructeurs.vue` L88-L89. 【F:app/pages/constructeurs.vue†L88-L90】
|
|
||||||
- `app/components/sites/SiteEditModal.vue` L86-L88. 【F:app/components/sites/SiteEditModal.vue†L86-L88】
|
|
||||||
- **Extraction proposée**: `app/components/common/LoadingButton.vue` gérant les variantes (`primary`, `outline`), le spinner et le label via slots.
|
|
||||||
- **Plan**: Remplacer les boutons concernés par le composant, propager `loading` & `disabled` automatiquement.
|
|
||||||
|
|
||||||
## Annexes
|
|
||||||
- **Validations centralisées**: `app/shared/validation/phone.ts` & `app/shared/validation/email.ts` fournissent désormais des schémas communs. 【F:app/shared/validation/phone.ts†L1-L36】【F:app/shared/validation/email.ts†L1-L34】
|
|
||||||
- **Formatters communs**: `app/utils/formatters/phone.ts` et `app/utils/formatters/email.ts` proposent les helpers associés. 【F:app/utils/formatters/phone.ts†L1-L67】【F:app/utils/formatters/email.ts†L1-L37】
|
|
||||||
229
migration.md
229
migration.md
@@ -1,229 +0,0 @@
|
|||||||
# Plan de migration — Réduction de code frontend
|
|
||||||
|
|
||||||
> Objectif : réduire ~5 700 LOC sans modifier le fonctionnel.
|
|
||||||
> Branche : à partir de `refacto/F1-decoupage-mega-composants`
|
|
||||||
> Statut global : **EN ATTENTE**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Pages catalogue (3 pages, ~1 200 LOC → ~350 LOC)
|
|
||||||
|
|
||||||
### M1.1 · Composant générique `CatalogPage.vue`
|
|
||||||
|
|
||||||
- **Motif** : `component-catalog.vue` (348 LOC), `pieces-catalog.vue` (463 LOC) et `product-catalog.vue` (408 LOC) partagent 95 % de structure (recherche, tri, pagination, tableau, suppression, états vides/loading).
|
|
||||||
- **Différences isolées** : colonnes du tableau, garde de suppression, extraction fournisseur.
|
|
||||||
- **Plan** :
|
|
||||||
1. Créer `app/components/common/CatalogPage.vue` acceptant :
|
|
||||||
- `columns: ColumnDef[]` (nom, clé, slot optionnel)
|
|
||||||
- `fetchFn: (params) => Promise<PaginatedResult>`
|
|
||||||
- `deleteFn: (id) => Promise<Result>`
|
|
||||||
- `deleteGuard?: (item) => string | null` (message bloquant ou null)
|
|
||||||
- `entityLabel: string`, `createRoute: string`
|
|
||||||
- Slots nommés pour colonnes custom (`#col-supplier`, etc.)
|
|
||||||
2. Extraire `supplierDisplayUtils.ts` (pattern `MAX_VISIBLE_SUPPLIERS` dupliqué dans pieces-catalog et product-catalog).
|
|
||||||
3. Réduire chaque page catalogue à ~80 LOC (config + slots custom).
|
|
||||||
- **Gain estimé** : ~850 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Composables CRUD génériques (~1 170 LOC → ~400 LOC)
|
|
||||||
|
|
||||||
### M2.1 · Factory `useEntityCRUD<T>(config)`
|
|
||||||
|
|
||||||
- **Motif** : `usePieces.ts` (240), `useProducts.ts` (305), `useComposants.ts` (231), `useSites.ts` (124) suivent le même pattern CRUD : refs `loading/loaded/error`, `loadItems()` paginé, `create/update/delete` avec mise à jour cache + toast.
|
|
||||||
- **Différences isolées** : endpoint, normaliseur, enrichissement constructeurs, champs de tri.
|
|
||||||
- **Plan** :
|
|
||||||
1. Créer `app/composables/useEntityCRUD.ts` :
|
|
||||||
```ts
|
|
||||||
interface EntityCRUDConfig {
|
|
||||||
endpoint: string
|
|
||||||
label: string
|
|
||||||
normalizer?: (item: any) => any
|
|
||||||
enricher?: (item: any) => Promise<any>
|
|
||||||
defaultSort?: { field: string; dir: 'asc' | 'desc' }
|
|
||||||
}
|
|
||||||
export function useEntityCRUD(config: EntityCRUDConfig)
|
|
||||||
```
|
|
||||||
2. Extraire `extractTotal()` dans `apiHelpers.ts` (dupliqué 3×, ~10 LOC chacun).
|
|
||||||
3. Extraire `buildPaginatedQuery(options)` dans `apiHelpers.ts` (dupliqué 3×, ~15 LOC chacun).
|
|
||||||
4. Extraire pattern `withResolvedConstructeurs()` dans `useEntityEnricher.ts` (dupliqué 3× dans pieces/products/composants, ~50 LOC chacun).
|
|
||||||
5. Réduire chaque composable à un appel de factory + méthodes spécifiques.
|
|
||||||
6. Garder `useMachines.ts` séparé (méthodes spéciales : `reconfigureSkeleton`, `createMachineFromType`).
|
|
||||||
- **Gain estimé** : ~770 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M2.2 · Helper `withLoadingState()`
|
|
||||||
|
|
||||||
- **Motif** : pattern `loading.value = true; try { ... } finally { loading.value = false }` répété 10+ fois dans les composables CRUD.
|
|
||||||
- **Plan** : créer `app/composables/useLoadingHelper.ts` exportant :
|
|
||||||
```ts
|
|
||||||
async function withLoadingState<T>(loading: Ref<boolean>, fn: () => Promise<T>): Promise<T>
|
|
||||||
```
|
|
||||||
- **Gain estimé** : ~100 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M2.3 · Fusion `usePersistedValue` + `usePersistedSort`
|
|
||||||
|
|
||||||
- **Motif** : même pattern `useCookie()` + `watch()` + JSON parse/stringify.
|
|
||||||
- **Plan** : fusionner en `usePersistedState<T>(key, fallback, prefix?)`.
|
|
||||||
- **Gain estimé** : ~30 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — Pages edit entités (~2 750 LOC → ~1 200 LOC)
|
|
||||||
|
|
||||||
### M3.1 · Composant `HistorySection.vue`
|
|
||||||
|
|
||||||
- **Motif** : bloc historique identique (loading/error/empty + itération entries) dans `component/[id]/edit.vue` (L437-503), `pieces/[id]/edit.vue` (L384-450), `product/[id]/edit.vue` (L304-370) — ~67 LOC × 3.
|
|
||||||
- **Plan** : créer `app/components/common/HistorySection.vue` avec props `entries`, `loading`, `error`.
|
|
||||||
- **Gain estimé** : ~130 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M3.2 · Composant `DocumentsSection.vue`
|
|
||||||
|
|
||||||
- **Motif** : bloc document (upload, liste, preview, download, delete) dupliqué dans les 3 pages edit + `MachineDocumentsCard.vue` + `SiteEditModal.vue` — ~70-180 LOC × 5.
|
|
||||||
- **Plan** : créer `app/components/common/DocumentsSection.vue` avec props `documents`, `entityId`, `entityType` et events `upload`, `delete`, `preview`.
|
|
||||||
- **Gain estimé** : ~400 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M3.3 · Composable `useEntityEditForm(config)`
|
|
||||||
|
|
||||||
- **Motif** : les 3 pages edit partagent : chargement entité + types + constructeurs, gestion champs custom, normalisation payload, sauvegarde, gestion erreur.
|
|
||||||
- **Différences** : component a structure display, piece a product selection, product est plus simple.
|
|
||||||
- **Plan** :
|
|
||||||
1. Créer `app/composables/useEntityEditForm.ts` gérant le cycle de vie commun (load, save, custom fields sync).
|
|
||||||
2. Chaque page edit ne garde que ses spécificités.
|
|
||||||
- **Gain estimé** : ~500 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M3.4 · Réutilisation `customFieldFormUtils.ts` dans `component/create.vue`
|
|
||||||
|
|
||||||
- **Motif** : `component/create.vue` (1 266 LOC) réimplémente `resolveFieldName`, `resolveFieldType`, `resolveDefaultValue` déjà dans `customFieldFormUtils.ts`. Aussi 3 fonctions `resolveXxxLabel` quasi-identiques (~18 LOC × 3).
|
|
||||||
- **Plan** :
|
|
||||||
1. Remplacer les fonctions locales par les imports de `customFieldFormUtils.ts`.
|
|
||||||
2. Créer `resolveTypeLabel(entity, typeField, labelField, fallback)` générique.
|
|
||||||
- **Gain estimé** : ~120 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — Décomposition `useMachineDetailData.ts` (1 410 LOC → ~500 LOC)
|
|
||||||
|
|
||||||
### M4.1 · Extraire `useMachineDocuments.ts`
|
|
||||||
|
|
||||||
- **Motif** : gestion documents (upload, delete, preview, refresh) = ~200 LOC dans le composable monolithique.
|
|
||||||
- **Gain estimé** : ~150 LOC (après factorisation avec DocumentsSection)
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M4.2 · Extraire `useMachineConstructeurs.ts`
|
|
||||||
|
|
||||||
- **Motif** : résolution constructeurs avec chaînes de fallback 4 niveaux, `uniqueConstructeurIds`, `resolveConstructeurs` = ~80 LOC.
|
|
||||||
- **Gain estimé** : ~60 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M4.3 · Fusionner `transformCustomFields` et `transformComponentCustomFields`
|
|
||||||
|
|
||||||
- **Motif** : L303-405 et L407-514 — logique quasi-identique de transformation des champs custom, seule la source (machine vs composant) diffère.
|
|
||||||
- **Plan** : créer `transformEntityCustomFields(entity, fieldSource, config)` paramétrable.
|
|
||||||
- **Gain estimé** : ~100 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M4.4 · Extraire groupement de requirements
|
|
||||||
|
|
||||||
- **Motif** : `componentRequirementGroups`, `pieceRequirementGroups` = computed complexes avec construction de maps et filtres répétitifs.
|
|
||||||
- **Gain estimé** : ~80 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 — `StructureNodeEditor.vue` (1 167 LOC → ~600 LOC)
|
|
||||||
|
|
||||||
### M5.1 · Composable `useDragDrop.ts`
|
|
||||||
|
|
||||||
- **Motif** : 4 handlers drag-drop quasi-identiques (custom fields, pièces, produits, sous-composants) avec chacun `draggingIndex`, `dropTargetIndex`, `reorderClass()`, `handleDragStart/Over/End`.
|
|
||||||
- **Plan** : créer `useDragDrop<T>(items: Ref<T[]>)` retournant `{ dragging, target, reorderClass, onDragStart, onDragOver, onDragEnd, onDrop }`.
|
|
||||||
- **Gain estimé** : ~350 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M5.2 · Extraire validation noeud
|
|
||||||
|
|
||||||
- **Motif** : `isAssignmentNodeComplete` + logique de validation dispersée.
|
|
||||||
- **Plan** : déplacer vers `app/shared/utils/structureValidation.ts`.
|
|
||||||
- **Gain estimé** : ~40 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6 — Micro-duplications restantes (du `micro-dup-report.md`)
|
|
||||||
|
|
||||||
### M6.1 · `useControlledModel.ts` (MDUP-004)
|
|
||||||
|
|
||||||
- **Motif** : `computed({ get, set })` pour transiter `v-model` entre props et emits — dupliqué dans 6 composants.
|
|
||||||
- **Gain estimé** : ~60 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M6.2 · `ModalShell.vue` (MDUP-008) + `ModalActions.vue` (MDUP-007)
|
|
||||||
|
|
||||||
- **Motif** : squelette de modale DaisyUI (`.modal` + `.modal-box` + titre + footer) dupliqué dans 4+ composants. Pieds de modale « Annuler + Primaire + spinner » dupliqués 5×.
|
|
||||||
- **Gain estimé** : ~120 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M6.3 · `LoadingButton.vue` (MDUP-010) + `FieldText.vue` (MDUP-009)
|
|
||||||
|
|
||||||
- **Motif** : bouton primaire avec spinner (3 occurrences), champ texte simple label+input (5 occurrences).
|
|
||||||
- **Gain estimé** : ~80 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
### M6.4 · `createRequirementDefaults` + `useEnsureOptionsLoaded` (MDUP-005, MDUP-006)
|
|
||||||
|
|
||||||
- **Motif** : factory de requirement par défaut + `onMounted` identiques dans les sections composant/pièce.
|
|
||||||
- **Gain estimé** : ~30 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7 — Consolidation custom fields (~1 150 LOC → ~800 LOC)
|
|
||||||
|
|
||||||
### M7.1 · Fusionner logique de résolution dans `customFieldUtils.ts`
|
|
||||||
|
|
||||||
- **Motif** : `customFieldUtils.ts` (440), `entityCustomFieldLogic.ts` (349), `customFieldFormUtils.ts` (367) contiennent des fonctions de résolution de champs qui se chevauchent (`resolveFieldId`, `resolveFieldName`, génération de clé, déduplication).
|
|
||||||
- **Plan** : consolider les fonctions dupliquées en gardant la séparation thématique (utils / form / entity) mais en partageant les primitives.
|
|
||||||
- **Gain estimé** : ~150 LOC
|
|
||||||
- **Statut** : `[ ]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Récapitulatif
|
|
||||||
|
|
||||||
| Phase | Cible | LOC avant | Gain estimé | Priorité |
|
|
||||||
|-------|-------|-----------|-------------|----------|
|
|
||||||
| **P1** | Pages catalogue | ~1 220 | ~850 | Haute |
|
|
||||||
| **P2** | Composables CRUD | ~1 170 | ~900 | Haute |
|
|
||||||
| **P3** | Pages edit entités | ~2 750 | ~1 150 | Haute |
|
|
||||||
| **P4** | useMachineDetailData | ~1 410 | ~390 | Moyenne |
|
|
||||||
| **P5** | StructureNodeEditor | ~1 167 | ~390 | Moyenne |
|
|
||||||
| **P6** | Micro-duplications | ~400 | ~290 | Basse |
|
|
||||||
| **P7** | Custom fields utils | ~1 150 | ~150 | Basse |
|
|
||||||
| | **Total** | | **~4 120 LOC** | |
|
|
||||||
|
|
||||||
### Ordre recommandé
|
|
||||||
|
|
||||||
1. **P2** (CRUD generics) — fondation pour P1 et P3
|
|
||||||
2. **P1** (catalogues) — dépend de P2 pour les fetch functions
|
|
||||||
3. **P3** (pages edit) — plus gros gain absolu, dépend partiellement de P2
|
|
||||||
4. **P5** (drag-drop) — indépendant, quick win
|
|
||||||
5. **P4** (machine detail) — complexe mais fort impact
|
|
||||||
6. **P6** (micro-dup) — petits gains, faible risque
|
|
||||||
7. **P7** (custom fields) — délicat, à faire en dernier
|
|
||||||
|
|
||||||
### Vérification après chaque phase
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd Inventory_frontend
|
|
||||||
npx nuxi typecheck # 0 erreurs
|
|
||||||
npm run lint:fix # 0 erreurs
|
|
||||||
npm run build # succès
|
|
||||||
npx vitest run # 54+ tests pass
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user