Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d03b480d | ||
|
|
6f1bac381d | ||
|
|
89dc2e93b8 | ||
|
|
8f5f25b3e7 | ||
|
|
c06c852493 | ||
|
|
41f5319b67 | ||
|
|
c7fd8328d6 | ||
|
|
55e2a4fafe | ||
|
|
e88ed5b8f2 | ||
|
|
546cc37a09 | ||
|
|
efd0fbe407 | ||
|
|
607f84fc3d | ||
|
|
a98ab8c275 | ||
|
|
e22463874c | ||
|
|
256039264e | ||
|
|
e459da7c20 | ||
|
|
e84b5cf674 | ||
|
|
cc70fe2b29 | ||
|
|
6bed715b7f | ||
|
|
dbf8c8856b | ||
|
|
62127a33f5 | ||
|
|
2fffe4a368 | ||
|
|
c9054e5b4d | ||
|
|
5cab15422d | ||
|
|
439db8117a | ||
|
|
675820532c | ||
|
|
4edfc55c37 | ||
|
|
480aaa24b2 | ||
|
|
185af65519 | ||
|
|
8fecf67a7f |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
178
README.md
178
README.md
@@ -1,75 +1,155 @@
|
|||||||
# Nuxt Minimal Starter
|
# Inventory Frontend
|
||||||
|
|
||||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
Interface web de gestion d'inventaire industriel pour **Malio**. Application SPA complète permettant la gestion du parc machines, des pièces, composants, produits, fournisseurs et documents associés.
|
||||||
|
|
||||||
## Setup
|
## Stack technique
|
||||||
|
|
||||||
Make sure to install dependencies:
|
| Technologie | Version | Rôle |
|
||||||
|
|-------------|---------|------|
|
||||||
|
| [Nuxt](https://nuxt.com) | 4 | Framework (SPA, SSR désactivé) |
|
||||||
|
| [Vue 3](https://vuejs.org) | 3.5 | Composition API + `<script setup>` |
|
||||||
|
| [TypeScript](https://www.typescriptlang.org) | 5.7 | Typage strict sur l'ensemble du projet |
|
||||||
|
| [TailwindCSS](https://tailwindcss.com) | 4 | Utility-first CSS |
|
||||||
|
| [DaisyUI](https://daisyui.com) | 5 | Composants UI (alertes, modales, badges, etc.) |
|
||||||
|
| [Lucide](https://lucide.dev) | via unplugin-icons | Icônes SVG |
|
||||||
|
| [Vitest](https://vitest.dev) | 4 | Tests unitaires |
|
||||||
|
| [Playwright](https://playwright.dev) | 1.58 | Tests E2E |
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- **Node.js** >= 20
|
||||||
|
- **npm**
|
||||||
|
- **Backend Symfony** démarré avec l'API sur `http://localhost:8081/api`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Server
|
## Développement
|
||||||
|
|
||||||
Start the development server on `http://localhost:3000`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn dev
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production
|
L'application est accessible sur **http://localhost:3001**.
|
||||||
|
|
||||||
Build the application for production:
|
## Commandes disponibles
|
||||||
|
|
||||||
```bash
|
| Commande | Description |
|
||||||
# npm
|
|----------|-------------|
|
||||||
npm run build
|
| `npm run dev` | Serveur de développement avec HMR |
|
||||||
|
| `npm run build` | Build de production |
|
||||||
|
| `npm run lint:fix` | Correction automatique ESLint |
|
||||||
|
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
|
||||||
|
| `npm run test` | Tests unitaires Vitest |
|
||||||
|
| `npm run test:watch` | Tests unitaires en mode watch |
|
||||||
|
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
|
||||||
|
|
||||||
# pnpm
|
## Fonctionnalités
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# yarn
|
### Gestion du parc
|
||||||
yarn build
|
|
||||||
|
|
||||||
# bun
|
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
|
||||||
bun run build
|
- **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
|
||||||
|
- **Sites** : gestion multi-sites avec coordonnées de contact
|
||||||
|
|
||||||
|
### Catalogues
|
||||||
|
|
||||||
|
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
|
||||||
|
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
|
||||||
|
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
|
||||||
|
|
||||||
|
### Documents et traçabilité
|
||||||
|
|
||||||
|
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
|
||||||
|
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
|
||||||
|
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
|
||||||
|
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
|
||||||
|
- **Profils** : gestion des utilisateurs et attribution des rôles
|
||||||
|
- **Notifications** : badge compteur de commentaires ouverts avec polling
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── pages/ # 36 pages (file-based routing)
|
||||||
|
├── components/ # 57 composants Vue (auto-imported par Nuxt)
|
||||||
|
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
|
||||||
|
│ ├── form/ # Champs de formulaire (email, téléphone)
|
||||||
|
│ ├── layout/ # Navbar principale
|
||||||
|
│ ├── machine/ # Vue détail et création de machines
|
||||||
|
│ │ └── create/ # Wizard de création machine
|
||||||
|
│ ├── model-types/ # Gestion des types/catégories
|
||||||
|
│ └── sites/ # Modales site (création, édition)
|
||||||
|
├── composables/ # 45 composables (logique métier)
|
||||||
|
├── shared/ # Types, utilitaires, validation
|
||||||
|
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
|
||||||
|
│ ├── validation/ # Validation email, téléphone
|
||||||
|
│ └── model/ # Définitions de structures
|
||||||
|
├── services/ # Service layer (wrappers API spécialisés)
|
||||||
|
├── middleware/ # Middleware d'auth global (session cookie)
|
||||||
|
└── utils/ # Formatage dates, montants, événements
|
||||||
```
|
```
|
||||||
|
|
||||||
Locally preview production build:
|
## Conventions de code
|
||||||
|
|
||||||
```bash
|
### Composables
|
||||||
# npm
|
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# pnpm
|
Pattern avec injection de dépendances explicite :
|
||||||
pnpm preview
|
|
||||||
|
|
||||||
# yarn
|
```typescript
|
||||||
yarn preview
|
interface Deps {
|
||||||
|
machineId: Ref<string>
|
||||||
|
onSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
# bun
|
export function useMachineDetail(deps: Deps) {
|
||||||
bun run preview
|
// ...
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
### Communication entre composants
|
||||||
|
|
||||||
|
**Props + Events uniquement** — pas de `provide/inject` dans le projet.
|
||||||
|
|
||||||
|
### Appels API
|
||||||
|
|
||||||
|
Le composable `useApi.ts` centralise tous les appels HTTP :
|
||||||
|
- Cookies de session inclus automatiquement (`credentials: 'include'`)
|
||||||
|
- `application/ld+json` pour POST/PUT
|
||||||
|
- `application/merge-patch+json` pour PATCH
|
||||||
|
- Gestion d'erreurs centralisée avec traduction des messages backend en français
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
Classes DaisyUI standard :
|
||||||
|
- Input : `input input-bordered input-sm md:input-md`
|
||||||
|
- Select : `select select-bordered select-sm md:select-md`
|
||||||
|
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
L'application utilise une **authentification par session (cookies)**, pas de JWT.
|
||||||
|
|
||||||
|
Le middleware global `profile.global.ts` vérifie la session à chaque navigation :
|
||||||
|
- Utilisateur non connecté → redirection vers `/profiles`
|
||||||
|
- Route `/admin/*` → accès restreint à `ROLE_ADMIN`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **13 tests unitaires** (Vitest + happy-dom) couvrant composables, utils et composants
|
||||||
|
- **3 specs E2E** (Playwright + Chrome) avec setup d'authentification
|
||||||
|
|
||||||
|
## Submodule Git
|
||||||
|
|
||||||
|
Ce repo est un **submodule** du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory).
|
||||||
|
|
||||||
|
Workflow de commit :
|
||||||
|
1. Commiter dans ce repo (frontend) en premier
|
||||||
|
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
|
||||||
|
3. Pousser les deux repos
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
|
|
||||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||||
<div class="items-center grid-flow-col">
|
<div class="items-center grid-flow-col">
|
||||||
<p>@Malio 2025 · v{{ appVersion }}</p>
|
<p>
|
||||||
|
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
212
app/components/CommentSection.vue
Normal file
212
app/components/CommentSection.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<IconLucideMessageSquare class="w-5 h-5" />
|
||||||
|
Commentaires
|
||||||
|
<span v-if="openComments.length" class="badge badge-warning badge-sm">
|
||||||
|
{{ openComments.length }}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
v-if="showResolved && resolvedComments.length"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="showResolvedList = !showResolvedList"
|
||||||
|
>
|
||||||
|
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire d'ajout -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="newContent"
|
||||||
|
class="textarea textarea-bordered flex-1 text-sm"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Ajouter un commentaire..."
|
||||||
|
:disabled="submitting"
|
||||||
|
@keydown.ctrl.enter="handleSubmit"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm self-end"
|
||||||
|
:disabled="!newContent.trim() || submitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
<IconLucideSend v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des commentaires ouverts -->
|
||||||
|
<div v-if="loadingComments" class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner loading-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
|
||||||
|
Aucun commentaire ouvert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="comment in openComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200 rounded-lg p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||||
|
<span>
|
||||||
|
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="canEdit" class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-xs gap-1"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleResolve(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideCheck class="w-3 h-3" />
|
||||||
|
Résoudre
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleDelete(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash2 class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commentaires résolus -->
|
||||||
|
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
|
||||||
|
<div class="divider text-xs text-base-content/40">
|
||||||
|
Résolus
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="comment in resolvedComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||||
|
>
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||||
|
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||||
|
<span v-if="comment.resolvedByName">
|
||||||
|
Résolu par {{ comment.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useComments, type Comment } from '~/composables/useComments'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||||
|
import IconLucideSend from '~icons/lucide/send'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string
|
||||||
|
showResolved?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
} = useComments()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const newContent = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const loadingComments = ref(false)
|
||||||
|
const showResolvedList = ref(false)
|
||||||
|
|
||||||
|
const openComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'open'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'resolved'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatCommentDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loadingComments.value = true
|
||||||
|
const [openResult, resolvedResult] = await Promise.all([
|
||||||
|
fetchComments(props.entityType, props.entityId, 'open'),
|
||||||
|
props.showResolved
|
||||||
|
? fetchComments(props.entityType, props.entityId, 'resolved')
|
||||||
|
: Promise.resolve({ success: true, data: [] as Comment[] }),
|
||||||
|
])
|
||||||
|
const open = openResult.success ? (openResult.data ?? []) : []
|
||||||
|
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
|
||||||
|
comments.value = [...open, ...resolved]
|
||||||
|
loadingComments.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const content = newContent.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
submitting.value = true
|
||||||
|
const result = await createComment(
|
||||||
|
props.entityType,
|
||||||
|
props.entityId,
|
||||||
|
content,
|
||||||
|
props.entityName,
|
||||||
|
)
|
||||||
|
submitting.value = false
|
||||||
|
if (result.success) {
|
||||||
|
newContent.value = ''
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (commentId: string) => {
|
||||||
|
const result = await resolveComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (commentId: string) => {
|
||||||
|
const result = await deleteComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.entityId) {
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="componentDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -25,12 +26,6 @@
|
|||||||
{{ component.name }}
|
{{ component.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<span
|
|
||||||
v-if="component.skeletonOnly"
|
|
||||||
class="badge badge-warning badge-sm"
|
|
||||||
>
|
|
||||||
Défini dans le catalogue
|
|
||||||
</span>
|
|
||||||
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
||||||
<template v-if="componentConstructeursDisplay.length">
|
<template v-if="componentConstructeursDisplay.length">
|
||||||
<span
|
<span
|
||||||
@@ -48,12 +43,6 @@
|
|||||||
>
|
>
|
||||||
Produit : {{ displayProductName }}
|
Produit : {{ displayProductName }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
v-if="component.typeMachineComponentRequirement"
|
|
||||||
class="badge badge-outline badge-sm"
|
|
||||||
>
|
|
||||||
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,8 +163,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 +321,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}`"
|
||||||
>
|
>
|
||||||
@@ -400,8 +389,7 @@
|
|||||||
v-for="piece in component.pieces"
|
v-for="piece in component.pieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
:piece="piece"
|
:piece="piece"
|
||||||
:is-edit-mode="isEditMode && !piece.skeletonOnly"
|
:is-edit-mode="isEditMode"
|
||||||
|
|
||||||
@update="updatePiece"
|
@update="updatePiece"
|
||||||
@edit="editPiece"
|
@edit="editPiece"
|
||||||
@custom-field-update="updatePieceCustomField"
|
@custom-field-update="updatePieceCustomField"
|
||||||
@@ -419,7 +407,7 @@
|
|||||||
v-for="subComponent in childComponents"
|
v-for="subComponent in childComponents"
|
||||||
:key="subComponent.id"
|
:key="subComponent.id"
|
||||||
:component="subComponent"
|
:component="subComponent"
|
||||||
:is-edit-mode="isEditMode && !subComponent.skeletonOnly"
|
:is-edit-mode="isEditMode"
|
||||||
:collapse-all="collapseAll"
|
:collapse-all="collapseAll"
|
||||||
:toggle-token="toggleToken"
|
:toggle-token="toggleToken"
|
||||||
@update="$emit('update', $event)"
|
@update="$emit('update', $event)"
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="openDropdown"
|
v-if="openDropdown"
|
||||||
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="options.length === 0"
|
v-if="filteredOptions.length === 0"
|
||||||
class="px-3 py-2 text-xs text-gray-500"
|
class="px-3 py-2 text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
Aucun fournisseur trouvé
|
Aucun fournisseur trouvé
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="option in options"
|
v-for="option in filteredOptions"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||||
@@ -164,8 +164,7 @@ const openCreateModal = ref(false)
|
|||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const options = ref<ConstructeurSummary[]>([])
|
const options = ref<ConstructeurSummary[]>([])
|
||||||
const selectedIds = ref<string[]>([])
|
const selectedIds = ref<string[]>([])
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let lastSearchTerm = ''
|
|
||||||
|
|
||||||
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
const seen = new Map<string, ConstructeurSummary>()
|
const seen = new Map<string, ConstructeurSummary>()
|
||||||
@@ -182,32 +181,22 @@ const normalizedInitialOptions = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
const normalized = uniqueOptions([
|
options.value = uniqueOptions([
|
||||||
...normalizedInitialOptions.value,
|
...normalizedInitialOptions.value,
|
||||||
...items,
|
...items,
|
||||||
])
|
])
|
||||||
const limited = normalized.slice(0, 10)
|
|
||||||
|
|
||||||
selectedIds.value.forEach((id) => {
|
|
||||||
if (!limited.some((item) => item.id === id)) {
|
|
||||||
const match =
|
|
||||||
normalized.find((item) => item.id === id) ||
|
|
||||||
constructeurs.value.find((item) => item.id === id)
|
|
||||||
if (match) {
|
|
||||||
if (limited.length >= 10) {
|
|
||||||
limited.pop()
|
|
||||||
}
|
|
||||||
limited.unshift(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
options.value = uniqueOptions([
|
|
||||||
...normalizedInitialOptions.value,
|
|
||||||
...limited,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
const term = searchTerm.value.trim().toLowerCase()
|
||||||
|
if (!term) return options.value
|
||||||
|
return options.value.filter((option) =>
|
||||||
|
(option.name ?? '').toLowerCase().includes(term)
|
||||||
|
|| (option.email && option.email.toLowerCase().includes(term))
|
||||||
|
|| (option.phone && option.phone.toLowerCase().includes(term))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const createForm = ref({
|
const createForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -257,46 +246,20 @@ const extractDataArray = (data: unknown): ConstructeurSummary[] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ensureOptionsLoaded = async (force = false) => {
|
const ensureOptionsLoaded = async (force = false) => {
|
||||||
if (!force && !searchTerm.value && constructeurs.value.length) {
|
if (!force && constructeurs.value.length) {
|
||||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!force && searchTerm.value === lastSearchTerm && options.value.length) {
|
const result = await searchConstructeurs('')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.value.length && !force) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await searchConstructeurs(searchTerm.value)
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
applyOptions(extractDataArray(result.data))
|
applyOptions(extractDataArray(result.data))
|
||||||
lastSearchTerm = searchTerm.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSearch = () => {
|
const onSearch = () => {
|
||||||
openDropdown.value = true
|
openDropdown.value = true
|
||||||
if (searchTimeout) {
|
ensureOptionsLoaded()
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(async () => {
|
|
||||||
if (!searchTerm.value && constructeurs.value.length) {
|
|
||||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
|
||||||
lastSearchTerm = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (searchTerm.value === lastSearchTerm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await searchConstructeurs(searchTerm.value)
|
|
||||||
if (result.success) {
|
|
||||||
applyOptions(extractDataArray(result.data))
|
|
||||||
lastSearchTerm = searchTerm.value
|
|
||||||
}
|
|
||||||
}, 250)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleOption = (option: ConstructeurSummary) => {
|
const toggleOption = (option: ConstructeurSummary) => {
|
||||||
@@ -319,9 +282,19 @@ const closeCreateModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
const trimmedName = createForm.value.name.trim()
|
||||||
|
const duplicate = options.value.find(
|
||||||
|
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
emitSelection([...selectedIds.value, duplicate.id])
|
||||||
|
closeCreateModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
creating.value = true
|
creating.value = true
|
||||||
const payload: { name: string; email?: string; phone?: string } = {
|
const payload: { name: string; email?: string; phone?: string } = {
|
||||||
name: createForm.value.name,
|
name: trimmedName,
|
||||||
}
|
}
|
||||||
if (createForm.value.email) {
|
if (createForm.value.email) {
|
||||||
payload.email = createForm.value.email
|
payload.email = createForm.value.email
|
||||||
@@ -383,9 +356,6 @@ watch(
|
|||||||
constructeurs,
|
constructeurs,
|
||||||
(list) => {
|
(list) => {
|
||||||
applyOptions((list as ConstructeurSummary[]) || [])
|
applyOptions((list as ConstructeurSummary[]) || [])
|
||||||
if (!searchTerm.value) {
|
|
||||||
lastSearchTerm = ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -405,9 +375,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('click', clickHandler)
|
window.removeEventListener('click', clickHandler)
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -55,16 +55,16 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Champ de type BOOLEAN -->
|
<!-- Champ de type BOOLEAN -->
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="fieldValues[field.id]"
|
v-model="fieldValues[field.id]"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm"
|
||||||
:checked="fieldValues[field.id] === 'true'"
|
:checked="fieldValues[field.id] === 'true'"
|
||||||
@change="updateCustomFieldValue(field.id)"
|
@change="updateCustomFieldValue(field.id)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<!-- Champ de type DATE -->
|
<!-- Champ de type DATE -->
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -1,63 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border border-gray-200 rounded-lg p-4">
|
<div class="space-y-4">
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="pieceDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||||
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" />
|
<div class="flex items-start gap-3 w-full">
|
||||||
<input
|
<button
|
||||||
v-if="isEditMode"
|
type="button"
|
||||||
:id="`piece-name-${piece.id}`"
|
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
||||||
v-model="pieceData.name"
|
:class="{ 'rotate-90': !isCollapsed }"
|
||||||
type="text"
|
:aria-expanded="!isCollapsed"
|
||||||
class="font-semibold text-lg input input-sm input-bordered"
|
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
||||||
@blur="updatePiece"
|
@click="toggleCollapse"
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
|
|
||||||
>
|
>
|
||||||
{{ pieceData.name }}
|
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
{{ pieceData.name }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
||||||
|
Rattachée à {{ piece.parentComponentName }}
|
||||||
|
</span>
|
||||||
|
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
||||||
|
<template v-if="pieceConstructeursDisplay.length">
|
||||||
|
<span
|
||||||
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="badge badge-outline badge-sm"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
||||||
|
<span
|
||||||
|
v-if="displayProductName"
|
||||||
|
class="badge badge-info badge-sm"
|
||||||
|
>
|
||||||
|
Produit : {{ displayProductName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<span
|
|
||||||
v-if="piece.skeletonOnly"
|
|
||||||
class="badge badge-warning badge-sm"
|
|
||||||
>
|
|
||||||
Défini dans le catalogue
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="piece.typeMachinePieceRequirement"
|
|
||||||
class="badge badge-outline badge-sm"
|
|
||||||
>
|
|
||||||
Groupe :
|
|
||||||
{{
|
|
||||||
piece.typeMachinePieceRequirement.label ||
|
|
||||||
piece.typeMachinePieceRequirement.typePiece?.name ||
|
|
||||||
"Non défini"
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="piece.parentComponentName"
|
|
||||||
class="badge badge-ghost badge-sm"
|
|
||||||
>
|
|
||||||
Rattachée à {{ piece.parentComponentName }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="displayProductName"
|
|
||||||
class="badge badge-info badge-sm"
|
|
||||||
>
|
|
||||||
Produit : {{ displayProductName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2 text-sm">
|
<div v-show="!isCollapsed" class="space-y-4">
|
||||||
|
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg">
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">Référence:</span>
|
<span class="font-medium">Référence:</span>
|
||||||
<input
|
<input
|
||||||
@@ -184,8 +180,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}`"
|
||||||
>
|
>
|
||||||
@@ -235,6 +231,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés de la pièce -->
|
<!-- Champs personnalisés de la pièce -->
|
||||||
<div
|
<div
|
||||||
@@ -413,8 +410,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}`"
|
||||||
>
|
>
|
||||||
@@ -479,16 +476,17 @@
|
|||||||
Aucun document lié à cette pièce.
|
Aucun document lié à cette pièce.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, onMounted, watch, computed } from 'vue'
|
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
||||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||||
import ProductSelect from '~/components/ProductSelect.vue'
|
import ProductSelect from '~/components/ProductSelect.vue'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucidePackage from '~icons/lucide/package'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
@@ -522,6 +520,8 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
piece: { type: Object, required: true },
|
piece: { type: Object, required: true },
|
||||||
isEditMode: { type: Boolean, default: false },
|
isEditMode: { type: Boolean, default: false },
|
||||||
|
collapseAll: { type: Boolean, default: true },
|
||||||
|
toggleToken: { type: Number, default: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
|
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
|
||||||
@@ -575,6 +575,23 @@ const {
|
|||||||
updateCustomField,
|
updateCustomField,
|
||||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||||
|
|
||||||
|
// --- Collapse state ---
|
||||||
|
const isCollapsed = ref(true)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.toggleToken,
|
||||||
|
() => {
|
||||||
|
isCollapsed.value = props.collapseAll
|
||||||
|
if (!isCollapsed.value) refreshDocuments()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleCollapse = () => {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
if (!isCollapsed.value) refreshDocuments()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Constructeurs ---
|
// --- Constructeurs ---
|
||||||
const { constructeurs } = useConstructeurs()
|
const { constructeurs } = useConstructeurs()
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<IconLucideX
|
<IconLucideCircleX
|
||||||
v-else-if="toast.type === 'error'"
|
v-else-if="toast.type === 'error'"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x'
|
||||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||||
import IconLucideInfo from '~icons/lucide/info'
|
import IconLucideInfo from '~icons/lucide/info'
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card-actions justify-end">
|
|
||||||
<button type="button" class="btn btn-outline" @click="$emit('reset')">
|
|
||||||
Réinitialiser
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
||||||
<IconLucideRefreshCw
|
|
||||||
v-if="saving"
|
|
||||||
class="w-5 h-5 mr-2 animate-spin"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<IconLucideCheck v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
|
||||||
{{ saving ? 'Sauvegarde...' : 'Sauvegarder les modifications' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import IconLucideRefreshCw from '~icons/lucide/refresh-cw'
|
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
saving: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['reset'])
|
|
||||||
</script>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg mb-4">
|
|
||||||
Informations de base
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du type</span>
|
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="nameModel"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom du type de machine"
|
|
||||||
class="input input-bordered"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Catégorie</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="categoryModel"
|
|
||||||
type="text"
|
|
||||||
placeholder="Catégorie du type"
|
|
||||||
class="input input-bordered"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control md:col-span-2">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="descriptionModel"
|
|
||||||
placeholder="Description du type de machine"
|
|
||||||
class="textarea textarea-bordered h-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fréquence de maintenance</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="maintenanceModel"
|
|
||||||
type="text"
|
|
||||||
placeholder="ex: Mensuelle, Trimestrielle"
|
|
||||||
class="input input-bordered"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
maintenanceFrequency: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:name', 'update:category', 'update:description', 'update:maintenanceFrequency'])
|
|
||||||
|
|
||||||
const nameModel = computed({
|
|
||||||
get: () => props.name,
|
|
||||||
set: value => emit('update:name', value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const categoryModel = computed({
|
|
||||||
get: () => props.category,
|
|
||||||
set: value => emit('update:category', value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const descriptionModel = computed({
|
|
||||||
get: () => props.description,
|
|
||||||
set: value => emit('update:description', value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const maintenanceModel = computed({
|
|
||||||
get: () => props.maintenanceFrequency,
|
|
||||||
set: value => emit('update:maintenanceFrequency', value)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<RequirementListEditor
|
|
||||||
v-model="requirements"
|
|
||||||
:type-options="componentTypes"
|
|
||||||
type-field="typeComposantId"
|
|
||||||
:labels="labels"
|
|
||||||
:default-requirement="createDefaultRequirement"
|
|
||||||
:required-fallback="true"
|
|
||||||
:min-fallback="1"
|
|
||||||
:type-loading="loadingComponentTypes"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
|
||||||
|
|
||||||
type Requirement = Record<string, unknown> & {
|
|
||||||
id?: string | number
|
|
||||||
typeComposantId?: string | number | null
|
|
||||||
label?: string
|
|
||||||
minCount?: number | null
|
|
||||||
maxCount?: number | null
|
|
||||||
required?: boolean | null
|
|
||||||
allowNewModels?: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type Labels = {
|
|
||||||
headerTitle: string
|
|
||||||
addButton: string
|
|
||||||
description: string
|
|
||||||
emptyState: string
|
|
||||||
typeSelectLabel: string
|
|
||||||
typePlaceholder: string
|
|
||||||
labelFieldLabel: string
|
|
||||||
labelFieldHelper: string
|
|
||||||
labelPlaceholder: string
|
|
||||||
minLabel: string
|
|
||||||
maxLabel: string
|
|
||||||
maxHelper: string
|
|
||||||
requiredLabel: string
|
|
||||||
allowNewModelsLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Array as () => Requirement[],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
|
||||||
|
|
||||||
const requirements = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createDefaultRequirement = (): Requirement => ({
|
|
||||||
id: undefined,
|
|
||||||
typeComposantId: null,
|
|
||||||
label: '',
|
|
||||||
minCount: 1,
|
|
||||||
maxCount: null,
|
|
||||||
required: true,
|
|
||||||
allowNewModels: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const labels: Labels = {
|
|
||||||
headerTitle: 'Familles de composants',
|
|
||||||
addButton: 'Ajouter une famille',
|
|
||||||
description:
|
|
||||||
"Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.",
|
|
||||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de composants.',
|
|
||||||
typeSelectLabel: 'Type de composant',
|
|
||||||
typePlaceholder: 'Sélectionner un type',
|
|
||||||
labelFieldLabel: 'Libellé',
|
|
||||||
labelFieldHelper: 'Optionnel',
|
|
||||||
labelPlaceholder: 'Ex: Sangles principales',
|
|
||||||
minLabel: 'Minimum requis',
|
|
||||||
maxLabel: 'Maximum autorisé',
|
|
||||||
maxHelper: 'Laisser vide pour illimité',
|
|
||||||
requiredLabel: 'Requis',
|
|
||||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!componentTypes.value.length) {
|
|
||||||
await loadComponentTypes()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-sm p-1"
|
|
||||||
@click="toggleSection"
|
|
||||||
>
|
|
||||||
<IconLucideChevronRight
|
|
||||||
class="w-4 h-4 transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-90': expanded }"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<h3 class="card-title text-lg">
|
|
||||||
Champs personnalisés du type
|
|
||||||
</h3>
|
|
||||||
<span class="badge badge-primary">{{ fields.length }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="expanded" class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="(field, fieldIndex) in fields"
|
|
||||||
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
|
|
||||||
class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
|
|
||||||
:class="fieldReorderClass(fieldIndex)"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="onFieldDragStart(fieldIndex, $event)"
|
|
||||||
@dragenter="onFieldDragEnter(fieldIndex)"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop="onFieldDrop(fieldIndex)"
|
|
||||||
@dragend="onFieldDragEnd"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
|
||||||
title="Réorganiser"
|
|
||||||
draggable="false"
|
|
||||||
>
|
|
||||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs p-1"
|
|
||||||
title="Plier / déplier le champ"
|
|
||||||
@click="toggleField(fieldIndex)"
|
|
||||||
>
|
|
||||||
<IconLucideChevronRight
|
|
||||||
class="w-4 h-4 transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-90': isFieldExpanded(fieldIndex) }"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<IconLucideListChecks class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
|
||||||
<h5 class="text-sm font-medium">
|
|
||||||
Champ personnalisé {{ fieldIndex + 1 }}
|
|
||||||
</h5>
|
|
||||||
<span v-if="!isFieldExpanded(fieldIndex)" class="text-xs text-gray-500 truncate max-w-[160px]">
|
|
||||||
{{ field.name || 'Sans nom' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-square btn-error btn-sm"
|
|
||||||
title="Supprimer ce champ"
|
|
||||||
@click="removeField(fieldIndex)"
|
|
||||||
>
|
|
||||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isFieldExpanded(fieldIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du champ</span>
|
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
:value="field.name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom du champ"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
required
|
|
||||||
@input="updateField(fieldIndex, { name: $event.target.value })"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Type de champ</span>
|
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
class="select select-bordered select-sm"
|
|
||||||
required
|
|
||||||
:value="field.type"
|
|
||||||
@change="updateField(fieldIndex, { type: $event.target.value })"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
Sélectionner un type
|
|
||||||
</option>
|
|
||||||
<option value="text">
|
|
||||||
Texte
|
|
||||||
</option>
|
|
||||||
<option value="number">
|
|
||||||
Nombre
|
|
||||||
</option>
|
|
||||||
<option value="select">
|
|
||||||
Liste déroulante
|
|
||||||
</option>
|
|
||||||
<option value="boolean">
|
|
||||||
Oui/Non
|
|
||||||
</option>
|
|
||||||
<option value="date">
|
|
||||||
Date
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isFieldExpanded(fieldIndex)" class="mt-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
:checked="field.required"
|
|
||||||
@change="updateField(fieldIndex, { required: $event.target.checked })"
|
|
||||||
>
|
|
||||||
<span class="text-sm">Champ obligatoire</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isFieldExpanded(fieldIndex) && field.type === 'select'"
|
|
||||||
class="mt-3"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Options de la liste</span>
|
|
||||||
<span class="label-text-alt">Une option par ligne</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
:value="field.optionsText || ''"
|
|
||||||
placeholder="Option 1 Option 2 Option 3"
|
|
||||||
class="textarea textarea-bordered textarea-sm w-full h-20"
|
|
||||||
@input="updateOptions(fieldIndex, $event.target.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" @click="addField">
|
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Ajouter un champ
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex justify-end">
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" @click="addField">
|
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Ajouter un champ
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|
||||||
import IconLucideListChecks from '~icons/lucide/list-checks'
|
|
||||||
import IconLucideX from '~icons/lucide/x'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
allExpanded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
expandAllTrigger: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const fields = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: value => emit('update:modelValue', value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
|
||||||
|
|
||||||
const expanded = ref(false)
|
|
||||||
const expandedFields = ref([])
|
|
||||||
const draggingFieldIndex = ref(null)
|
|
||||||
const fieldDropTargetIndex = ref(null)
|
|
||||||
|
|
||||||
const applyOrderIndex = (list = []) => {
|
|
||||||
if (!Array.isArray(list)) { return [] }
|
|
||||||
list.forEach((field, index) => {
|
|
||||||
if (field && typeof field === 'object') {
|
|
||||||
field.orderIndex = index
|
|
||||||
if (typeof field.__key !== 'string' || !field.__key) {
|
|
||||||
field.__key = createFieldKey()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
const createEmptyField = () => ({
|
|
||||||
name: '',
|
|
||||||
type: '',
|
|
||||||
required: false,
|
|
||||||
optionsText: '',
|
|
||||||
orderIndex: fields.value.length,
|
|
||||||
__key: createFieldKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetDragState = () => {
|
|
||||||
draggingFieldIndex.value = null
|
|
||||||
fieldDropTargetIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderFields = (from, to) => {
|
|
||||||
const list = Array.isArray(fields.value) ? fields.value.slice() : []
|
|
||||||
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
|
||||||
resetDragState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [moved] = list.splice(from, 1)
|
|
||||||
list.splice(to, 0, moved)
|
|
||||||
if (Array.isArray(expandedFields.value)) {
|
|
||||||
const expandedCopy = expandedFields.value.slice()
|
|
||||||
const [expandedState] = expandedCopy.splice(from, 1)
|
|
||||||
expandedCopy.splice(to, 0, expandedState)
|
|
||||||
expandedFields.value = expandedCopy
|
|
||||||
}
|
|
||||||
fields.value = applyOrderIndex(list)
|
|
||||||
resetDragState()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.expandAllTrigger,
|
|
||||||
() => {
|
|
||||||
expanded.value = props.allExpanded
|
|
||||||
expandedFields.value = fields.value.map(() => props.allExpanded)
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => fields.value.length,
|
|
||||||
(length) => {
|
|
||||||
expandedFields.value = Array.from({ length }, (_, index) => expandedFields.value[index] ?? props.allExpanded)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleSection = () => {
|
|
||||||
expanded.value = !expanded.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureFieldState = (index) => {
|
|
||||||
if (expandedFields.value[index] === undefined) {
|
|
||||||
expandedFields.value[index] = props.allExpanded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFieldExpanded = (index) => {
|
|
||||||
ensureFieldState(index)
|
|
||||||
return expandedFields.value[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleField = (index) => {
|
|
||||||
ensureFieldState(index)
|
|
||||||
expandedFields.value[index] = !expandedFields.value[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
const addField = () => {
|
|
||||||
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
|
||||||
next.push(createEmptyField())
|
|
||||||
fields.value = applyOrderIndex(next)
|
|
||||||
expandedFields.value.push(true)
|
|
||||||
expanded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeField = (index) => {
|
|
||||||
const next = Array.isArray(fields.value)
|
|
||||||
? fields.value.filter((_, i) => i !== index)
|
|
||||||
: []
|
|
||||||
fields.value = applyOrderIndex(next)
|
|
||||||
expandedFields.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateField = (index, patch) => {
|
|
||||||
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
|
||||||
next[index] = { ...next[index], ...patch }
|
|
||||||
fields.value = applyOrderIndex(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateOptions = (index, value) => {
|
|
||||||
updateField(index, {
|
|
||||||
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFieldDragStart = (index, event) => {
|
|
||||||
draggingFieldIndex.value = index
|
|
||||||
fieldDropTargetIndex.value = index
|
|
||||||
if (event.dataTransfer) {
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFieldDragEnter = (index) => {
|
|
||||||
if (draggingFieldIndex.value === null) { return }
|
|
||||||
fieldDropTargetIndex.value = index
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFieldDrop = (index) => {
|
|
||||||
if (draggingFieldIndex.value === null) {
|
|
||||||
resetDragState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reorderFields(draggingFieldIndex.value, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFieldDragEnd = () => {
|
|
||||||
resetDragState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldReorderClass = (index) => {
|
|
||||||
if (draggingFieldIndex.value === index) {
|
|
||||||
return 'border-dashed border-primary'
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
draggingFieldIndex.value !== null &&
|
|
||||||
fieldDropTargetIndex.value === index &&
|
|
||||||
draggingFieldIndex.value !== index
|
|
||||||
) {
|
|
||||||
return 'border-primary border-dashed bg-primary/5'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
|
||||||
<TypeEditToolbar :all-expanded="allExpanded" @toggle="toggleAllSections" />
|
|
||||||
|
|
||||||
<TypeEditBaseInfoSection
|
|
||||||
v-model:name="formData.name"
|
|
||||||
v-model:category="formData.category"
|
|
||||||
v-model:description="formData.description"
|
|
||||||
v-model:maintenance-frequency="formData.maintenanceFrequency"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TypeEditCustomFieldsSection
|
|
||||||
:model-value="formData.customFields"
|
|
||||||
:all-expanded="allExpanded"
|
|
||||||
:expand-all-trigger="expandAllTrigger"
|
|
||||||
@update:model-value="(value) => (formData.customFields = value)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TypeEditComponentRequirementsSection
|
|
||||||
:model-value="formData.componentRequirements"
|
|
||||||
@update:model-value="(value) => (formData.componentRequirements = value)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TypeEditPieceRequirementsSection
|
|
||||||
:model-value="formData.pieceRequirements"
|
|
||||||
@update:model-value="(value) => (formData.pieceRequirements = value)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TypeEditProductRequirementsSection
|
|
||||||
:model-value="formData.productRequirements"
|
|
||||||
@update:model-value="(value) => (formData.productRequirements = value)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, watch } from 'vue'
|
|
||||||
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
|
|
||||||
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
|
|
||||||
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
|
|
||||||
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
|
||||||
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
|
|
||||||
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
|
|
||||||
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
saving: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'submit'])
|
|
||||||
|
|
||||||
const deepClone = value => JSON.parse(JSON.stringify(value))
|
|
||||||
|
|
||||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
|
||||||
|
|
||||||
const normalizeCustomField = (field = {}, index = 0) => {
|
|
||||||
const clone = deepClone(field)
|
|
||||||
if (clone.type === 'select') {
|
|
||||||
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
|
|
||||||
if (Array.isArray(clone.options)) {
|
|
||||||
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
|
|
||||||
} else {
|
|
||||||
clone.optionsText = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const currentOrder =
|
|
||||||
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
|
||||||
clone.orderIndex = currentOrder
|
|
||||||
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
|
||||||
clone.__key = createFieldKey()
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
const withNormalizedOrder = (items = []) => {
|
|
||||||
if (!Array.isArray(items)) { return [] }
|
|
||||||
return items
|
|
||||||
.map((item, index) => normalizeCustomField(item, index))
|
|
||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
.map((item, index) => ({ ...item, orderIndex: index }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const createDefaultForm = (source = {}) => ({
|
|
||||||
name: source.name || '',
|
|
||||||
description: source.description || '',
|
|
||||||
category: source.category || '',
|
|
||||||
maintenanceFrequency: source.maintenanceFrequency || '',
|
|
||||||
customFields: withNormalizedOrder(source.customFields || []),
|
|
||||||
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
|
|
||||||
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
|
|
||||||
productRequirements: withNormalizedOrder(source.productRequirements || []),
|
|
||||||
})
|
|
||||||
|
|
||||||
const formData = reactive(createDefaultForm(props.modelValue))
|
|
||||||
const allExpanded = ref(false)
|
|
||||||
const expandAllTrigger = ref(0)
|
|
||||||
|
|
||||||
let syncingFromParent = false
|
|
||||||
const toPlainObject = value => JSON.parse(JSON.stringify(value))
|
|
||||||
const lastSnapshot = ref(toPlainObject(createDefaultForm(props.modelValue)))
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(value) => {
|
|
||||||
const normalized = createDefaultForm(value)
|
|
||||||
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
syncingFromParent = true
|
|
||||||
Object.assign(formData, normalized)
|
|
||||||
lastSnapshot.value = toPlainObject(normalized)
|
|
||||||
syncingFromParent = false
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
formData,
|
|
||||||
(value) => {
|
|
||||||
if (syncingFromParent) { return }
|
|
||||||
const normalized = createDefaultForm(value)
|
|
||||||
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSnapshot.value = toPlainObject(normalized)
|
|
||||||
emit('update:modelValue', normalized)
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleAllSections = () => {
|
|
||||||
allExpanded.value = !allExpanded.value
|
|
||||||
expandAllTrigger.value += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
const normalized = createDefaultForm(props.modelValue)
|
|
||||||
syncingFromParent = true
|
|
||||||
Object.assign(formData, normalized)
|
|
||||||
lastSnapshot.value = toPlainObject(normalized)
|
|
||||||
syncingFromParent = false
|
|
||||||
allExpanded.value = false
|
|
||||||
expandAllTrigger.value += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
emit('submit')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<RequirementListEditor
|
|
||||||
v-model="requirements"
|
|
||||||
:type-options="pieceTypes"
|
|
||||||
type-field="typePieceId"
|
|
||||||
:labels="labels"
|
|
||||||
:default-requirement="createDefaultRequirement"
|
|
||||||
:required-fallback="false"
|
|
||||||
:min-fallback="0"
|
|
||||||
:type-loading="loadingPieceTypes"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
|
||||||
|
|
||||||
type Requirement = Record<string, unknown> & {
|
|
||||||
id?: string | number
|
|
||||||
typePieceId?: string | number | null
|
|
||||||
label?: string
|
|
||||||
minCount?: number | null
|
|
||||||
maxCount?: number | null
|
|
||||||
required?: boolean | null
|
|
||||||
allowNewModels?: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type Labels = {
|
|
||||||
headerTitle: string
|
|
||||||
addButton: string
|
|
||||||
description: string
|
|
||||||
emptyState: string
|
|
||||||
typeSelectLabel: string
|
|
||||||
typePlaceholder: string
|
|
||||||
labelFieldLabel: string
|
|
||||||
labelFieldHelper: string
|
|
||||||
labelPlaceholder: string
|
|
||||||
minLabel: string
|
|
||||||
maxLabel: string
|
|
||||||
maxHelper: string
|
|
||||||
requiredLabel: string
|
|
||||||
allowNewModelsLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Array as () => Requirement[],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
|
|
||||||
|
|
||||||
const requirements = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createDefaultRequirement = (): Requirement => ({
|
|
||||||
id: undefined,
|
|
||||||
typePieceId: null,
|
|
||||||
label: '',
|
|
||||||
minCount: 0,
|
|
||||||
maxCount: null,
|
|
||||||
required: false,
|
|
||||||
allowNewModels: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const labels: Labels = {
|
|
||||||
headerTitle: 'Pièces principales',
|
|
||||||
addButton: 'Ajouter un groupe',
|
|
||||||
description:
|
|
||||||
"Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.",
|
|
||||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de pièces.',
|
|
||||||
typeSelectLabel: 'Type de pièce',
|
|
||||||
typePlaceholder: 'Sélectionner un type',
|
|
||||||
labelFieldLabel: 'Libellé',
|
|
||||||
labelFieldHelper: 'Optionnel',
|
|
||||||
labelPlaceholder: 'Ex: Vis principale',
|
|
||||||
minLabel: 'Minimum requis',
|
|
||||||
maxLabel: 'Maximum autorisé',
|
|
||||||
maxHelper: 'Laisser vide pour illimité',
|
|
||||||
requiredLabel: 'Requis',
|
|
||||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!pieceTypes.value.length) {
|
|
||||||
await loadPieceTypes()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<RequirementListEditor
|
|
||||||
v-model="requirements"
|
|
||||||
:type-options="productTypes"
|
|
||||||
type-field="typeProductId"
|
|
||||||
:labels="labels"
|
|
||||||
:default-requirement="createDefaultRequirement"
|
|
||||||
:required-fallback="false"
|
|
||||||
:min-fallback="0"
|
|
||||||
:type-loading="loadingProductTypes"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
|
||||||
|
|
||||||
type Requirement = Record<string, unknown> & {
|
|
||||||
id?: string | number
|
|
||||||
typeProductId?: string | number | null
|
|
||||||
label?: string
|
|
||||||
minCount?: number | null
|
|
||||||
maxCount?: number | null
|
|
||||||
required?: boolean | null
|
|
||||||
allowNewModels?: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type Labels = {
|
|
||||||
headerTitle: string
|
|
||||||
addButton: string
|
|
||||||
description: string
|
|
||||||
emptyState: string
|
|
||||||
typeSelectLabel: string
|
|
||||||
typePlaceholder: string
|
|
||||||
labelFieldLabel: string
|
|
||||||
labelFieldHelper: string
|
|
||||||
labelPlaceholder: string
|
|
||||||
minLabel: string
|
|
||||||
maxLabel: string
|
|
||||||
maxHelper: string
|
|
||||||
requiredLabel: string
|
|
||||||
allowNewModelsLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Array as () => Requirement[],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
|
||||||
|
|
||||||
const requirements = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createDefaultRequirement = (): Requirement => ({
|
|
||||||
id: undefined,
|
|
||||||
typeProductId: null,
|
|
||||||
label: '',
|
|
||||||
minCount: 0,
|
|
||||||
maxCount: null,
|
|
||||||
required: false,
|
|
||||||
allowNewModels: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const labels: Labels = {
|
|
||||||
headerTitle: 'Produits requis',
|
|
||||||
addButton: 'Ajouter un produit',
|
|
||||||
description:
|
|
||||||
"Définissez les produits catalogue attendus pour ce type de machine. Sélectionnez la catégorie de produit, précisez les quantités minimales et maximales, puis indiquez si de nouveaux produits peuvent être créés à l'usage.",
|
|
||||||
emptyState: 'Aucun produit requis configuré pour le moment.',
|
|
||||||
typeSelectLabel: 'Catégorie de produit',
|
|
||||||
typePlaceholder: 'Sélectionner une catégorie',
|
|
||||||
labelFieldLabel: 'Libellé',
|
|
||||||
labelFieldHelper: 'Optionnel',
|
|
||||||
labelPlaceholder: 'Ex : Lubrifiant recommandé',
|
|
||||||
minLabel: 'Minimum requis',
|
|
||||||
maxLabel: 'Maximum autorisé',
|
|
||||||
maxHelper: 'Laisser vide pour illimité',
|
|
||||||
requiredLabel: 'Requis',
|
|
||||||
allowNewModelsLabel: "Autoriser la création de nouveaux produits lors de l'instanciation",
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!productTypes.value.length) {
|
|
||||||
await loadProductTypes()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button type="button" class="btn btn-outline btn-sm" @click="$emit('toggle')">
|
|
||||||
<IconLucideMinus v-if="allExpanded" class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
<IconLucidePlus v-else class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
{{ allExpanded ? 'Tout plier' : 'Tout déplier' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import IconLucideMinus from '~icons/lucide/minus'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
allExpanded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['toggle'])
|
|
||||||
</script>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="alert alert-info mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-bold">
|
|
||||||
Type existant
|
|
||||||
</h3>
|
|
||||||
<div class="text-sm">
|
|
||||||
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
|
|
||||||
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
|
|
||||||
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
|
|
||||||
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
|
|
||||||
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
|
|
||||||
<p v-if="type.description">
|
|
||||||
<strong>Description:</strong> {{ type.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
type: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
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>
|
||||||
@@ -18,6 +18,15 @@
|
|||||||
@keydown.enter.prevent="selectHighlighted"
|
@keydown.enter.prevent="selectHighlighted"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="clearable && modelValue"
|
||||||
|
type="button"
|
||||||
|
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
|
||||||
|
aria-label="Effacer la sélection"
|
||||||
|
@click.stop="clearSelection"
|
||||||
|
>
|
||||||
|
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="toggleButtonClasses"
|
:class="toggleButtonClasses"
|
||||||
@@ -77,6 +86,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -111,6 +121,10 @@ const props = defineProps({
|
|||||||
type: [String, Function],
|
type: [String, Function],
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
|
clearable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'md',
|
default: 'md',
|
||||||
@@ -155,7 +169,8 @@ const displayedOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const inputClasses = computed(() => {
|
const inputClasses = computed(() => {
|
||||||
const base = ['input', 'input-bordered', 'w-full', 'pr-10']
|
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||||
|
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||||
if (props.size === 'xs') base.push('input-xs')
|
if (props.size === 'xs') base.push('input-xs')
|
||||||
if (props.size === 'sm') base.push('input-sm')
|
if (props.size === 'sm') base.push('input-sm')
|
||||||
if (props.size === 'lg') base.push('input-lg')
|
if (props.size === 'lg') base.push('input-lg')
|
||||||
@@ -269,9 +284,17 @@ function handleInput () {
|
|||||||
emit('search', searchTerm.value)
|
emit('search', searchTerm.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelection () {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
searchTerm.value = ''
|
||||||
|
openDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function closeDropdown () {
|
function closeDropdown () {
|
||||||
openDropdown.value = false
|
openDropdown.value = false
|
||||||
if (selectedOption.value) {
|
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
} else if (selectedOption.value) {
|
||||||
searchTerm.value = resolveLabel(selectedOption.value)
|
searchTerm.value = resolveLabel(selectedOption.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' : ''"
|
||||||
@@ -65,6 +69,9 @@
|
|||||||
:class="childLinkClass(child)"
|
:class="childLinkClass(child)"
|
||||||
>
|
>
|
||||||
{{ child.label }}
|
{{ child.label }}
|
||||||
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -97,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>
|
||||||
@@ -116,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"
|
||||||
@@ -142,6 +151,9 @@
|
|||||||
:class="childLinkClass(child)"
|
:class="childLinkClass(child)"
|
||||||
>
|
>
|
||||||
{{ child.label }}
|
{{ child.label }}
|
||||||
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -166,8 +178,14 @@
|
|||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
class="btn btn-ghost btn-circle avatar placeholder"
|
class="btn btn-ghost btn-circle avatar placeholder indicator"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="unresolvedCount > 0"
|
||||||
|
class="indicator-item badge badge-warning badge-xs"
|
||||||
|
>
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
<div
|
<div
|
||||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
||||||
>
|
>
|
||||||
@@ -185,11 +203,21 @@
|
|||||||
<li class="px-2 py-1 text-sm text-base-content/70">
|
<li class="px-2 py-1 text-sm text-base-content/70">
|
||||||
Connecté en tant que<br />
|
Connecté en tant que<br />
|
||||||
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
||||||
|
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="isAdmin">
|
||||||
|
<NuxtLink to="/admin" class="justify-between">
|
||||||
|
Administration
|
||||||
|
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
<NuxtLink to="/comments" class="justify-between">
|
||||||
Gestion des profils
|
Commentaires
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
|
<IconLucideChevronRight v-else class="w-4 h-4" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -211,14 +239,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
|
||||||
import { useRoute } from '#imports'
|
import { useRoute } from '#imports'
|
||||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
import { useProfileSession } from '~/composables/useProfileSession'
|
import { useProfileSession } from '~/composables/useProfileSession'
|
||||||
|
import { useComments } from '~/composables/useComments'
|
||||||
import IconLucideMenu from '~icons/lucide/menu'
|
import IconLucideMenu from '~icons/lucide/menu'
|
||||||
import IconLucideSettings from '~icons/lucide/settings'
|
import IconLucideSettings from '~icons/lucide/settings'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||||
|
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||||
|
import IconLucideFactory from '~icons/lucide/factory'
|
||||||
|
|
||||||
|
import IconLucideCpu from '~icons/lucide/cpu'
|
||||||
|
import IconLucidePuzzle from '~icons/lucide/puzzle'
|
||||||
|
import IconLucidePackage from '~icons/lucide/package'
|
||||||
|
import IconLucideLink from '~icons/lucide/link'
|
||||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -229,25 +266,37 @@ defineEmits<{
|
|||||||
interface NavLink {
|
interface NavLink {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
|
icon?: Component
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
icon?: Component
|
||||||
activePaths: string[]
|
activePaths: string[]
|
||||||
children: NavLink[]
|
children: NavLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const simpleLinks: NavLink[] = [
|
const simpleLinks: NavLink[] = [
|
||||||
{ to: '/', label: 'Vue d\'ensemble' },
|
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||||
{ to: '/machines', label: 'Parc Machines' },
|
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||||
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
id: 'component',
|
||||||
|
label: 'Composants',
|
||||||
|
icon: IconLucideCpu,
|
||||||
|
activePaths: ['/component-category', '/component-catalog'],
|
||||||
|
children: [
|
||||||
|
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||||
|
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pieces',
|
id: 'pieces',
|
||||||
label: 'Pièces',
|
label: 'Pièces',
|
||||||
|
icon: IconLucidePuzzle,
|
||||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||||
@@ -257,29 +306,23 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: 'products',
|
id: 'products',
|
||||||
label: 'Produits',
|
label: 'Produits',
|
||||||
|
icon: IconLucidePackage,
|
||||||
activePaths: ['/product-category', '/product-catalog'],
|
activePaths: ['/product-category', '/product-catalog'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'component',
|
|
||||||
label: 'Composant',
|
|
||||||
activePaths: ['/component-category', '/component-catalog'],
|
|
||||||
children: [
|
|
||||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
|
||||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'resources',
|
id: 'resources',
|
||||||
label: 'Ressources liées',
|
label: 'Ressources liées',
|
||||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
|
icon: IconLucideLink,
|
||||||
|
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/sites', label: 'Sites' },
|
{ to: '/sites', label: 'Sites' },
|
||||||
{ to: '/documents', label: 'Documents' },
|
{ to: '/documents', label: 'Documents' },
|
||||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||||
|
{ to: '/comments', label: 'Commentaires' },
|
||||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -288,6 +331,25 @@ const navGroups: NavGroup[] = [
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||||
const { activeProfile } = useProfileSession()
|
const { activeProfile } = useProfileSession()
|
||||||
|
const { isAdmin, canEdit } = usePermissions()
|
||||||
|
const { fetchUnresolvedCount } = useComments()
|
||||||
|
|
||||||
|
const unresolvedCount = ref(0)
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const refreshUnresolvedCount = async () => {
|
||||||
|
if (!activeProfile.value) return
|
||||||
|
unresolvedCount.value = await fetchUnresolvedCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshUnresolvedCount()
|
||||||
|
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
|
})
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
@@ -318,6 +380,18 @@ const childLinkClass = (child: NavLink) => {
|
|||||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roleLabel = computed(() => {
|
||||||
|
if (isAdmin.value) return 'Admin'
|
||||||
|
if (canEdit.value) return 'Gestionnaire'
|
||||||
|
return 'Lecteur'
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleBadgeClass = computed(() => {
|
||||||
|
if (isAdmin.value) return 'badge-error'
|
||||||
|
if (canEdit.value) return 'badge-warning'
|
||||||
|
return 'badge-info'
|
||||||
|
})
|
||||||
|
|
||||||
const activeProfileLabel = computed(() => {
|
const activeProfileLabel = computed(() => {
|
||||||
if (!activeProfile.value) {
|
if (!activeProfile.value) {
|
||||||
return 'Profil inconnu'
|
return 'Profil inconnu'
|
||||||
|
|||||||
207
app/components/machine/AddEntityToMachineModal.vue
Normal file
207
app/components/machine/AddEntityToMachineModal.vue
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-xl w-full" style="overflow: visible">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 class="font-bold text-lg mb-6">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Step 1: Choose category -->
|
||||||
|
<div class="form-control mb-5" style="position: relative; z-index: 20">
|
||||||
|
<label class="label pb-1">
|
||||||
|
<span class="label-text font-medium">Catégorie</span>
|
||||||
|
</label>
|
||||||
|
<SearchSelect
|
||||||
|
v-model="selectedTypeId"
|
||||||
|
:options="types"
|
||||||
|
:loading="loadingTypes"
|
||||||
|
:max-visible="8"
|
||||||
|
placeholder="Rechercher une catégorie..."
|
||||||
|
empty-text="Aucune catégorie disponible"
|
||||||
|
:option-label="(t: any) => t.name"
|
||||||
|
:option-description="(t: any) => t.code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Choose entity (visible only after category selected) -->
|
||||||
|
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
|
||||||
|
<label class="label pb-1">
|
||||||
|
<span class="label-text font-medium">{{ entityLabel }}</span>
|
||||||
|
</label>
|
||||||
|
<SearchSelect
|
||||||
|
v-model="selectedEntityId"
|
||||||
|
:options="entities"
|
||||||
|
:loading="loadingEntities"
|
||||||
|
:max-visible="8"
|
||||||
|
:placeholder="`Rechercher ${entityLabelLower}...`"
|
||||||
|
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
||||||
|
:option-label="entityOptionLabel"
|
||||||
|
:option-description="entityOptionDescription"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary of selection -->
|
||||||
|
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
|
||||||
|
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
|
||||||
|
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
|
||||||
|
Réf : {{ selectedEntitySummary.reference }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="handleClose">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!selectedEntityId"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" @click="handleClose" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
|
||||||
|
type EntityKind = 'component' | 'piece' | 'product'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
entityKind: EntityKind
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: [entityId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedTypeId = ref('')
|
||||||
|
const selectedEntityId = ref('')
|
||||||
|
const loadingEntities = ref(false)
|
||||||
|
const entities = ref<any[]>([])
|
||||||
|
|
||||||
|
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
|
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
|
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
|
||||||
|
const { loadComposants } = useComposants()
|
||||||
|
const { loadPieces } = usePieces()
|
||||||
|
const { loadProducts } = useProducts()
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const labels: Record<EntityKind, string> = {
|
||||||
|
component: 'Ajouter un composant',
|
||||||
|
piece: 'Ajouter une pièce',
|
||||||
|
product: 'Ajouter un produit',
|
||||||
|
}
|
||||||
|
return labels[props.entityKind]
|
||||||
|
})
|
||||||
|
|
||||||
|
const entityLabel = computed(() => {
|
||||||
|
const labels: Record<EntityKind, string> = {
|
||||||
|
component: 'Composant',
|
||||||
|
piece: 'Pièce',
|
||||||
|
product: 'Produit',
|
||||||
|
}
|
||||||
|
return labels[props.entityKind]
|
||||||
|
})
|
||||||
|
|
||||||
|
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
|
||||||
|
|
||||||
|
const types = computed(() => {
|
||||||
|
if (props.entityKind === 'component') return componentTypes.value
|
||||||
|
if (props.entityKind === 'piece') return pieceTypes.value
|
||||||
|
return productTypes.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadingTypes = computed(() => {
|
||||||
|
if (props.entityKind === 'component') return loadingComponentTypes.value
|
||||||
|
if (props.entityKind === 'piece') return loadingPieceTypes.value
|
||||||
|
return loadingProductTypes.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTypeName = computed(() => {
|
||||||
|
if (!selectedTypeId.value) return ''
|
||||||
|
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
|
||||||
|
return found?.name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const entityOptionLabel = (e: any) => e.name || '(sans nom)'
|
||||||
|
const entityOptionDescription = (e: any) => e.reference || ''
|
||||||
|
|
||||||
|
const selectedEntitySummary = computed(() => {
|
||||||
|
if (!selectedEntityId.value || !entities.value.length) return null
|
||||||
|
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
|
||||||
|
if (!found) return null
|
||||||
|
return { name: found.name || '(sans nom)', reference: found.reference || null }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load types when modal opens
|
||||||
|
watch(() => props.open, async (isOpen) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
if (props.entityKind === 'component') await loadComponentTypes()
|
||||||
|
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||||
|
else await loadProductTypes()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load entities when type changes
|
||||||
|
watch(selectedTypeId, async () => {
|
||||||
|
selectedEntityId.value = ''
|
||||||
|
entities.value = []
|
||||||
|
|
||||||
|
if (!selectedTypeName.value) return
|
||||||
|
|
||||||
|
loadingEntities.value = true
|
||||||
|
try {
|
||||||
|
if (props.entityKind === 'component') {
|
||||||
|
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
} else if (props.entityKind === 'piece') {
|
||||||
|
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
} else {
|
||||||
|
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingEntities.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetState()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!selectedEntityId.value) return
|
||||||
|
emit('confirm', selectedEntityId.value)
|
||||||
|
resetState()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
selectedTypeId.value = ''
|
||||||
|
selectedEntityId.value = ''
|
||||||
|
entities.value = []
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -3,32 +3,57 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Composants</h2>
|
<h2 class="card-title">Composants</h2>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-ghost btn-sm gap-2"
|
v-if="isEditMode"
|
||||||
@click="$emit('toggle-collapse')"
|
type="button"
|
||||||
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
class="btn btn-sm md:btn-md btn-primary"
|
||||||
>
|
@click="$emit('add-component')"
|
||||||
<IconLucideChevronRight
|
>
|
||||||
class="w-5 h-5 transition-transform"
|
Ajouter un composant
|
||||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
</button>
|
||||||
aria-hidden="true"
|
<button
|
||||||
/>
|
type="button"
|
||||||
<span class="text-sm">
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
@click="$emit('toggle-collapse')"
|
||||||
</span>
|
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
||||||
</button>
|
>
|
||||||
|
<IconLucideChevronRight
|
||||||
|
class="w-5 h-5 transition-transform"
|
||||||
|
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComponentHierarchy
|
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
||||||
:components="components"
|
Aucun composant associé à cette machine.
|
||||||
:is-edit-mode="isEditMode"
|
</div>
|
||||||
:collapse-all="collapsed"
|
|
||||||
:toggle-token="collapseToggleToken"
|
<div v-else class="space-y-2">
|
||||||
@update="$emit('update-component', $event)"
|
<div v-for="component in components" :key="component.id" class="relative">
|
||||||
@edit-piece="$emit('edit-piece', $event)"
|
<button
|
||||||
@custom-field-update="$emit('custom-field-update', $event)"
|
v-if="isEditMode"
|
||||||
/>
|
type="button"
|
||||||
|
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
|
||||||
|
title="Supprimer ce composant"
|
||||||
|
@click="$emit('remove-component', component.linkId || component.id)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<ComponentHierarchy
|
||||||
|
:components="[component]"
|
||||||
|
:is-edit-mode="false"
|
||||||
|
:collapse-all="collapsed"
|
||||||
|
:toggle-token="collapseToggleToken"
|
||||||
|
@edit-piece="$emit('edit-piece', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,5 +74,7 @@ defineEmits<{
|
|||||||
'update-component': [component: any]
|
'update-component': [component: any]
|
||||||
'edit-piece': [piece: any]
|
'edit-piece': [piece: any]
|
||||||
'custom-field-update': [fieldUpdate: any]
|
'custom-field-update': [fieldUpdate: any]
|
||||||
|
'add-component': []
|
||||||
|
'remove-component': [linkId: string]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,25 +4,6 @@
|
|||||||
<h1 class="text-3xl font-bold">
|
<h1 class="text-3xl font-bold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="btn-group w-full max-w-xs print:hidden" data-print-hide>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="isDetailsView ? 'btn-primary' : 'btn-outline'"
|
|
||||||
@click="$emit('change-view', 'details')"
|
|
||||||
>
|
|
||||||
Vue machine
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="isSkeletonView ? 'btn-primary' : 'btn-outline'"
|
|
||||||
:disabled="!hasSkeletonRequirements"
|
|
||||||
@click="$emit('change-view', 'skeleton')"
|
|
||||||
>
|
|
||||||
Squelette
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||||
<button
|
<button
|
||||||
@@ -43,7 +24,7 @@
|
|||||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="isDetailsView && !isEditMode"
|
v-if="!isEditMode"
|
||||||
@click="$emit('open-print')"
|
@click="$emit('open-print')"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline btn-secondary"
|
class="btn btn-outline btn-secondary"
|
||||||
@@ -62,14 +43,10 @@ import IconLucidePrinter from '~icons/lucide/printer'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
isDetailsView: boolean
|
|
||||||
isSkeletonView: boolean
|
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
hasSkeletonRequirements: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'change-view': [view: 'details' | 'skeleton']
|
|
||||||
'toggle-edit': []
|
'toggle-edit': []
|
||||||
'open-print': []
|
'open-print': []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -120,17 +120,16 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
:value="field.value ?? ''"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm"
|
||||||
:checked="String(field.value).toLowerCase() === 'true'"
|
:checked="String(field.value).toLowerCase() === 'true'"
|
||||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||||
@blur="$emit('update-custom-field', field)"
|
@blur="$emit('update-custom-field', field)"
|
||||||
/>
|
>
|
||||||
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
|
|||||||
@@ -3,32 +3,75 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Pièces de la machine</h2>
|
<h2 class="card-title">Pièces de la machine</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="isEditMode"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm md:btn-md btn-primary"
|
||||||
|
@click="$emit('add-piece')"
|
||||||
|
>
|
||||||
|
Ajouter une pièce
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
@click="$emit('toggle-collapse')"
|
||||||
|
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
||||||
|
>
|
||||||
|
<IconLucideChevronRight
|
||||||
|
class="w-5 h-5 transition-transform"
|
||||||
|
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
||||||
<PieceItem
|
Aucune pièce associée à cette machine.
|
||||||
v-for="piece in pieces"
|
</div>
|
||||||
:key="piece.id"
|
|
||||||
:piece="piece"
|
<div v-else class="space-y-2">
|
||||||
:is-edit-mode="isEditMode"
|
<div v-for="piece in pieces" :key="piece.id" class="relative">
|
||||||
@update="$emit('update-piece', $event)"
|
<button
|
||||||
@edit="$emit('edit-piece', $event)"
|
v-if="isEditMode"
|
||||||
@custom-field-update="$emit('custom-field-update', $event)"
|
type="button"
|
||||||
/>
|
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
|
||||||
|
title="Supprimer cette pièce"
|
||||||
|
@click="$emit('remove-piece', piece.linkId || piece.id)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<PieceItem
|
||||||
|
:piece="piece"
|
||||||
|
:is-edit-mode="false"
|
||||||
|
:collapse-all="collapsed"
|
||||||
|
:toggle-token="collapseToggleToken"
|
||||||
|
@edit="$emit('edit-piece', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
pieces: any[]
|
pieces: any[]
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
|
collapsed: boolean
|
||||||
|
collapseToggleToken: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'update-piece': [piece: any]
|
'toggle-collapse': []
|
||||||
'edit-piece': [piece: any]
|
'edit-piece': [piece: any]
|
||||||
'custom-field-update': [fieldUpdate: any]
|
'add-piece': []
|
||||||
|
'remove-piece': [linkId: string]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,29 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<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">
|
||||||
|
<DocumentPreviewModal
|
||||||
|
:document="previewDocument"
|
||||||
|
:visible="previewVisible"
|
||||||
|
:documents="previewDocumentList"
|
||||||
|
@close="closePreview"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title">Produits associés</h2>
|
<h2 class="card-title">Produits associés</h2>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
Produits sélectionnés directement pour cette machine selon le squelette.
|
Produits sélectionnés directement pour cette machine.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge badge-outline" v-if="products.length">
|
<div class="flex items-center gap-2">
|
||||||
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
<button
|
||||||
</span>
|
v-if="isEditMode"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm md:btn-md btn-primary"
|
||||||
|
@click="$emit('add-product')"
|
||||||
|
>
|
||||||
|
Ajouter un produit
|
||||||
|
</button>
|
||||||
|
<span class="badge badge-outline" v-if="products.length">
|
||||||
|
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="products.length" class="space-y-3">
|
<div v-if="products.length" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="product in products"
|
v-for="product in products"
|
||||||
:key="product.id || product.name"
|
:key="product.id || product.name"
|
||||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-1"
|
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2 relative"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="isEditMode"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs absolute top-2 right-2"
|
||||||
|
title="Supprimer ce produit"
|
||||||
|
@click="$emit('remove-product', (product.linkId || product.id) as string)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
<p class="font-semibold text-base-content">
|
<p class="font-semibold text-base-content">
|
||||||
{{ product.name }}
|
{{ product.name }}
|
||||||
</p>
|
</p>
|
||||||
<span class="badge badge-ghost badge-sm">
|
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||||
{{ product.groupLabel }}
|
{{ product.groupLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,6 +65,53 @@
|
|||||||
<span class="font-medium">Prix indicatif :</span>
|
<span class="font-medium">Prix indicatif :</span>
|
||||||
<span class="ml-1">{{ product.priceLabel }}</span>
|
<span class="ml-1">{{ product.priceLabel }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Documents liés au produit -->
|
||||||
|
<div v-if="product.documents?.length" class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs font-medium text-base-content/70">Documents :</p>
|
||||||
|
<div
|
||||||
|
v-for="doc in product.documents"
|
||||||
|
:key="doc.id || doc.name"
|
||||||
|
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 text-xs">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="documentIcon(doc).component"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="documentIcon(doc).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-base-content">{{ doc.name }}</div>
|
||||||
|
<div class="text-xs text-base-content/60">
|
||||||
|
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(doc)"
|
||||||
|
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||||
|
@click="openPreview(doc, product.documents || [])"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="downloadDocument(doc)"
|
||||||
|
>
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-gray-500">
|
<p v-else class="text-xs text-gray-500">
|
||||||
@@ -49,14 +122,53 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
|
import {
|
||||||
|
formatSize,
|
||||||
|
documentIcon,
|
||||||
|
downloadDocument,
|
||||||
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
products: Array<{
|
products: Array<{
|
||||||
id?: string | null
|
id?: string | null
|
||||||
|
linkId?: string | null
|
||||||
name?: string
|
name?: string
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
supplierLabel?: string | null
|
supplierLabel?: string | null
|
||||||
priceLabel?: string | null
|
priceLabel?: string | null
|
||||||
groupLabel?: string
|
groupLabel?: string
|
||||||
|
documents?: Array<{
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
mimeType?: string
|
||||||
|
size?: number
|
||||||
|
fileUrl?: string
|
||||||
|
downloadUrl?: string
|
||||||
|
}>
|
||||||
}>
|
}>
|
||||||
|
isEditMode: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'add-product': []
|
||||||
|
'remove-product': [linkId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const previewDocument = ref<any>(null)
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
const previewDocumentList = ref<any[]>([])
|
||||||
|
|
||||||
|
const openPreview = (doc: any, docs: any[]) => {
|
||||||
|
previewDocument.value = doc
|
||||||
|
previewDocumentList.value = docs
|
||||||
|
previewVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePreview = () => {
|
||||||
|
previewVisible.value = false
|
||||||
|
previewDocument.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="componentRequirementGroups.length || pieceRequirementGroups.length || productRequirementGroups.length"
|
|
||||||
class="card bg-base-100 shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="card-body space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="card-title">Structure sélectionnée</h2>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Synthèse des familles définies dans le type et des modèles utilisés pour cette machine.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Component requirement groups -->
|
|
||||||
<div v-if="componentRequirementGroups.length" class="space-y-4">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700">Composants</h3>
|
|
||||||
<div
|
|
||||||
v-for="group in componentRequirementGroups"
|
|
||||||
:key="group.requirement.id"
|
|
||||||
class="rounded-lg border border-base-200 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-sm">
|
|
||||||
{{ group.requirement.label || group.requirement.typeComposant?.name || 'Famille de composants' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ group.requirement.typeComposant?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-outline badge-sm">{{ group.components.length }} composant(s)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="group.components.length" class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="component in group.components"
|
|
||||||
:key="component.id"
|
|
||||||
class="flex flex-wrap items-center gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<span class="font-medium">{{ component.name }}</span>
|
|
||||||
<span v-if="component.parentComposantId" class="text-xs text-gray-500">
|
|
||||||
(Sous-composant)
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
v-if="summarizeCustomFields(component.customFields || []).length"
|
|
||||||
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="field in summarizeCustomFields(component.customFields || [])"
|
|
||||||
:key="field.key"
|
|
||||||
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
|
||||||
>
|
|
||||||
<span class="font-medium">{{ field.label }} :</span>
|
|
||||||
<span class="ml-1">{{ field.value }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<SkeletonProductDisplay :product-display="component.__productDisplay" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-xs text-gray-500">Aucun composant rattaché à ce groupe.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Piece requirement groups -->
|
|
||||||
<div v-if="pieceRequirementGroups.length" class="space-y-4">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700">Pièces principales</h3>
|
|
||||||
<div
|
|
||||||
v-for="group in pieceRequirementGroups"
|
|
||||||
:key="group.requirement.id"
|
|
||||||
class="rounded-lg border border-base-200 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-sm">
|
|
||||||
{{ group.requirement.label || group.requirement.typePiece?.name || 'Groupe de pièces' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ group.requirement.typePiece?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-outline badge-sm">{{ group.pieces.length }} pièce(s)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="group.pieces.length" class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="piece in group.pieces"
|
|
||||||
:key="piece.id"
|
|
||||||
class="flex flex-wrap items-center gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<span class="font-medium">{{ piece.name }}</span>
|
|
||||||
<span v-if="piece.parentComponentName" class="text-xs text-gray-500">
|
|
||||||
(Rattachée à {{ piece.parentComponentName }})
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
v-if="summarizeCustomFields(piece.customFields || []).length"
|
|
||||||
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="field in summarizeCustomFields(piece.customFields || [])"
|
|
||||||
:key="field.key"
|
|
||||||
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
|
||||||
>
|
|
||||||
<span class="font-medium">{{ field.label }} :</span>
|
|
||||||
<span class="ml-1">{{ field.value }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<SkeletonProductDisplay :product-display="piece.__productDisplay" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-xs text-gray-500">Aucune pièce rattachée à ce groupe.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product requirement groups -->
|
|
||||||
<div v-if="productRequirementGroups.length" class="space-y-4">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700">Produits requis</h3>
|
|
||||||
<div
|
|
||||||
v-for="group in productRequirementGroups"
|
|
||||||
:key="group.requirement.id"
|
|
||||||
class="rounded-lg border border-base-200 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-sm">
|
|
||||||
{{ group.requirement.label || group.requirement.typeProduct?.name || 'Groupe de produits' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Catégorie : {{ group.requirement.typeProduct?.name || 'Non définie' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="badge badge-outline badge-sm">Total {{ group.totalCount }}</span>
|
|
||||||
<span class="badge badge-ghost badge-sm">Direct {{ group.directProducts.length }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 mb-3">
|
|
||||||
Via composants : {{ group.componentCount }} • Via pièces : {{ group.pieceCount }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="group.directProducts.length" class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="product in group.directProducts"
|
|
||||||
:key="product.id || product.name"
|
|
||||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm"
|
|
||||||
>
|
|
||||||
<div class="font-medium">{{ product.name }}</div>
|
|
||||||
<div v-if="product.reference" class="text-xs text-gray-500">
|
|
||||||
Référence : {{ product.reference }}
|
|
||||||
</div>
|
|
||||||
<div v-if="product.supplierLabel" class="text-xs text-gray-500">
|
|
||||||
Fournisseurs : {{ product.supplierLabel }}
|
|
||||||
</div>
|
|
||||||
<div v-if="product.priceLabel" class="text-xs text-gray-500">
|
|
||||||
Prix indicatif : {{ product.priceLabel }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-xs text-gray-500">
|
|
||||||
Aucune sélection directe. Couverture assurée via composants ou pièces associés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { defineComponent } from 'vue'
|
|
||||||
import { summarizeCustomFields } from '~/shared/utils/customFieldUtils'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
componentRequirementGroups: any[]
|
|
||||||
pieceRequirementGroups: any[]
|
|
||||||
productRequirementGroups: any[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const SkeletonProductDisplay = defineComponent({
|
|
||||||
name: 'SkeletonProductDisplay',
|
|
||||||
props: {
|
|
||||||
productDisplay: { type: Object, default: null },
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<div v-if="productDisplay" class="w-full text-xs text-gray-600 space-y-1">
|
|
||||||
<div><span class="font-medium">Produit :</span> <span>{{ productDisplay.name }}</span></div>
|
|
||||||
<div v-if="productDisplay.category"><span class="font-medium">Catégorie :</span> <span>{{ productDisplay.category }}</span></div>
|
|
||||||
<div v-if="productDisplay.reference"><span class="font-medium">Référence :</span> <span>{{ productDisplay.reference }}</span></div>
|
|
||||||
<div v-if="productDisplay.suppliers"><span class="font-medium">Fournisseurs :</span> <span>{{ productDisplay.suppliers }}</span></div>
|
|
||||||
<div v-if="productDisplay.price"><span class="font-medium">Prix indicatif :</span> <span>{{ productDisplay.price }}</span></div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="preview" class="space-y-4">
|
|
||||||
<div class="border border-base-200 rounded-lg bg-base-100/80">
|
|
||||||
<div class="p-4 space-y-4">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
|
||||||
<IconLucideEye class="w-4 h-4" aria-hidden="true" />
|
|
||||||
<span>Prévisualisation avant création</span>
|
|
||||||
</div>
|
|
||||||
<span class="badge" :class="getStatusBadgeClass(preview.status)">
|
|
||||||
{{ preview.status === 'ready' ? 'Prête à créer' : preview.status === 'warning' ? 'À compléter' : 'Bloquante' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div
|
|
||||||
v-for="field in preview.base.fields"
|
|
||||||
:key="field.key"
|
|
||||||
class="flex flex-col gap-1"
|
|
||||||
>
|
|
||||||
<span class="text-[11px] uppercase tracking-wide text-gray-500">{{ field.label }}</span>
|
|
||||||
<span
|
|
||||||
class="text-sm font-medium"
|
|
||||||
:class="field.status === 'missing'
|
|
||||||
? 'text-error'
|
|
||||||
: field.status === 'optional'
|
|
||||||
? 'text-gray-500 italic'
|
|
||||||
: 'text-gray-900'"
|
|
||||||
>
|
|
||||||
{{ field.display }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
|
|
||||||
<span class="badge badge-ghost badge-sm">Type : {{ preview.type.name }}</span>
|
|
||||||
<span v-if="preview.type.category" class="badge badge-ghost badge-sm">Catégorie : {{ preview.type.category }}</span>
|
|
||||||
<span class="badge badge-ghost badge-sm">Structure JSON : {{ preview.type.hasStructuredDefinition ? 'Oui' : 'Legacy' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Base issues -->
|
|
||||||
<div v-if="preview.base.issues.length" class="rounded-md bg-warning/10 border border-warning/30 p-3 text-xs text-warning">
|
|
||||||
<p class="font-medium mb-1">
|
|
||||||
Informations générales incomplètes :
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li v-for="issue in preview.base.issues" :key="issue.message">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-start gap-2 text-left hover:underline"
|
|
||||||
@click="handleIssueClick(issue)"
|
|
||||||
>
|
|
||||||
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
|
||||||
<span>{{ issue.message }}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Component groups -->
|
|
||||||
<div v-if="preview.componentGroups.length" class="space-y-3">
|
|
||||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
|
||||||
Composants hérités
|
|
||||||
</h5>
|
|
||||||
<PreviewRequirementGroup
|
|
||||||
v-for="group in preview.componentGroups"
|
|
||||||
:key="group.id"
|
|
||||||
:group="group"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-xs text-gray-500">
|
|
||||||
Aucun composant n'est requis pour ce type de machine.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Piece groups -->
|
|
||||||
<div v-if="preview.pieceGroups.length" class="space-y-3">
|
|
||||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
|
||||||
Pièces associées
|
|
||||||
</h5>
|
|
||||||
<PreviewRequirementGroup
|
|
||||||
v-for="group in preview.pieceGroups"
|
|
||||||
:key="group.id"
|
|
||||||
:group="group"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-xs text-gray-500">
|
|
||||||
Aucun groupe de pièces à configurer pour ce type.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product groups -->
|
|
||||||
<div v-if="preview.productGroups.length" class="space-y-3">
|
|
||||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
|
||||||
Produits requis
|
|
||||||
</h5>
|
|
||||||
<div
|
|
||||||
v-for="group in preview.productGroups"
|
|
||||||
:key="group.id"
|
|
||||||
:id="`product-group-${group.id}`"
|
|
||||||
class="border border-base-200 rounded-md p-3 space-y-3"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold">
|
|
||||||
{{ group.label }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
|
|
||||||
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
|
||||||
Couverture : {{ group.count }}
|
|
||||||
</span>
|
|
||||||
<span class="badge badge-ghost badge-sm">
|
|
||||||
Direct {{ group.completed }} / {{ group.total || 0 }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
|
||||||
<ul class="list-disc pl-4 space-y-1">
|
|
||||||
<li v-for="issue in group.issues" :key="issue.message">
|
|
||||||
{{ issue.message }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-if="group.entries?.length" class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="entry in group.entries"
|
|
||||||
:key="entry.key"
|
|
||||||
class="flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
|
||||||
class="w-4 h-4 mt-0.5"
|
|
||||||
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
|
||||||
{{ entry.title }}
|
|
||||||
</p>
|
|
||||||
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
|
||||||
{{ entry.subtitle }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p v-else class="text-xs text-gray-500">
|
|
||||||
Couverture assurée via composants ou pièces liés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Global issues -->
|
|
||||||
<div
|
|
||||||
v-if="preview.issues.length && preview.status !== 'ready'"
|
|
||||||
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<IconLucideAlertTriangle class="w-4 h-4 mt-0.5" aria-hidden="true" />
|
|
||||||
<div class="space-y-1">
|
|
||||||
<p class="font-medium">
|
|
||||||
Points à vérifier avant la création :
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li v-for="issue in preview.issues" :key="`${issue.scope}-${issue.message}`">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-start gap-2 text-left hover:underline"
|
|
||||||
@click="handleIssueClick(issue)"
|
|
||||||
>
|
|
||||||
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
|
||||||
<span>
|
|
||||||
<span class="font-medium">{{ issue.scope }} :</span> {{ issue.message }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
getStatusBadgeClass,
|
|
||||||
handleIssueClick,
|
|
||||||
} from '~/composables/useMachineCreatePreview'
|
|
||||||
import PreviewRequirementGroup from './PreviewRequirementGroup.vue'
|
|
||||||
import IconLucideEye from '~icons/lucide/eye'
|
|
||||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
|
||||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
|
||||||
import IconLucideCircle from '~icons/lucide/circle'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
preview: any
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="border border-base-200 rounded-md p-3 space-y-3">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold">
|
|
||||||
{{ group.label }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ group.typeName }} · Min {{ group.min }} ·
|
|
||||||
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
|
||||||
{{ group.completed }} / {{ group.total || 0 }} complétée(s)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
|
||||||
<ul class="list-disc pl-4 space-y-1">
|
|
||||||
<li v-for="issue in group.issues" :key="issue.message">
|
|
||||||
{{ issue.message }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="entry in group.entries"
|
|
||||||
:key="entry.key"
|
|
||||||
class="flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
|
||||||
class="w-4 h-4 mt-0.5"
|
|
||||||
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
|
||||||
{{ entry.title }}
|
|
||||||
</p>
|
|
||||||
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
|
||||||
{{ entry.subtitle }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { getStatusBadgeClass } from '~/composables/useMachineCreatePreview'
|
|
||||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
|
||||||
import IconLucideCircle from '~icons/lucide/circle'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
group: any
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="requirements?.length" class="space-y-4">
|
|
||||||
<h4 class="text-sm font-semibold">
|
|
||||||
Sélection des composants
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="requirement in requirements"
|
|
||||||
:id="`component-group-${requirement.id}`"
|
|
||||||
:key="requirement.id"
|
|
||||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h5 class="font-medium text-sm">
|
|
||||||
{{ requirement.label || requirement.typeComposant?.name || 'Famille de composants' }}
|
|
||||||
</h5>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ requirement.typeComposant?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
|
||||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-outline"
|
|
||||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
|
||||||
@click="$emit('add-entry', requirement)"
|
|
||||||
>
|
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
|
||||||
Aucun composant sélectionné pour ce groupe.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
|
||||||
:key="`${requirement.id}-${entryIndex}`"
|
|
||||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
|
||||||
<span>
|
|
||||||
Type appliqué :
|
|
||||||
{{ resolveTypeLabel(requirement, entry) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-square btn-xs btn-error"
|
|
||||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
|
||||||
>
|
|
||||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs">Composant existant</span>
|
|
||||||
</label>
|
|
||||||
<SearchSelect
|
|
||||||
:model-value="entry.composantId || ''"
|
|
||||||
:options="getOptions(requirement, entry)"
|
|
||||||
:loading="loading"
|
|
||||||
size="sm"
|
|
||||||
placeholder="Rechercher un composant…"
|
|
||||||
empty-text="Aucun composant disponible"
|
|
||||||
:option-label="optionLabel"
|
|
||||||
:option-description="optionDescription"
|
|
||||||
@update:modelValue="$emit('set-component', requirement, entryIndex, $event || '')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="getOptions(requirement, entry).length === 0"
|
|
||||||
class="text-xs text-error"
|
|
||||||
>
|
|
||||||
Aucun composant disponible pour cette famille.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="entry.composantId"
|
|
||||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
|
||||||
>
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ findById(entry.composantId)?.name || "Composant" }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Référence : {{ findById(entry.composantId)?.reference || "—" }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Fournisseur :
|
|
||||||
{{ findById(entry.composantId)?.constructeur?.name || findById(entry.composantId)?.constructeurName || "—" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
import IconLucideX from '~icons/lucide/x'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
requirements: any[]
|
|
||||||
loading: boolean
|
|
||||||
getEntries: (requirementId: string) => any[]
|
|
||||||
getOptions: (requirement: any, entry: any) => any[]
|
|
||||||
resolveTypeLabel: (requirement: any, entry: any) => string
|
|
||||||
findById: (id: string) => any
|
|
||||||
optionLabel: (item: any) => string
|
|
||||||
optionDescription: (item: any) => string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'add-entry': [requirement: any]
|
|
||||||
'remove-entry': [requirementId: string, entryIndex: number]
|
|
||||||
'set-component': [requirement: any, entryIndex: number, componentId: string]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="requirements?.length" class="space-y-4">
|
|
||||||
<h4 class="text-sm font-semibold">
|
|
||||||
Sélection des pièces principales
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="requirement in requirements"
|
|
||||||
:id="`piece-group-${requirement.id}`"
|
|
||||||
:key="requirement.id"
|
|
||||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h5 class="font-medium text-sm">
|
|
||||||
{{ requirement.label || requirement.typePiece?.name || 'Groupe de pièces' }}
|
|
||||||
</h5>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ requirement.typePiece?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
|
||||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-outline"
|
|
||||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
|
||||||
@click="$emit('add-entry', requirement)"
|
|
||||||
>
|
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
|
||||||
Aucune pièce sélectionnée pour ce groupe.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
|
||||||
:key="`${requirement.id}-piece-${entryIndex}`"
|
|
||||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
|
||||||
<span>
|
|
||||||
Type appliqué :
|
|
||||||
{{ resolveTypeLabel(requirement, entry) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-square btn-xs btn-error"
|
|
||||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
|
||||||
>
|
|
||||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs">Pièce existante</span>
|
|
||||||
</label>
|
|
||||||
<SearchSelect
|
|
||||||
:model-value="entry.pieceId || ''"
|
|
||||||
:options="getOptions(requirement, entry, entryIndex)"
|
|
||||||
:loading="loading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
|
|
||||||
size="sm"
|
|
||||||
placeholder="Rechercher une pièce…"
|
|
||||||
empty-text="Aucune pièce disponible"
|
|
||||||
:option-label="optionLabel"
|
|
||||||
:option-description="optionDescription"
|
|
||||||
@search="(term: string) => $emit('search', requirement, entryIndex, term)"
|
|
||||||
@update:modelValue="$emit('set-piece', requirement, entryIndex, $event || '')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="getOptions(requirement, entry, entryIndex).length === 0"
|
|
||||||
class="text-xs text-error"
|
|
||||||
>
|
|
||||||
Aucune pièce disponible pour cette famille.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="entry.pieceId"
|
|
||||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
|
||||||
>
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ findById(entry.pieceId)?.name || "Pièce" }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Référence : {{ findById(entry.pieceId)?.reference || "—" }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Fournisseur :
|
|
||||||
{{ findById(entry.pieceId)?.constructeur?.name || findById(entry.pieceId)?.constructeurName || "—" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
import IconLucideX from '~icons/lucide/x'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
requirements: any[]
|
|
||||||
loading: boolean
|
|
||||||
pieceLoadingByKey: Record<string, boolean>
|
|
||||||
getEntries: (requirementId: string) => any[]
|
|
||||||
getOptions: (requirement: any, entry: any, entryIndex: number) => any[]
|
|
||||||
getPieceKey: (requirement: any, entryIndex: number) => string
|
|
||||||
resolveTypeLabel: (requirement: any, entry: any) => string
|
|
||||||
findById: (id: string) => any
|
|
||||||
optionLabel: (item: any) => string
|
|
||||||
optionDescription: (item: any) => string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'add-entry': [requirement: any]
|
|
||||||
'remove-entry': [requirementId: string, entryIndex: number]
|
|
||||||
'set-piece': [requirement: any, entryIndex: number, pieceId: string]
|
|
||||||
'search': [requirement: any, entryIndex: number, term: string]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="requirements?.length" class="space-y-4">
|
|
||||||
<h4 class="text-sm font-semibold">
|
|
||||||
Produits catalogue requis
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="requirement in requirements"
|
|
||||||
:id="`product-group-${requirement.id}`"
|
|
||||||
:key="requirement.id"
|
|
||||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h5 class="font-medium text-sm">
|
|
||||||
{{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
|
|
||||||
</h5>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
|
||||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="(requirement.allowNewModels ?? true) === false"
|
|
||||||
class="text-xs text-error"
|
|
||||||
>
|
|
||||||
Sélection de produits existants uniquement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-outline"
|
|
||||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
|
||||||
@click="$emit('add-entry', requirement)"
|
|
||||||
>
|
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
|
||||||
Aucun produit sélectionné pour ce groupe.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
|
||||||
:key="`${requirement.id}-product-${entryIndex}`"
|
|
||||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
|
||||||
<span>
|
|
||||||
Catégorie appliquée :
|
|
||||||
{{ requirement.typeProduct?.name || 'Non définie' }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-square btn-xs btn-error"
|
|
||||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
|
||||||
>
|
|
||||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs">Produit existant</span>
|
|
||||||
</label>
|
|
||||||
<ProductSelect
|
|
||||||
:model-value="entry.productId || ''"
|
|
||||||
:type-product-id="requirement.typeProductId || requirement.typeProduct?.id || null"
|
|
||||||
:placeholder="productsLoading ? 'Chargement…' : 'Sélectionner un produit…'"
|
|
||||||
empty-text="Aucun produit disponible pour cette catégorie"
|
|
||||||
:disabled="productsLoading"
|
|
||||||
@update:modelValue="$emit('set-product', requirement, entryIndex, $event || '')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="!productsLoading && getProductOptions(requirement).length === 0"
|
|
||||||
class="text-xs text-error"
|
|
||||||
>
|
|
||||||
Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="entry.productId"
|
|
||||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
|
||||||
>
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ findById(entry.productId)?.name || 'Produit' }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Référence : {{ findById(entry.productId)?.reference || "—" }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Prix indicatif :
|
|
||||||
<span
|
|
||||||
v-if="findById(entry.productId)?.supplierPrice !== undefined && findById(entry.productId)?.supplierPrice !== null"
|
|
||||||
>
|
|
||||||
{{ Number(findById(entry.productId)?.supplierPrice).toFixed(2) }} €
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Fournisseurs :
|
|
||||||
<span v-if="findById(entry.productId)?.constructeurs?.length">
|
|
||||||
{{ findById(entry.productId)?.constructeurs.map((constructeur: any) => constructeur?.name).filter(Boolean).join(', ') }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ProductSelect from '~/components/ProductSelect.vue'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
import IconLucideX from '~icons/lucide/x'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
requirements: any[]
|
|
||||||
productsLoading: boolean
|
|
||||||
getEntries: (requirementId: string) => any[]
|
|
||||||
getProductOptions: (requirement: any) => any[]
|
|
||||||
findById: (id: string) => any
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'add-entry': [requirement: any]
|
|
||||||
'remove-entry': [requirementId: string, entryIndex: number]
|
|
||||||
'set-product': [requirement: any, entryIndex: number, productId: string]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
172
app/components/model-types/ConversionModal.vue
Normal file
172
app/components/model-types/ConversionModal.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
|
Convertir la catégorie
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Vérification de la conversion…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="checkError" class="mt-4 text-sm text-error">
|
||||||
|
{{ checkError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocked state -->
|
||||||
|
<template v-else-if="checkResult && !checkResult.canConvert">
|
||||||
|
<p class="mt-3 text-sm text-base-content/70">
|
||||||
|
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
|
||||||
|
</p>
|
||||||
|
<ul class="mt-3 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(blocker, i) in checkResult.blockers"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
|
||||||
|
>
|
||||||
|
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
{{ blocker }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Eligible state -->
|
||||||
|
<template v-else-if="checkResult && checkResult.canConvert">
|
||||||
|
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
|
||||||
|
<p class="text-sm font-medium text-warning">
|
||||||
|
{{ directionLabel }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70">
|
||||||
|
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="checkResult.names.length > 0"
|
||||||
|
class="mt-3 rounded-xl border border-base-200 bg-base-100"
|
||||||
|
>
|
||||||
|
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
|
||||||
|
Éléments concernés :
|
||||||
|
</p>
|
||||||
|
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
|
||||||
|
<li
|
||||||
|
v-for="(name, i) in checkResult.names"
|
||||||
|
:key="i"
|
||||||
|
class="py-1.5 text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="convertError" class="mt-3 text-sm text-error">
|
||||||
|
{{ convertError }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="checkResult?.canConvert"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="doConvert"
|
||||||
|
>
|
||||||
|
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x';
|
||||||
|
import {
|
||||||
|
checkConversion,
|
||||||
|
convertCategory,
|
||||||
|
type ConversionCheck,
|
||||||
|
type ModelType,
|
||||||
|
} from '~/services/modelTypes';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
modelType: ModelType | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'converted'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const checking = ref(false);
|
||||||
|
const checkError = ref<string | null>(null);
|
||||||
|
const checkResult = ref<ConversionCheck | null>(null);
|
||||||
|
const converting = ref(false);
|
||||||
|
const convertError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const directionLabel = computed(() => {
|
||||||
|
if (!checkResult.value) return '';
|
||||||
|
return checkResult.value.direction === 'piece_to_component'
|
||||||
|
? 'Conversion : Catégorie de pièce → Catégorie de composant'
|
||||||
|
: 'Conversion : Catégorie de composant → Catégorie de pièce';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (isOpen) => {
|
||||||
|
if (!isOpen || !props.modelType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checking.value = true;
|
||||||
|
checkError.value = null;
|
||||||
|
checkResult.value = null;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
checkResult.value = await checkConversion(props.modelType.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
checkError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const doConvert = async () => {
|
||||||
|
if (!props.modelType) return;
|
||||||
|
|
||||||
|
converting.value = true;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await convertCategory(props.modelType.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
convertError.value = result.error || 'La conversion a échoué.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('converted');
|
||||||
|
} catch (err: any) {
|
||||||
|
convertError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
|
||||||
|
} finally {
|
||||||
|
converting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -9,30 +9,101 @@
|
|||||||
</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
|
||||||
@update:category="onCategoryChange"
|
v-for="option in categories"
|
||||||
@update:search="onSearchInput"
|
:key="option.value"
|
||||||
@update:sort="onSortChange"
|
type="button"
|
||||||
@update:dir="onDirChange"
|
class="tab"
|
||||||
@create="openCreatePage"
|
:class="{ 'tab-active': option.value === selectedCategory }"
|
||||||
/>
|
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"
|
||||||
@related="openRelatedModal"
|
row-key="id"
|
||||||
@edit="openEditPage"
|
empty-message="Aucune catégorie trouvée."
|
||||||
@delete="confirmDelete"
|
no-results-message="Aucune catégorie ne correspond à votre recherche."
|
||||||
@update:offset="onOffsetChange"
|
@sort="handleSort"
|
||||||
|
@update:current-page="handlePageChange"
|
||||||
|
@update:per-page="handlePerPageChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||||
|
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
v-model="searchInput"
|
||||||
|
type="search"
|
||||||
|
class="grow min-w-0"
|
||||||
|
placeholder="Rechercher par nom…"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="openCreatePage"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-medium">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-notes="{ row }">
|
||||||
|
<span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
|
||||||
|
<span v-else class="text-base-content/50">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||||
|
Liés
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit && showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-warning"
|
||||||
|
@click="openConversionModal(row)"
|
||||||
|
>
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<ModelTypesConversionModal
|
||||||
|
:open="conversionModalOpen"
|
||||||
|
:model-type="conversionTarget"
|
||||||
|
@close="closeConversionModal"
|
||||||
|
@converted="onConverted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||||
@@ -46,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>
|
||||||
|
|
||||||
@@ -92,106 +163,174 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from '#imports'
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||||
import { useApi } from "~/composables/useApi";
|
import { useApi } from '~/composables/useApi'
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
type ModelCategory,
|
type ModelCategory,
|
||||||
type ModelType,
|
type ModelType,
|
||||||
type ModelTypeListResponse,
|
type ModelTypeListResponse,
|
||||||
} from "~/services/modelTypes";
|
} from '~/services/modelTypes'
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from '~/composables/useToast'
|
||||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
|
import IconLucideSearch from '~icons/lucide/search'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
const DEFAULT_DESCRIPTION =
|
const DEFAULT_DESCRIPTION
|
||||||
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
category: ModelCategory;
|
category: ModelCategory
|
||||||
heading: string;
|
heading: string
|
||||||
description?: string;
|
description?: string
|
||||||
allowCategorySwitch?: boolean;
|
allowCategorySwitch?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
allowCategorySwitch: false,
|
allowCategorySwitch: false,
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
const selectedCategory = ref<ModelCategory>(props.category);
|
const selectedCategory = ref<ModelCategory>(props.category)
|
||||||
const searchInput = ref("");
|
const searchInput = ref('')
|
||||||
const searchTerm = ref("");
|
|
||||||
const sort = ref<"name" | "createdAt">("name");
|
|
||||||
const dir = ref<"asc" | "desc">("asc");
|
|
||||||
const limit = ref(20);
|
|
||||||
const offset = ref(0);
|
|
||||||
|
|
||||||
const items = ref<ModelType[]>([]);
|
// State synced with URL query params
|
||||||
const total = ref(0);
|
const urlState = useUrlState({
|
||||||
const loading = ref(false);
|
q: { default: '' },
|
||||||
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
limit: { default: 20, type: 'number' },
|
||||||
|
offset: { default: 0, type: 'number' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => {
|
||||||
|
searchInput.value = urlState.q.value
|
||||||
|
doRefresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const searchTerm = urlState.q
|
||||||
|
const sort = urlState.sort as Ref<'name' | 'createdAt'>
|
||||||
|
const dir = urlState.dir as Ref<'asc' | 'desc'>
|
||||||
|
const limit = urlState.limit
|
||||||
|
const offset = urlState.offset
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
// Initialize searchInput from URL
|
||||||
let activeController: AbortController | null = null;
|
searchInput.value = searchTerm.value
|
||||||
|
|
||||||
const router = useRouter();
|
const items = ref<ModelType[]>([])
|
||||||
const { showError, showSuccess } = useToast();
|
const total = ref(0)
|
||||||
const { get } = useApi();
|
const loading = ref(false)
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const descriptionText = computed(
|
let activeController: AbortController | null = null
|
||||||
() => props.description ?? DEFAULT_DESCRIPTION
|
|
||||||
);
|
|
||||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
|
|
||||||
|
|
||||||
useHead(() => ({
|
const router = useRouter()
|
||||||
title: headingText.value,
|
const { showError, showSuccess } = useToast()
|
||||||
}));
|
const { get } = useApi()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown) => {
|
const headingText = computed(() => props.heading)
|
||||||
if (error && typeof error === "object") {
|
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
|
||||||
|
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
|
||||||
|
|
||||||
|
useHead(() => ({ title: headingText.value }))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const showConvertButton = computed(() =>
|
||||||
|
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||||
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
|
{ label: 'Pièces', value: 'PIECE' },
|
||||||
|
{ label: 'Produits', value: 'PRODUCT' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Sort state for DataTable
|
||||||
|
const currentSort = computed<DataTableSort>(() => ({
|
||||||
|
field: sort.value,
|
||||||
|
direction: dir.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSort = (newSort: DataTableSort) => {
|
||||||
|
sort.value = newSort.field as 'name' | 'createdAt'
|
||||||
|
dir.value = newSort.direction as 'asc' | 'desc'
|
||||||
|
offset.value = 0
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination: convert offset/limit to page-based for DataTable
|
||||||
|
const currentPage = computed(() => {
|
||||||
|
if (limit.value <= 0) return 1
|
||||||
|
return Math.floor(offset.value / limit.value) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (limit.value <= 0) return 1
|
||||||
|
return Math.max(1, Math.ceil(total.value / limit.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginationState = computed(() => ({
|
||||||
|
currentPage: currentPage.value,
|
||||||
|
totalPages: totalPages.value,
|
||||||
|
totalItems: total.value,
|
||||||
|
pageItems: items.value.length,
|
||||||
|
perPageOptions: [20, 50, 100],
|
||||||
|
perPage: limit.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
offset.value = (page - 1) * limit.value
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerPageChange = (perPage: number) => {
|
||||||
|
limit.value = perPage
|
||||||
|
offset.value = 0
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractErrorMessage = (error: unknown): string => {
|
||||||
|
let raw: string | null = null
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
const maybeFetchError = error as {
|
const maybeFetchError = error as {
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>
|
||||||
statusMessage?: string;
|
statusMessage?: string
|
||||||
message?: string;
|
message?: string
|
||||||
};
|
}
|
||||||
if (maybeFetchError.data) {
|
if (maybeFetchError.data) {
|
||||||
const data = maybeFetchError.data;
|
const data = maybeFetchError.data
|
||||||
if (typeof data.message === "string") {
|
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||||
return data.message;
|
else if (typeof data.detail === 'string') raw = data.detail
|
||||||
}
|
else if (typeof data.message === 'string') raw = data.message
|
||||||
if (Array.isArray(data.message) && data.message.length > 0) {
|
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
|
||||||
return data.message[0];
|
else if (typeof data.error === 'string') raw = data.error
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.statusMessage === "string") {
|
|
||||||
return maybeFetchError.statusMessage;
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.message === "string") {
|
|
||||||
return maybeFetchError.message;
|
|
||||||
}
|
}
|
||||||
|
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
|
||||||
|
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
|
||||||
}
|
}
|
||||||
return "Une erreur est survenue lors de la communication avec le serveur.";
|
return humanizeError(raw)
|
||||||
};
|
}
|
||||||
|
|
||||||
const refresh = async ({
|
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
|
||||||
resetOffset = false,
|
if (resetOffset) offset.value = 0
|
||||||
}: { resetOffset?: boolean } = {}) => {
|
|
||||||
if (resetOffset) {
|
|
||||||
offset.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeController) {
|
if (activeController) activeController.abort()
|
||||||
activeController.abort();
|
const controller = new AbortController()
|
||||||
}
|
activeController = controller
|
||||||
|
loading.value = true
|
||||||
const controller = new AbortController();
|
|
||||||
activeController = controller;
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: ModelTypeListResponse = await listModelTypes(
|
const response: ModelTypeListResponse = await listModelTypes(
|
||||||
@@ -203,292 +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 conversionTarget = ref<ModelType | null>(null)
|
||||||
|
|
||||||
|
const openConversionModal = (item: ModelType) => {
|
||||||
|
conversionTarget.value = item
|
||||||
|
conversionModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConversionModal = () => {
|
||||||
|
conversionModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConverted = () => {
|
||||||
|
conversionModalOpen.value = false
|
||||||
|
invalidateEntityTypeCache('PIECE')
|
||||||
|
invalidateEntityTypeCache('COMPONENT')
|
||||||
|
showSuccess('Catégorie convertie avec succès.')
|
||||||
|
doRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
}
|
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
searchTerm.value = value.trim();
|
searchTerm.value = value.trim()
|
||||||
refresh({ resetOffset: true });
|
doRefresh({ resetOffset: true })
|
||||||
}, 300);
|
}, 300)
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refresh();
|
doRefresh()
|
||||||
});
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
clearTimeout(debounceTimer);
|
if (activeController) activeController.abort()
|
||||||
}
|
})
|
||||||
if (activeController) {
|
|
||||||
activeController.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
name="category"
|
name="category"
|
||||||
required
|
required
|
||||||
:disabled="lockCategory"
|
:disabled="lockCategory || isReadonly"
|
||||||
>
|
>
|
||||||
<option value="COMPONENT">Composants</option>
|
<option value="COMPONENT">Composants</option>
|
||||||
<option value="PIECE">Pièces</option>
|
<option value="PIECE">Pièces</option>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
<button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
||||||
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
{{ submitLabel }}
|
{{ submitLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disableSubmitMessage?: string
|
disableSubmitMessage?: string
|
||||||
restrictedMode?: boolean
|
restrictedMode?: boolean
|
||||||
restrictedModeMessage?: string
|
restrictedModeMessage?: string
|
||||||
|
readonly?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
initialData: null,
|
initialData: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -187,6 +188,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disableSubmitMessage: '',
|
disableSubmitMessage: '',
|
||||||
restrictedMode: false,
|
restrictedMode: false,
|
||||||
restrictedModeMessage: '',
|
restrictedModeMessage: '',
|
||||||
|
readonly: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -209,7 +211,8 @@ const disableSubmitMessage = computed(() =>
|
|||||||
? props.disableSubmitMessage
|
? props.disableSubmitMessage
|
||||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||||
)
|
)
|
||||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
const isReadonly = computed(() => props.readonly === true)
|
||||||
|
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
|
||||||
const restrictedModeMessage = computed(() =>
|
const restrictedModeMessage = computed(() =>
|
||||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
||||||
? props.restrictedModeMessage
|
? props.restrictedModeMessage
|
||||||
@@ -291,7 +294,7 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
@@ -308,6 +311,7 @@ const validate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
if (isReadonly.value) return
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,19 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit && showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -78,10 +87,19 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit && showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -118,6 +136,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import IconLucideInbox from '~icons/lucide/inbox';
|
import IconLucideInbox from '~icons/lucide/inbox';
|
||||||
|
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -126,15 +145,22 @@ const props = defineProps<{
|
|||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
category?: ModelCategory;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'related', item: ModelType): void;
|
(e: 'related', item: ModelType): void;
|
||||||
(e: 'edit', item: ModelType): void;
|
(e: 'edit', item: ModelType): void;
|
||||||
(e: 'delete', item: ModelType): void;
|
(e: 'delete', item: ModelType): void;
|
||||||
|
(e: 'convert', item: ModelType): void;
|
||||||
(e: 'update:offset', offset: number): void;
|
(e: 'update:offset', offset: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showConvertButton = computed(() =>
|
||||||
|
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||||
|
);
|
||||||
|
|
||||||
const categoryDictionary: Record<ModelCategory, string> = {
|
const categoryDictionary: Record<ModelCategory, string> = {
|
||||||
COMPONENT: 'Composants',
|
COMPONENT: 'Composants',
|
||||||
PIECE: 'Pièces',
|
PIECE: 'Pièces',
|
||||||
|
|||||||
@@ -83,13 +83,14 @@ import type { ModelCategory } from '~/services/modelTypes';
|
|||||||
type SortField = 'name' | 'createdAt';
|
type SortField = 'name' | 'createdAt';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
category: ModelCategory;
|
category: ModelCategory;
|
||||||
search: string;
|
search: string;
|
||||||
sort: SortField;
|
sort: SortField;
|
||||||
dir: SortDirection;
|
dir: SortDirection;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
showCategoryTabs?: boolean;
|
showCategoryTabs?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
||||||
Modifier
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error" @click="emit('delete', site)">
|
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +55,8 @@ import IconLucidePhone from '~icons/lucide/phone'
|
|||||||
import IconLucideUser from '~icons/lucide/user'
|
import IconLucideUser from '~icons/lucide/user'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
site: {
|
site: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom et prénom"
|
placeholder="Nom et prénom"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldPhone v-model="contactPhone" required />
|
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Adresse complète"
|
placeholder="Adresse complète"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Code postal"
|
placeholder="Code postal"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ville"
|
placeholder="Ville"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +81,10 @@ const props = defineProps({
|
|||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = toRef(props, 'form')
|
const form = toRef(props, 'form')
|
||||||
|
|||||||
@@ -12,17 +12,18 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Usine principale"
|
placeholder="Ex: Usine principale"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="siteRef" />
|
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||||
Créer le site
|
Créer le site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +54,10 @@ const props = defineProps({
|
|||||||
site: {
|
site: {
|
||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if="visible" class="modal modal-open">
|
<div v-if="visible" class="modal modal-open">
|
||||||
<div class="modal-box max-w-md">
|
<div class="modal-box max-w-md">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
Modifier le site
|
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
|
||||||
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="emit('submit')">
|
<form class="space-y-4" @submit.prevent="emit('submit')">
|
||||||
@@ -15,11 +15,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom du site"
|
placeholder="Nom du site"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="props.form" />
|
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
|
v-if="!disabled"
|
||||||
v-model="selectedFilesModel"
|
v-model="selectedFilesModel"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
||||||
@@ -55,8 +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}`"
|
||||||
>
|
>
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
<button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +105,7 @@
|
|||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments">
|
<button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
|
||||||
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -114,7 +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'
|
||||||
@@ -155,6 +157,10 @@ const props = defineProps({
|
|||||||
formatSize: {
|
formatSize: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,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)
|
||||||
|
|||||||
80
app/composables/useAdminProfiles.ts
Normal file
80
app/composables/useAdminProfiles.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
|
||||||
|
export interface AdminProfile {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string | null
|
||||||
|
isActive: boolean
|
||||||
|
hasPassword: boolean
|
||||||
|
roles: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminProfiles() {
|
||||||
|
const { get, post, put } = useApi()
|
||||||
|
const profiles = ref<AdminProfile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await get<AdminProfile[]>('/admin/profiles')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
profiles.value = result.data
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProfile = async (data: {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
role?: string
|
||||||
|
}) => {
|
||||||
|
const result = await post<AdminProfile>('/admin/profiles', data)
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRole = async (id: string, role: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPassword = async (id: string, password: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateProfile = async (id: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles,
|
||||||
|
loading,
|
||||||
|
fetchAll,
|
||||||
|
createProfile,
|
||||||
|
updateRole,
|
||||||
|
setPassword,
|
||||||
|
deactivateProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -20,11 +21,10 @@ export function useApi() {
|
|||||||
|
|
||||||
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
||||||
const url = `${API_BASE_URL}${endpoint}`
|
const url = `${API_BASE_URL}${endpoint}`
|
||||||
|
const isFormData = options.body instanceof FormData
|
||||||
const defaultOptions: ApiCallOptions = {
|
const defaultOptions: ApiCallOptions = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter un timeout à la requête
|
// Ajouter un timeout à la requête
|
||||||
@@ -60,21 +60,26 @@ export function useApi() {
|
|||||||
} else {
|
} else {
|
||||||
const contentType = response.headers.get('content-type') || ''
|
const contentType = response.headers.get('content-type') || ''
|
||||||
let errorData: Record<string, unknown> = {}
|
let errorData: Record<string, unknown> = {}
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('json')) {
|
||||||
errorData = await response.json().catch(() => ({}))
|
errorData = await response.json().catch(() => ({}))
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text().catch(() => '')
|
const text = await response.text().catch(() => '')
|
||||||
errorData = text ? { message: text } : {}
|
errorData = text ? { message: text } : {}
|
||||||
}
|
}
|
||||||
const errorMessage = (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
const rawMessage = response.status === 403
|
||||||
|
? 'Permissions insuffisantes pour cette action.'
|
||||||
|
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
|
||||||
|
const errorMessage = humanizeError(rawMessage)
|
||||||
showError(errorMessage)
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage, status: response.status }
|
return { success: false, error: errorMessage, status: response.status }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
const err = error as Error & { name?: string }
|
const err = error as Error & { name?: string }
|
||||||
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
|
const errorMessage = err.name === 'AbortError'
|
||||||
showError(`Erreur réseau: ${errorMessage}`)
|
? 'La requête a pris trop de temps. Veuillez réessayer.'
|
||||||
|
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
|
||||||
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage }
|
return { success: false, error: errorMessage }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +118,13 @@ export function useApi() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
|
||||||
|
return apiCall<T>(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||||
return apiCall<T>(endpoint, { method: 'DELETE' })
|
return apiCall<T>(endpoint, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -121,6 +133,7 @@ export function useApi() {
|
|||||||
apiCall,
|
apiCall,
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
|
postFormData,
|
||||||
patch,
|
patch,
|
||||||
put,
|
put,
|
||||||
delete: del,
|
delete: del,
|
||||||
|
|||||||
190
app/composables/useComments.ts
Normal file
190
app/composables/useComments.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string | null
|
||||||
|
authorId: string
|
||||||
|
authorName: string
|
||||||
|
status: 'open' | 'resolved'
|
||||||
|
resolvedById?: string | null
|
||||||
|
resolvedByName?: string | null
|
||||||
|
resolvedAt?: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment | Comment[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentListResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment[]
|
||||||
|
total?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComments() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchComments = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
status: string = 'open',
|
||||||
|
): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
status,
|
||||||
|
'order[createdAt]': 'desc',
|
||||||
|
itemsPerPage: '200',
|
||||||
|
})
|
||||||
|
const result = await get(`/comments?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection<Comment>(result.data)
|
||||||
|
return { success: true, data: items }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllComments = async (options: {
|
||||||
|
status?: string
|
||||||
|
entityType?: string
|
||||||
|
entityName?: string
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
orderBy?: string
|
||||||
|
orderDir?: string
|
||||||
|
} = {}): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options.status) params.set('status', options.status)
|
||||||
|
if (options.entityType) params.set('entityType', options.entityType)
|
||||||
|
if (options.entityName) params.set('entityName', options.entityName)
|
||||||
|
const sortField = options.orderBy || 'createdAt'
|
||||||
|
const sortDir = options.orderDir || 'desc'
|
||||||
|
params.set(`order[${sortField}]`, sortDir)
|
||||||
|
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||||
|
params.set('page', String(options.page || 1))
|
||||||
|
|
||||||
|
const result = await get(`/comments?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection<Comment>(result.data)
|
||||||
|
const raw = result.data as Record<string, unknown> | null
|
||||||
|
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
|
||||||
|
return { success: true, data: items, total }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createComment = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
content: string,
|
||||||
|
entityName?: string,
|
||||||
|
): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = { entityType, entityId, content }
|
||||||
|
if (entityName) payload.entityName = entityName
|
||||||
|
const result = await post('/comments', payload)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire ajouté')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible d\'ajouter le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/comments/${commentId}/resolve`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire résolu')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de résoudre le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/comments/${commentId}`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire supprimé')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de supprimer le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUnresolvedCount = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data.count
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
fetchAllComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
fetchUnresolvedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,28 +40,40 @@ 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 (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get(endpoint)
|
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||||
|
const result = await get(url)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = extractCollection(result.data)
|
const data = extractCollection(result.data)
|
||||||
if (updateStore) {
|
if (updateStore) {
|
||||||
@@ -75,10 +95,61 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocuments = async (
|
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
|
||||||
options: { updateStore?: boolean } = {},
|
const {
|
||||||
): Promise<DocumentResult> => {
|
search = '',
|
||||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'createdAt',
|
||||||
|
orderDir = 'desc',
|
||||||
|
attachmentFilter = 'all',
|
||||||
|
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 (
|
||||||
@@ -144,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é`)
|
||||||
@@ -212,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
|
||||||
|
|||||||
@@ -1,46 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Machine creation page – orchestration composable.
|
* Machine creation page – orchestration composable.
|
||||||
*
|
*
|
||||||
* Consolidates entity lookup maps, option filters, label helpers,
|
* Simplified: no more TypeMachine / skeleton system.
|
||||||
* template wrappers, and the finalization logic that were previously
|
* Supports direct creation or cloning from an existing machine.
|
||||||
* inlined in pages/machines/new.vue.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|
||||||
import { useComposants } from '~/composables/useComposants'
|
|
||||||
import { usePieces } from '~/composables/usePieces'
|
|
||||||
import { useProducts } from '~/composables/useProducts'
|
|
||||||
import { useApi } from '~/composables/useApi'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import {
|
|
||||||
useMachineCreatePreview,
|
|
||||||
validateRequirementSelections as _validateRequirementSelections,
|
|
||||||
resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel,
|
|
||||||
resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel,
|
|
||||||
} from '~/composables/useMachineCreatePreview'
|
|
||||||
import {
|
|
||||||
getComponentMachineAssignments,
|
|
||||||
getPieceMachineAssignments,
|
|
||||||
getPieceComponentAssignments,
|
|
||||||
formatAssignmentList,
|
|
||||||
} from '~/shared/utils/assignmentUtils'
|
|
||||||
|
|
||||||
export function useMachineCreatePage() {
|
export function useMachineCreatePage() {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Composable calls
|
// Composable calls
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
|
||||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
|
||||||
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
|
||||||
const { products, loadProducts, loading: productsLoading } = useProducts()
|
|
||||||
const { get } = useApi()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -48,348 +25,84 @@ export function useMachineCreatePage() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
const newMachine = reactive({
|
const newMachine = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
siteId: '',
|
siteId: '',
|
||||||
typeMachineId: '',
|
|
||||||
reference: '',
|
reference: '',
|
||||||
|
cloneFromMachineId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedMachineType = computed(() => {
|
|
||||||
if (!newMachine.typeMachineId) return null
|
|
||||||
return (machineTypes as any).value.find((type: any) => type.id === newMachine.typeMachineId) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Entity lookup maps
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const componentById = computed(() => {
|
|
||||||
const map = new Map()
|
|
||||||
;((composants as any).value || []).forEach((component: any) => {
|
|
||||||
if (component?.id) map.set(component.id, component)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceById = computed(() => {
|
|
||||||
const map = new Map()
|
|
||||||
;((pieces as any).value || []).forEach((piece: any) => {
|
|
||||||
if (piece?.id) map.set(piece.id, piece)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const componentInventory = computed(() => (composants as any).value || [])
|
|
||||||
const pieceInventory = computed(() => (pieces as any).value || [])
|
|
||||||
const productInventory = computed(() => (products as any).value || [])
|
|
||||||
|
|
||||||
const productById = computed(() => {
|
|
||||||
const map = new Map()
|
|
||||||
;(productInventory.value || []).forEach((product: any) => {
|
|
||||||
if (product?.id) map.set(product.id, product)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Entity finders
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const findComponentById = (id: string) => {
|
|
||||||
if (!id) return null
|
|
||||||
return componentById.value.get(id) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const findPieceById = (id: string): any => {
|
|
||||||
if (!id) return null
|
|
||||||
return pieceById.value.get(id) || findPieceInCachedOptions(id) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const findProductById = (id: string) => {
|
|
||||||
if (!id) return null
|
|
||||||
return productById.value.get(id) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Selection state (from composable)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const {
|
|
||||||
pieceOptionsByKey,
|
|
||||||
pieceLoadingByKey,
|
|
||||||
selectedPieceIds,
|
|
||||||
getPieceKey,
|
|
||||||
findPieceInCachedOptions,
|
|
||||||
fetchPieceOptions,
|
|
||||||
getComponentRequirementEntries,
|
|
||||||
getPieceRequirementEntries,
|
|
||||||
getProductRequirementEntries,
|
|
||||||
addComponentSelectionEntry,
|
|
||||||
removeComponentSelectionEntry,
|
|
||||||
addPieceSelectionEntry,
|
|
||||||
removePieceSelectionEntry,
|
|
||||||
addProductSelectionEntry,
|
|
||||||
removeProductSelectionEntry,
|
|
||||||
setComponentRequirementComponent,
|
|
||||||
setPieceRequirementPiece,
|
|
||||||
setProductRequirementProduct: _setProductRequirementProduct,
|
|
||||||
clearRequirementSelections,
|
|
||||||
initializeRequirementSelections,
|
|
||||||
} = useMachineCreateSelections({
|
|
||||||
findComponentById,
|
|
||||||
findPieceById,
|
|
||||||
pieces: pieces as any,
|
|
||||||
get: get as any,
|
|
||||||
toast,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Preview / validation (from composable)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const { machinePreview, blockingPreviewIssues, canCreateMachine } = useMachineCreatePreview({
|
|
||||||
newMachine,
|
|
||||||
sites: sites as any,
|
|
||||||
selectedMachineType,
|
|
||||||
findComponentById,
|
|
||||||
findPieceById,
|
|
||||||
findProductById,
|
|
||||||
getComponentRequirementEntries,
|
|
||||||
getPieceRequirementEntries,
|
|
||||||
getProductRequirementEntries,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Template wrappers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const resolveComponentRequirementTypeLabel = (requirement: any, entry: any) =>
|
|
||||||
_resolveComponentRequirementTypeLabel(requirement, entry, findComponentById)
|
|
||||||
|
|
||||||
const resolvePieceRequirementTypeLabel = (requirement: any, entry: any) =>
|
|
||||||
_resolvePieceRequirementTypeLabel(requirement, entry, findPieceById)
|
|
||||||
|
|
||||||
const setProductRequirementProduct = (requirement: any, index: number, productId: string) =>
|
|
||||||
_setProductRequirementProduct(requirement, index, productId, findProductById)
|
|
||||||
|
|
||||||
const validateRequirementSelections = (type: any) =>
|
|
||||||
_validateRequirementSelections(type, {
|
|
||||||
newMachine,
|
|
||||||
sites: sites as any,
|
|
||||||
selectedMachineType,
|
|
||||||
findComponentById,
|
|
||||||
findPieceById,
|
|
||||||
findProductById,
|
|
||||||
getComponentRequirementEntries,
|
|
||||||
getPieceRequirementEntries,
|
|
||||||
getProductRequirementEntries,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Machine type helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const machineTypeLabel = (type: any) => {
|
|
||||||
if (!type) return ''
|
|
||||||
return type.name || 'Type de machine'
|
|
||||||
}
|
|
||||||
|
|
||||||
const machineTypeDescription = (type: any) => {
|
|
||||||
if (!type) return ''
|
|
||||||
const parts: string[] = []
|
|
||||||
if (type.category) parts.push(`Catégorie : ${type.category}`)
|
|
||||||
const componentCount = type.componentRequirements?.length ?? 0
|
|
||||||
const pieceCount = type.pieceRequirements?.length ?? 0
|
|
||||||
const productCount = type.productRequirements?.length ?? 0
|
|
||||||
parts.push(
|
|
||||||
`${componentCount} composant(s)`,
|
|
||||||
`${pieceCount} pièce(s)`,
|
|
||||||
`${productCount} produit(s)`,
|
|
||||||
)
|
|
||||||
return parts.join(' • ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Option filters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const getComponentOptions = (requirement: any, currentEntry: any) => {
|
|
||||||
const requirementTypeId = requirement?.typeComposantId || requirement?.typeComposant?.id || null
|
|
||||||
return componentInventory.value.filter((component: any) => {
|
|
||||||
if (!component?.id) return false
|
|
||||||
if (requirementTypeId && component.typeComposantId !== requirementTypeId) {
|
|
||||||
return currentEntry?.composantId === component.id
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPieceOptions = (requirement: any, currentEntry: any, entryIndex: number) => {
|
|
||||||
const key = getPieceKey(requirement, entryIndex)
|
|
||||||
const cached = pieceOptionsByKey.value[key]
|
|
||||||
if (cached) return cached
|
|
||||||
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
|
||||||
const usedIds = new Set(
|
|
||||||
selectedPieceIds.value.filter((id: any) => id && (!currentEntry || id !== currentEntry.pieceId)),
|
|
||||||
)
|
|
||||||
return pieceInventory.value.filter((piece: any) => {
|
|
||||||
if (requirementTypeId && piece.typePieceId !== requirementTypeId) return false
|
|
||||||
if (!piece.id) return false
|
|
||||||
if (currentEntry?.pieceId === piece.id) return true
|
|
||||||
return !usedIds.has(piece.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProductOptions = (requirement: any) => {
|
|
||||||
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
|
|
||||||
return productInventory.value.filter((product: any) => {
|
|
||||||
if (!product?.id) return false
|
|
||||||
if (!requirementTypeId) return true
|
|
||||||
const productTypeId = product.typeProductId || product.typeProduct?.id || null
|
|
||||||
return productTypeId === requirementTypeId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Option label / description helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const componentOptionLabel = (component: any) => component?.name || 'Composant'
|
|
||||||
|
|
||||||
const componentOptionDescription = (component: any) => {
|
|
||||||
if (!component) return ''
|
|
||||||
const parts: string[] = []
|
|
||||||
if (component.reference) parts.push(`Réf. ${component.reference}`)
|
|
||||||
const constructeurName = component.constructeur?.name || component.constructeurName
|
|
||||||
if (constructeurName) parts.push(constructeurName)
|
|
||||||
const machineAssignments = getComponentMachineAssignments(component)
|
|
||||||
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
|
||||||
const productTypeName = component.product?.typeProduct?.name
|
|
||||||
const productLabel = component.product?.name || component.product?.reference
|
|
||||||
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
|
||||||
return parts.join(' • ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const pieceOptionLabel = (piece: any) => piece?.name || 'Pièce'
|
|
||||||
|
|
||||||
const pieceOptionDescription = (piece: any) => {
|
|
||||||
if (!piece) return ''
|
|
||||||
const parts: string[] = []
|
|
||||||
if (piece.reference) parts.push(`Réf. ${piece.reference}`)
|
|
||||||
const constructeurName = piece.constructeur?.name || piece.constructeurName
|
|
||||||
if (constructeurName) parts.push(constructeurName)
|
|
||||||
const machineAssignments = getPieceMachineAssignments(piece)
|
|
||||||
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
|
||||||
const componentAssignments = getPieceComponentAssignments(piece)
|
|
||||||
if (componentAssignments.length) parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
|
|
||||||
const productTypeName = piece.product?.typeProduct?.name
|
|
||||||
const productLabel = piece.product?.name || piece.product?.reference
|
|
||||||
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
|
||||||
return parts.join(' • ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Machine creation
|
// Machine creation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const finalizeMachineCreation = async () => {
|
const finalizeMachineCreation = async () => {
|
||||||
if (submitting.value) return
|
if (submitting.value) return
|
||||||
const type = selectedMachineType.value
|
|
||||||
if (!type) {
|
if (!newMachine.name?.trim()) {
|
||||||
toast.showError('Merci de sélectionner un type de machine')
|
toast.showError('Merci de renseigner un nom pour la machine')
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!canCreateMachine.value) {
|
|
||||||
toast.showError('Compléter les informations obligatoires avant de créer la machine')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const baseMachineData = {
|
let result: any
|
||||||
name: newMachine.name,
|
|
||||||
siteId: newMachine.siteId,
|
if (newMachine.cloneFromMachineId) {
|
||||||
reference: newMachine.reference,
|
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
||||||
typeMachineId: type.id,
|
name: newMachine.name,
|
||||||
|
siteId: newMachine.siteId,
|
||||||
|
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
result = await createMachine({
|
||||||
|
name: newMachine.name,
|
||||||
|
siteId: newMachine.siteId || undefined,
|
||||||
|
reference: newMachine.reference || undefined,
|
||||||
|
} as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRequirements =
|
|
||||||
(type.componentRequirements?.length || 0) > 0 ||
|
|
||||||
(type.pieceRequirements?.length || 0) > 0 ||
|
|
||||||
(type.productRequirements?.length || 0) > 0
|
|
||||||
|
|
||||||
let componentLinks: any[] = []
|
|
||||||
let pieceLinks: any[] = []
|
|
||||||
let productLinks: any[] = []
|
|
||||||
|
|
||||||
if (hasRequirements) {
|
|
||||||
const validationResult = validateRequirementSelections(type)
|
|
||||||
if (!validationResult.valid) {
|
|
||||||
toast.showError(validationResult.error as string)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
componentLinks = validationResult.componentLinks as any[]
|
|
||||||
pieceLinks = validationResult.pieceLinks as any[]
|
|
||||||
productLinks = validationResult.productLinks as any[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: any = hasRequirements
|
|
||||||
? await createMachine(baseMachineData as any)
|
|
||||||
: await createMachineFromType(baseMachineData as any, type)
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (hasRequirements && result.data?.id) {
|
const machineId = result.data?.id
|
||||||
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
|
|| (result.data?.machine as any)?.id
|
||||||
componentLinks,
|
|| null
|
||||||
pieceLinks,
|
|
||||||
productLinks,
|
|
||||||
} as any)
|
|
||||||
if (!skeletonResult.success) {
|
|
||||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newMachine.name = ''
|
newMachine.name = ''
|
||||||
newMachine.siteId = ''
|
newMachine.siteId = ''
|
||||||
newMachine.typeMachineId = ''
|
|
||||||
newMachine.reference = ''
|
newMachine.reference = ''
|
||||||
clearRequirementSelections()
|
newMachine.cloneFromMachineId = ''
|
||||||
await navigateTo('/machines')
|
|
||||||
|
if (machineId) {
|
||||||
|
await navigateTo(`/machine/${machineId}`)
|
||||||
|
} else {
|
||||||
|
await navigateTo('/machines')
|
||||||
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.showError(`Impossible de créer la machine: ${result.error}`)
|
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(`Erreur lors de la création: ${error.message}`)
|
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Watchers & lifecycle
|
// Lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
watch(
|
|
||||||
() => newMachine.typeMachineId,
|
|
||||||
(typeId) => {
|
|
||||||
clearRequirementSelections()
|
|
||||||
if (!typeId) return
|
|
||||||
const type = (machineTypes as any).value.find((item: any) => item.id === typeId)
|
|
||||||
if (!type) return
|
|
||||||
initializeRequirementSelections(type)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
loading.value = true
|
||||||
loadSites(),
|
try {
|
||||||
loadMachineTypes(),
|
await Promise.all([
|
||||||
loadComposants(),
|
loadSites(),
|
||||||
loadPieces(),
|
loadMachines(),
|
||||||
loadProducts(),
|
])
|
||||||
])
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -398,59 +111,11 @@ export function useMachineCreatePage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
submitting,
|
|
||||||
newMachine,
|
newMachine,
|
||||||
sites,
|
sites,
|
||||||
machineTypes,
|
machines,
|
||||||
machineTypesLoading,
|
submitting,
|
||||||
composantsLoading,
|
loading,
|
||||||
piecesLoading,
|
|
||||||
productsLoading,
|
|
||||||
selectedMachineType,
|
|
||||||
|
|
||||||
// Selection state
|
|
||||||
pieceLoadingByKey,
|
|
||||||
getPieceKey,
|
|
||||||
fetchPieceOptions,
|
|
||||||
getComponentRequirementEntries,
|
|
||||||
getPieceRequirementEntries,
|
|
||||||
getProductRequirementEntries,
|
|
||||||
addComponentSelectionEntry,
|
|
||||||
removeComponentSelectionEntry,
|
|
||||||
addPieceSelectionEntry,
|
|
||||||
removePieceSelectionEntry,
|
|
||||||
addProductSelectionEntry,
|
|
||||||
removeProductSelectionEntry,
|
|
||||||
setComponentRequirementComponent,
|
|
||||||
setPieceRequirementPiece,
|
|
||||||
setProductRequirementProduct,
|
|
||||||
|
|
||||||
// Preview
|
|
||||||
machinePreview,
|
|
||||||
blockingPreviewIssues,
|
|
||||||
canCreateMachine,
|
|
||||||
|
|
||||||
// Entity finders
|
|
||||||
findComponentById,
|
|
||||||
findPieceById,
|
|
||||||
findProductById,
|
|
||||||
|
|
||||||
// Options
|
|
||||||
getComponentOptions,
|
|
||||||
getPieceOptions,
|
|
||||||
getProductOptions,
|
|
||||||
|
|
||||||
// Label helpers
|
|
||||||
machineTypeLabel,
|
|
||||||
machineTypeDescription,
|
|
||||||
componentOptionLabel,
|
|
||||||
componentOptionDescription,
|
|
||||||
pieceOptionLabel,
|
|
||||||
pieceOptionDescription,
|
|
||||||
|
|
||||||
// Type label resolvers
|
|
||||||
resolveComponentRequirementTypeLabel,
|
|
||||||
resolvePieceRequirementTypeLabel,
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
finalizeMachineCreation,
|
finalizeMachineCreation,
|
||||||
|
|||||||
@@ -1,572 +0,0 @@
|
|||||||
/**
|
|
||||||
* Machine creation – preview computation and validation.
|
|
||||||
*
|
|
||||||
* Extracted from pages/machines/new.vue. Builds the live preview model
|
|
||||||
* and validates requirement selections before machine creation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { computed, type Ref, type ComputedRef } from 'vue'
|
|
||||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
|
||||||
import { extractParentLinkIdentifiers } from '~/shared/utils/productDisplayUtils'
|
|
||||||
import {
|
|
||||||
getComponentMachineAssignments,
|
|
||||||
getPieceMachineAssignments,
|
|
||||||
getPieceComponentAssignments,
|
|
||||||
formatAssignmentList,
|
|
||||||
} from '~/shared/utils/assignmentUtils'
|
|
||||||
|
|
||||||
type AnyRecord = Record<string, unknown>
|
|
||||||
|
|
||||||
export interface MachineCreatePreviewDeps {
|
|
||||||
newMachine: { name: string; siteId: string; typeMachineId: string; reference: string }
|
|
||||||
sites: Ref<AnyRecord[]>
|
|
||||||
selectedMachineType: ComputedRef<AnyRecord | null>
|
|
||||||
findComponentById: (id: string) => AnyRecord | null
|
|
||||||
findPieceById: (id: string) => AnyRecord | null
|
|
||||||
findProductById: (id: string) => AnyRecord | null
|
|
||||||
getComponentRequirementEntries: (requirementId: string) => AnyRecord[]
|
|
||||||
getPieceRequirementEntries: (requirementId: string) => AnyRecord[]
|
|
||||||
getProductRequirementEntries: (requirementId: string) => AnyRecord[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Product type ID extractors
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const getProductTypeIdFromComponent = (component: AnyRecord | null): string | null => {
|
|
||||||
if (!component || typeof component !== 'object') return null
|
|
||||||
return (
|
|
||||||
(component.product as AnyRecord)?.typeProductId ||
|
|
||||||
((component.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
|
||||||
component.productTypeId ||
|
|
||||||
null
|
|
||||||
) as string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProductTypeIdFromPiece = (piece: AnyRecord | null): string | null => {
|
|
||||||
if (!piece || typeof piece !== 'object') return null
|
|
||||||
return (
|
|
||||||
(piece.product as AnyRecord)?.typeProductId ||
|
|
||||||
((piece.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
|
||||||
piece.productTypeId ||
|
|
||||||
null
|
|
||||||
) as string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Status badge helper
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const getStatusBadgeClass = (status: string): string => {
|
|
||||||
if (status === 'ready') return 'badge-success'
|
|
||||||
if (status === 'warning') return 'badge-warning'
|
|
||||||
return 'badge-error'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Scroll / issue click helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2']
|
|
||||||
|
|
||||||
export const scrollToAnchor = (anchor: string): void => {
|
|
||||||
if (!anchor || typeof window === 'undefined' || typeof document === 'undefined') return
|
|
||||||
const target = document.getElementById(anchor)
|
|
||||||
if (!target) return
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
highlightClasses.forEach((cls) => target.classList.add(cls))
|
|
||||||
window.setTimeout(() => {
|
|
||||||
highlightClasses.forEach((cls) => target.classList.remove(cls))
|
|
||||||
}, 1500)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleIssueClick = (issue: AnyRecord): void => {
|
|
||||||
if (!issue?.anchor) return
|
|
||||||
scrollToAnchor(issue.anchor as string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Type label resolvers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const resolveComponentRequirementTypeLabel = (
|
|
||||||
requirement: AnyRecord,
|
|
||||||
entry: AnyRecord,
|
|
||||||
findComponentById: (id: string) => AnyRecord | null,
|
|
||||||
): string => {
|
|
||||||
if (entry?.composantId) {
|
|
||||||
const component = findComponentById(entry.composantId as string)
|
|
||||||
if ((component?.typeComposant as AnyRecord)?.name) {
|
|
||||||
return (component!.typeComposant as AnyRecord).name as string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolvePieceRequirementTypeLabel = (
|
|
||||||
requirement: AnyRecord,
|
|
||||||
entry: AnyRecord,
|
|
||||||
findPieceById: (id: string) => AnyRecord | null,
|
|
||||||
): string => {
|
|
||||||
if (entry?.pieceId) {
|
|
||||||
const piece = findPieceById(entry.pieceId as string)
|
|
||||||
if ((piece?.typePiece as AnyRecord)?.name) {
|
|
||||||
return (piece!.typePiece as AnyRecord).name as string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Product requirement stats
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const computeProductUsageFromSelections = (
|
|
||||||
type: AnyRecord,
|
|
||||||
deps: MachineCreatePreviewDeps,
|
|
||||||
): Map<string, number> => {
|
|
||||||
const usage = new Map<string, number>()
|
|
||||||
|
|
||||||
const increment = (typeProductId: string | null) => {
|
|
||||||
if (!typeProductId) return
|
|
||||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
|
||||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry?.composantId) return
|
|
||||||
const component = deps.findComponentById(entry.composantId as string)
|
|
||||||
increment(getProductTypeIdFromComponent(component))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
|
||||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry?.pieceId) return
|
|
||||||
const piece = deps.findPieceById(entry.pieceId as string)
|
|
||||||
increment(getProductTypeIdFromPiece(piece))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
|
||||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry?.productId) return
|
|
||||||
const product = deps.findProductById(entry.productId as string)
|
|
||||||
const typeProductId = (
|
|
||||||
product?.typeProductId ||
|
|
||||||
(product?.typeProduct as AnyRecord)?.id ||
|
|
||||||
entry?.typeProductId ||
|
|
||||||
requirement?.typeProductId ||
|
|
||||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
|
||||||
null
|
|
||||||
) as string | null
|
|
||||||
increment(typeProductId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildProductRequirementStats = (
|
|
||||||
type: AnyRecord,
|
|
||||||
deps: MachineCreatePreviewDeps,
|
|
||||||
): { stats: AnyRecord[]; usage: Map<string, number> } => {
|
|
||||||
const usage = computeProductUsageFromSelections(type, deps)
|
|
||||||
|
|
||||||
const stats = ((type.productRequirements || []) as AnyRecord[]).map((requirement) => {
|
|
||||||
const typeProductId = (
|
|
||||||
requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null
|
|
||||||
) as string | null
|
|
||||||
|
|
||||||
const label = (
|
|
||||||
(requirement.label as string)?.trim() ||
|
|
||||||
(requirement.typeProduct as AnyRecord)?.name ||
|
|
||||||
(requirement.typeProduct as AnyRecord)?.code ||
|
|
||||||
'Produit requis'
|
|
||||||
) as string
|
|
||||||
|
|
||||||
const typeName = ((requirement.typeProduct as AnyRecord)?.name || 'Non défini') as string
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
|
|
||||||
|
|
||||||
const rawEntries = deps.getProductRequirementEntries(requirement.id as string)
|
|
||||||
const normalizedEntries = rawEntries.map((entry, index) => {
|
|
||||||
const product = entry?.productId ? deps.findProductById(entry.productId as string) : null
|
|
||||||
const subtitleParts: string[] = []
|
|
||||||
if (product?.reference) subtitleParts.push(`Réf. ${product.reference}`)
|
|
||||||
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
|
|
||||||
const price = Number(product.supplierPrice)
|
|
||||||
if (!Number.isNaN(price)) subtitleParts.push(`${price.toFixed(2)} €`)
|
|
||||||
}
|
|
||||||
if (Array.isArray(product?.constructeurs) && (product!.constructeurs as AnyRecord[]).length) {
|
|
||||||
const cLabel = (product!.constructeurs as AnyRecord[])
|
|
||||||
.map((c) => c?.name)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ')
|
|
||||||
if (cLabel) subtitleParts.push(`Fournisseurs: ${cLabel}`)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: `${requirement.id}-${index}`,
|
|
||||||
status: product ? 'complete' : 'pending',
|
|
||||||
title: (product?.name || product?.reference || `Sélection #${index + 1}`) as string,
|
|
||||||
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const issues: AnyRecord[] = []
|
|
||||||
if (count < min) {
|
|
||||||
issues.push({
|
|
||||||
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
|
|
||||||
kind: 'error',
|
|
||||||
anchor: `product-group-${requirement.id}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (max !== null && count > max) {
|
|
||||||
issues.push({
|
|
||||||
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
|
|
||||||
kind: 'error',
|
|
||||||
anchor: `product-group-${requirement.id}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (normalizedEntries.length > 0 && normalizedEntries.some((e) => e.status !== 'complete')) {
|
|
||||||
issues.push({
|
|
||||||
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
|
|
||||||
kind: 'error',
|
|
||||||
anchor: `product-group-${requirement.id}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
|
||||||
const total = normalizedEntries.length
|
|
||||||
const status = issues.some((i) => i.kind === 'error')
|
|
||||||
? 'error'
|
|
||||||
: issues.some((i) => i.kind === 'warning')
|
|
||||||
? 'warning'
|
|
||||||
: 'ready'
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: requirement.id,
|
|
||||||
requirement,
|
|
||||||
label,
|
|
||||||
typeName,
|
|
||||||
count,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
completed,
|
|
||||||
total,
|
|
||||||
entries: normalizedEntries,
|
|
||||||
issues,
|
|
||||||
allowNewModels: requirement.allowNewModels ?? true,
|
|
||||||
status,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { stats, usage }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Validation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const validateRequirementSelections = (
|
|
||||||
type: AnyRecord,
|
|
||||||
deps: MachineCreatePreviewDeps,
|
|
||||||
): AnyRecord => {
|
|
||||||
const errors: string[] = []
|
|
||||||
const componentLinksPayload: AnyRecord[] = []
|
|
||||||
const pieceLinksPayload: AnyRecord[] = []
|
|
||||||
const productLinksPayload: AnyRecord[] = []
|
|
||||||
|
|
||||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
|
||||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
|
|
||||||
if (entries.length < min) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
|
||||||
}
|
|
||||||
if (max !== null && entries.length > max) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.composantId) {
|
|
||||||
errors.push(`Sélectionner un composant existant pour "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}".`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const component = deps.findComponentById(entry.composantId as string)
|
|
||||||
if (!component) {
|
|
||||||
errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const requiredTypeId = (requirement.typeComposantId || (requirement.typeComposant as AnyRecord)?.id || null) as string | null
|
|
||||||
if (requiredTypeId && component.typeComposantId && component.typeComposantId !== requiredTypeId) {
|
|
||||||
errors.push(`Le composant "${component.name || component.id}" n'appartient pas à la famille attendue.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const payload: AnyRecord = { requirementId: requirement.id, composantId: entry.composantId }
|
|
||||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
|
||||||
if (overrides) payload.overrides = overrides
|
|
||||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
|
||||||
componentLinksPayload.push(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
|
||||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
|
|
||||||
if (entries.length < min) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
|
||||||
}
|
|
||||||
if (max !== null && entries.length > max) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.pieceId) {
|
|
||||||
errors.push(`Sélectionner une pièce existante pour "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}".`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const piece = deps.findPieceById(entry.pieceId as string)
|
|
||||||
if (!piece) {
|
|
||||||
errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const requiredTypeId = (requirement.typePieceId || (requirement.typePiece as AnyRecord)?.id || null) as string | null
|
|
||||||
if (requiredTypeId && piece.typePieceId && piece.typePieceId !== requiredTypeId) {
|
|
||||||
errors.push(`La pièce "${piece.name || piece.id}" n'appartient pas à la famille attendue.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const payload: AnyRecord = { requirementId: requirement.id, pieceId: entry.pieceId }
|
|
||||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
|
||||||
if (overrides) payload.overrides = overrides
|
|
||||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
|
||||||
pieceLinksPayload.push(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stats: productStats } = buildProductRequirementStats(type, deps)
|
|
||||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
|
||||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
|
|
||||||
if (max !== null && entries.length > max) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.productId) {
|
|
||||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const product = deps.findProductById(entry.productId as string)
|
|
||||||
if (!product) {
|
|
||||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const requiredTypeId = (requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null) as string | null
|
|
||||||
const productTypeId = (product.typeProductId || (product.typeProduct as AnyRecord)?.id || entry.typeProductId || null) as string | null
|
|
||||||
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
|
|
||||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
|
||||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
|
||||||
productLinksPayload.push(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
productStats.forEach((stat) => {
|
|
||||||
((stat.issues || []) as AnyRecord[])
|
|
||||||
.filter((issue) => issue.kind === 'error')
|
|
||||||
.forEach((issue) => errors.push(issue.message as string))
|
|
||||||
})
|
|
||||||
|
|
||||||
if (errors.length > 0) return { valid: false, error: errors[0] }
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
componentLinks: componentLinksPayload,
|
|
||||||
pieceLinks: pieceLinksPayload,
|
|
||||||
productLinks: productLinksPayload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main preview composable
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function useMachineCreatePreview(deps: MachineCreatePreviewDeps) {
|
|
||||||
const machinePreview = computed(() => {
|
|
||||||
const type = deps.selectedMachineType.value
|
|
||||||
if (!type) return null
|
|
||||||
|
|
||||||
const trimmedName = (deps.newMachine.name || '').trim()
|
|
||||||
const currentSite = deps.newMachine.siteId
|
|
||||||
? deps.sites.value.find((site) => site.id === deps.newMachine.siteId) || null
|
|
||||||
: null
|
|
||||||
const trimmedReference = (deps.newMachine.reference || '').trim()
|
|
||||||
|
|
||||||
const baseFields = [
|
|
||||||
{ key: 'name', label: 'Nom', display: trimmedName || 'À renseigner', status: trimmedName ? 'complete' : 'missing' },
|
|
||||||
{ key: 'site', label: 'Site', display: (currentSite?.name || 'Sélectionner un site') as string, status: currentSite ? 'complete' : 'missing' },
|
|
||||||
{ key: 'type', label: 'Type sélectionné', display: type.name as string, status: 'complete' },
|
|
||||||
{ key: 'reference', label: 'Référence', display: trimmedReference || 'Non renseignée', status: trimmedReference ? 'complete' : 'optional' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const baseIssues: AnyRecord[] = []
|
|
||||||
if (!trimmedName) baseIssues.push({ message: 'Renseigner un nom de machine.', kind: 'error', anchor: 'machine-field-name' })
|
|
||||||
if (!currentSite) baseIssues.push({ message: "Sélectionner un site d'affectation.", kind: 'error', anchor: 'machine-field-site' })
|
|
||||||
|
|
||||||
const baseStatus = baseIssues.some((issue) => issue.kind === 'error') ? 'error' : 'ready'
|
|
||||||
|
|
||||||
// Component groups
|
|
||||||
const componentGroups = ((type.componentRequirements || []) as AnyRecord[]).map((requirement) => {
|
|
||||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
|
||||||
const normalizedEntries = entries.map((entry, index) => {
|
|
||||||
const selectedComponent = entry.composantId ? deps.findComponentById(entry.composantId as string) : null
|
|
||||||
const displayName = (selectedComponent?.name || (requirement.typeComposant as AnyRecord)?.name || 'Composant') as string
|
|
||||||
const subtitleParts: string[] = []
|
|
||||||
if (selectedComponent?.reference) subtitleParts.push(`Réf. ${selectedComponent.reference}`)
|
|
||||||
const constructeurName = (selectedComponent?.constructeur as AnyRecord)?.name || selectedComponent?.constructeurName
|
|
||||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
|
||||||
const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : []
|
|
||||||
const assignmentLabel = formatAssignmentList(machineAssignments)
|
|
||||||
if (assignmentLabel) subtitleParts.push(`Liée à ${assignmentLabel}`)
|
|
||||||
return {
|
|
||||||
key: `${requirement.id}-${index}`,
|
|
||||||
status: entry.composantId ? 'complete' : 'pending',
|
|
||||||
title: displayName,
|
|
||||||
subtitle: subtitleParts.join(' • ') || null,
|
|
||||||
assignmentLabel,
|
|
||||||
assignments: machineAssignments,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
|
||||||
const issues: AnyRecord[] = []
|
|
||||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
|
||||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
|
||||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` })
|
|
||||||
|
|
||||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
|
||||||
const hasWarnings = completed < entries.length
|
|
||||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: requirement.id,
|
|
||||||
label: (requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Famille de composants') as string,
|
|
||||||
typeName: ((requirement.typeComposant as AnyRecord)?.name || 'Non défini') as string,
|
|
||||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Piece groups
|
|
||||||
const pieceGroups = ((type.pieceRequirements || []) as AnyRecord[]).map((requirement) => {
|
|
||||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
|
||||||
const normalizedEntries = entries.map((entry, index) => {
|
|
||||||
const selectedPiece = entry.pieceId ? deps.findPieceById(entry.pieceId as string) : null
|
|
||||||
const displayName = (selectedPiece?.name || (requirement.typePiece as AnyRecord)?.name || 'Pièce') as string
|
|
||||||
const subtitleParts: string[] = []
|
|
||||||
if (selectedPiece?.reference) subtitleParts.push(`Réf. ${selectedPiece.reference}`)
|
|
||||||
const constructeurName = (selectedPiece?.constructeur as AnyRecord)?.name || selectedPiece?.constructeurName
|
|
||||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
|
||||||
const machineAssignments = selectedPiece ? getPieceMachineAssignments(selectedPiece) : []
|
|
||||||
const machineAssignmentLabel = formatAssignmentList(machineAssignments)
|
|
||||||
if (machineAssignmentLabel) subtitleParts.push(`Machines: ${machineAssignmentLabel}`)
|
|
||||||
const componentAssignments = selectedPiece ? getPieceComponentAssignments(selectedPiece) : []
|
|
||||||
const componentAssignmentLabel = formatAssignmentList(componentAssignments)
|
|
||||||
if (componentAssignmentLabel) subtitleParts.push(`Composants: ${componentAssignmentLabel}`)
|
|
||||||
return {
|
|
||||||
key: `${requirement.id}-${index}`,
|
|
||||||
status: entry.pieceId ? 'complete' : 'pending',
|
|
||||||
title: displayName,
|
|
||||||
subtitle: subtitleParts.join(' • ') || null,
|
|
||||||
machineAssignmentLabel, componentAssignmentLabel,
|
|
||||||
machineAssignments, componentAssignments,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
|
||||||
const issues: AnyRecord[] = []
|
|
||||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
|
||||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
|
||||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner une pièce pour chaque entrée.', kind: 'error', anchor: `piece-group-${requirement.id}` })
|
|
||||||
|
|
||||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
|
||||||
const hasWarnings = completed < entries.length
|
|
||||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: requirement.id,
|
|
||||||
label: (requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Groupe de pièces') as string,
|
|
||||||
typeName: ((requirement.typePiece as AnyRecord)?.name || 'Non défini') as string,
|
|
||||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Product groups
|
|
||||||
const { stats: productGroups } = buildProductRequirementStats(type, deps)
|
|
||||||
|
|
||||||
// Aggregate
|
|
||||||
const aggregatedIssues = [
|
|
||||||
...baseIssues.map((issue) => ({ ...issue, scope: 'Informations générales' })),
|
|
||||||
...componentGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
|
||||||
...pieceGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
|
||||||
...productGroups.flatMap((group: AnyRecord) => ((group.issues || []) as AnyRecord[]).map((issue) => ({ ...issue, scope: group.label }))),
|
|
||||||
]
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
baseStatus,
|
|
||||||
...componentGroups.map((g) => g.status),
|
|
||||||
...pieceGroups.map((g) => g.status),
|
|
||||||
...productGroups.map((g: AnyRecord) => g.status as string),
|
|
||||||
]
|
|
||||||
|
|
||||||
const overallStatus = statuses.includes('error') ? 'error' : statuses.includes('warning') ? 'warning' : 'ready'
|
|
||||||
|
|
||||||
return {
|
|
||||||
base: { fields: baseFields, issues: baseIssues, status: baseStatus },
|
|
||||||
componentGroups,
|
|
||||||
pieceGroups,
|
|
||||||
productGroups,
|
|
||||||
type: {
|
|
||||||
name: type.name,
|
|
||||||
category: type.category || null,
|
|
||||||
hasStructuredDefinition:
|
|
||||||
((type.componentRequirements as unknown[])?.length || 0) > 0 ||
|
|
||||||
((type.pieceRequirements as unknown[])?.length || 0) > 0 ||
|
|
||||||
((type.productRequirements as unknown[])?.length || 0) > 0,
|
|
||||||
},
|
|
||||||
status: overallStatus,
|
|
||||||
ready: overallStatus === 'ready',
|
|
||||||
issues: aggregatedIssues,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const blockingPreviewIssues = computed(() => {
|
|
||||||
if (!machinePreview.value) return []
|
|
||||||
return (machinePreview.value.issues as AnyRecord[]).filter((issue) => issue.kind === 'error')
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCreateMachine = computed(() => {
|
|
||||||
if (!machinePreview.value) return false
|
|
||||||
return blockingPreviewIssues.value.length === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
machinePreview,
|
|
||||||
blockingPreviewIssues,
|
|
||||||
canCreateMachine,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
/**
|
|
||||||
* Machine creation – requirement selection state management.
|
|
||||||
*
|
|
||||||
* Extracted from pages/machines/new.vue. Manages the reactive selection state
|
|
||||||
* for component / piece / product requirements when creating a new machine.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, reactive, computed } from 'vue'
|
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
|
||||||
|
|
||||||
type AnyRecord = Record<string, unknown>
|
|
||||||
|
|
||||||
export interface MachineCreateSelectionsDeps {
|
|
||||||
findComponentById: (id: string) => AnyRecord | null
|
|
||||||
findPieceById: (id: string) => AnyRecord | null
|
|
||||||
pieces: { value: AnyRecord[] }
|
|
||||||
get: (url: string) => Promise<AnyRecord>
|
|
||||||
toast: { showError: (msg: string) => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
|
||||||
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Reactive state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
|
||||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
|
||||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
|
||||||
|
|
||||||
const pieceOptionsByKey = ref<Record<string, AnyRecord[]>>({})
|
|
||||||
const pieceLoadingByKey = ref<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Piece option caching
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const getPieceKey = (requirement: AnyRecord, entryIndex: number): string =>
|
|
||||||
`${requirement?.id || 'req'}:${entryIndex}`
|
|
||||||
|
|
||||||
const findPieceInCachedOptions = (id: string): AnyRecord | null => {
|
|
||||||
if (!id) return null
|
|
||||||
const buckets = Object.values(pieceOptionsByKey.value || {})
|
|
||||||
for (const bucket of buckets) {
|
|
||||||
if (!Array.isArray(bucket)) continue
|
|
||||||
const found = bucket.find((piece) => piece?.id === id)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachePieceIfMissing = (piece: AnyRecord): void => {
|
|
||||||
if (!piece?.id) return
|
|
||||||
const current = Array.isArray(pieces.value) ? pieces.value : []
|
|
||||||
if (current.some((p: AnyRecord) => p?.id === piece.id)) return
|
|
||||||
pieces.value = [...current, piece]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPieceOptions = async (
|
|
||||||
requirement: AnyRecord,
|
|
||||||
entryIndex: number,
|
|
||||||
term = '',
|
|
||||||
): Promise<void> => {
|
|
||||||
const key = getPieceKey(requirement, entryIndex)
|
|
||||||
if (pieceLoadingByKey.value[key]) return
|
|
||||||
|
|
||||||
const requirementTypeId =
|
|
||||||
(requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null) as string | null
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('itemsPerPage', '50')
|
|
||||||
if (term && term.trim()) params.set('name', term.trim())
|
|
||||||
if (requirementTypeId) params.set('typePiece', `/api/model_types/${requirementTypeId}`)
|
|
||||||
|
|
||||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
|
|
||||||
try {
|
|
||||||
const result = await get(`/pieces?${params.toString()}`)
|
|
||||||
if (result.success) {
|
|
||||||
pieceOptionsByKey.value = {
|
|
||||||
...pieceOptionsByKey.value,
|
|
||||||
[key]: extractCollection(result.data) as AnyRecord[],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Entry getters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
|
||||||
componentRequirementSelections[requirementId] || []
|
|
||||||
|
|
||||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
|
||||||
pieceRequirementSelections[requirementId] || []
|
|
||||||
|
|
||||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
|
||||||
productRequirementSelections[requirementId] || []
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Entry factories
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
|
||||||
typeComposantId: requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
|
||||||
composantId: source?.composantId || null,
|
|
||||||
definition: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
|
||||||
typePieceId: requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
|
||||||
pieceId: source?.pieceId || null,
|
|
||||||
definition: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
|
||||||
typeProductId:
|
|
||||||
source?.typeProductId ||
|
|
||||||
requirement?.typeProductId ||
|
|
||||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
|
||||||
null,
|
|
||||||
productId: source?.productId || null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Selected piece IDs (for dedup)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const selectedPieceIds = computed(() => {
|
|
||||||
const ids: string[] = []
|
|
||||||
Object.values(pieceRequirementSelections).forEach((entries) => {
|
|
||||||
;(entries || []).forEach((entry) => {
|
|
||||||
if (entry?.pieceId) ids.push(entry.pieceId as string)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return ids
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CRUD operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const addComponentSelectionEntry = (requirement: AnyRecord): void => {
|
|
||||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
if (max !== null && entries.length >= max) {
|
|
||||||
toast.showError(
|
|
||||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
componentRequirementSelections[requirement.id as string] = [
|
|
||||||
...entries,
|
|
||||||
createComponentSelectionEntry(requirement),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeComponentSelectionEntry = (requirementId: string, index: number): void => {
|
|
||||||
const entries = getComponentRequirementEntries(requirementId)
|
|
||||||
componentRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addPieceSelectionEntry = (requirement: AnyRecord): void => {
|
|
||||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
if (max !== null && entries.length >= max) {
|
|
||||||
toast.showError(
|
|
||||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pieceRequirementSelections[requirement.id as string] = [
|
|
||||||
...entries,
|
|
||||||
createPieceSelectionEntry(requirement),
|
|
||||||
]
|
|
||||||
fetchPieceOptions(requirement, entries.length).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removePieceSelectionEntry = (requirementId: string, index: number): void => {
|
|
||||||
const entries = getPieceRequirementEntries(requirementId)
|
|
||||||
pieceRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addProductSelectionEntry = (requirement: AnyRecord): void => {
|
|
||||||
const entries = getProductRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount ?? null) as number | null
|
|
||||||
if (max !== null && entries.length >= max) {
|
|
||||||
toast.showError(
|
|
||||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
productRequirementSelections[requirement.id as string] = [
|
|
||||||
...entries,
|
|
||||||
createProductSelectionEntry(requirement),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeProductSelectionEntry = (requirementId: string, index: number): void => {
|
|
||||||
const entries = getProductRequirementEntries(requirementId)
|
|
||||||
productRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Selection setters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const setComponentRequirementComponent = (
|
|
||||||
requirement: AnyRecord,
|
|
||||||
index: number,
|
|
||||||
componentId: string,
|
|
||||||
): void => {
|
|
||||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
|
||||||
const entry = entries[index]
|
|
||||||
if (!entry) return
|
|
||||||
entry.composantId = componentId || null
|
|
||||||
if (componentId) {
|
|
||||||
const component = findComponentById(componentId)
|
|
||||||
entry.typeComposantId = component?.typeComposantId || requirement?.typeComposantId || null
|
|
||||||
} else {
|
|
||||||
entry.typeComposantId = requirement?.typeComposantId || null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPieceRequirementPiece = (
|
|
||||||
requirement: AnyRecord,
|
|
||||||
index: number,
|
|
||||||
pieceId: string,
|
|
||||||
): void => {
|
|
||||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
|
||||||
const entry = entries[index]
|
|
||||||
if (!entry) return
|
|
||||||
entry.pieceId = pieceId || null
|
|
||||||
if (pieceId) {
|
|
||||||
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
|
|
||||||
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
|
||||||
if (piece) cachePieceIfMissing(piece as AnyRecord)
|
|
||||||
} else {
|
|
||||||
entry.typePieceId = requirement?.typePieceId || null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setProductRequirementProduct = (
|
|
||||||
requirement: AnyRecord,
|
|
||||||
index: number,
|
|
||||||
productId: string,
|
|
||||||
findProductById: (id: string) => AnyRecord | null,
|
|
||||||
): void => {
|
|
||||||
const entries = getProductRequirementEntries(requirement.id as string)
|
|
||||||
const entry = entries[index]
|
|
||||||
if (!entry) return
|
|
||||||
|
|
||||||
const normalizedProductId = productId || null
|
|
||||||
entry.productId = normalizedProductId
|
|
||||||
|
|
||||||
if (normalizedProductId) {
|
|
||||||
const product = findProductById(normalizedProductId)
|
|
||||||
entry.typeProductId =
|
|
||||||
product?.typeProductId ||
|
|
||||||
(product?.typeProduct as AnyRecord)?.id ||
|
|
||||||
entry.typeProductId ||
|
|
||||||
requirement?.typeProductId ||
|
|
||||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
entry.typeProductId =
|
|
||||||
requirement?.typeProductId ||
|
|
||||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bulk operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const clearRequirementSelections = (): void => {
|
|
||||||
Object.keys(componentRequirementSelections).forEach((key) => {
|
|
||||||
delete componentRequirementSelections[key]
|
|
||||||
})
|
|
||||||
Object.keys(pieceRequirementSelections).forEach((key) => {
|
|
||||||
delete pieceRequirementSelections[key]
|
|
||||||
})
|
|
||||||
Object.keys(productRequirementSelections).forEach((key) => {
|
|
||||||
delete productRequirementSelections[key]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeRequirementSelections = (type: AnyRecord): void => {
|
|
||||||
const componentRequirements = (type.componentRequirements || []) as AnyRecord[]
|
|
||||||
const pieceRequirements = (type.pieceRequirements || []) as AnyRecord[]
|
|
||||||
const productRequirements = (type.productRequirements || []) as AnyRecord[]
|
|
||||||
|
|
||||||
componentRequirements.forEach((requirement) => {
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
|
||||||
if (initialCount > 0) {
|
|
||||||
componentRequirementSelections[requirement.id as string] = Array.from(
|
|
||||||
{ length: initialCount },
|
|
||||||
() => createComponentSelectionEntry(requirement),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
componentRequirementSelections[requirement.id as string] = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
pieceRequirements.forEach((requirement) => {
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
|
||||||
if (initialCount > 0) {
|
|
||||||
const entries = Array.from(
|
|
||||||
{ length: initialCount },
|
|
||||||
() => createPieceSelectionEntry(requirement),
|
|
||||||
)
|
|
||||||
pieceRequirementSelections[requirement.id as string] = entries
|
|
||||||
entries.forEach((_: unknown, index: number) => {
|
|
||||||
fetchPieceOptions(requirement, index).catch(() => {})
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
pieceRequirementSelections[requirement.id as string] = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
productRequirements.forEach((requirement) => {
|
|
||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
|
||||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
|
||||||
if (initialCount > 0) {
|
|
||||||
productRequirementSelections[requirement.id as string] = Array.from(
|
|
||||||
{ length: initialCount },
|
|
||||||
() => createProductSelectionEntry(requirement),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
productRequirementSelections[requirement.id as string] = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
componentRequirementSelections,
|
|
||||||
pieceRequirementSelections,
|
|
||||||
productRequirementSelections,
|
|
||||||
pieceOptionsByKey,
|
|
||||||
pieceLoadingByKey,
|
|
||||||
selectedPieceIds,
|
|
||||||
getPieceKey,
|
|
||||||
findPieceInCachedOptions,
|
|
||||||
fetchPieceOptions,
|
|
||||||
getComponentRequirementEntries,
|
|
||||||
getPieceRequirementEntries,
|
|
||||||
getProductRequirementEntries,
|
|
||||||
addComponentSelectionEntry,
|
|
||||||
removeComponentSelectionEntry,
|
|
||||||
addPieceSelectionEntry,
|
|
||||||
removePieceSelectionEntry,
|
|
||||||
addProductSelectionEntry,
|
|
||||||
removeProductSelectionEntry,
|
|
||||||
setComponentRequirementComponent,
|
|
||||||
setPieceRequirementPiece,
|
|
||||||
setProductRequirementProduct,
|
|
||||||
clearRequirementSelections,
|
|
||||||
initializeRequirementSelections,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
resolveIdentifier,
|
resolveIdentifier,
|
||||||
resolveProductReference as _resolveProductReference,
|
resolveProductReference as _resolveProductReference,
|
||||||
getProductDisplay as _getProductDisplay,
|
getProductDisplay as _getProductDisplay,
|
||||||
|
getProductSuppliersLabel,
|
||||||
|
getProductPriceLabel,
|
||||||
extractParentLinkIdentifiers,
|
extractParentLinkIdentifiers,
|
||||||
} from '~/shared/utils/productDisplayUtils'
|
} from '~/shared/utils/productDisplayUtils'
|
||||||
import {
|
import {
|
||||||
@@ -64,7 +66,7 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
updateMachine: updateMachineApi,
|
updateMachine: updateMachineApi,
|
||||||
reconfigureSkeleton: reconfigureMachineSkeleton,
|
updateStructure: updateMachineStructure,
|
||||||
} = useMachines()
|
} = useMachines()
|
||||||
const { updateComposant: updateComposantApi } = useComposants()
|
const { updateComposant: updateComposantApi } = useComposants()
|
||||||
const { updatePiece: updatePieceApi } = usePieces()
|
const { updatePiece: updatePieceApi } = usePieces()
|
||||||
@@ -75,11 +77,12 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
upsertCustomFieldValue,
|
upsertCustomFieldValue,
|
||||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||||
} = useCustomFields()
|
} = useCustomFields()
|
||||||
const { get } = useApi()
|
const { get, post: apiPost, delete: apiDel } = useApi()
|
||||||
const {
|
const {
|
||||||
uploadDocuments,
|
uploadDocuments,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
loadDocumentsByMachine,
|
loadDocumentsByMachine,
|
||||||
|
loadDocumentsByProduct,
|
||||||
} = useDocuments()
|
} = useDocuments()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { constructeurs, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loadConstructeurs } = useConstructeurs()
|
||||||
@@ -105,6 +108,7 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
const machineComponentLinks = ref<AnyRecord[]>([])
|
const machineComponentLinks = ref<AnyRecord[]>([])
|
||||||
const machinePieceLinks = ref<AnyRecord[]>([])
|
const machinePieceLinks = ref<AnyRecord[]>([])
|
||||||
const machineProductLinks = ref<AnyRecord[]>([])
|
const machineProductLinks = ref<AnyRecord[]>([])
|
||||||
|
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
|
||||||
const printAreaRef = ref<HTMLElement | null>(null)
|
const printAreaRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -169,39 +173,21 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
const componentsCollapsed = ref(true)
|
const componentsCollapsed = ref(true)
|
||||||
const collapseToggleToken = ref(0)
|
const collapseToggleToken = ref(0)
|
||||||
|
|
||||||
|
const piecesCollapsed = ref(true)
|
||||||
|
const pieceCollapseToggleToken = ref(0)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Product helpers
|
// Product helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const machineType = computed(() => (machine.value as AnyRecord)?.typeMachine || null)
|
|
||||||
|
|
||||||
const componentTypeOptions = computed(() => componentTypes.value || [])
|
const componentTypeOptions = computed(() => componentTypes.value || [])
|
||||||
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
||||||
|
|
||||||
const componentRequirements = computed(
|
|
||||||
() => ((machineType.value as AnyRecord)?.componentRequirements as AnyRecord[]) || [],
|
|
||||||
)
|
|
||||||
const pieceRequirements = computed(
|
|
||||||
() => ((machineType.value as AnyRecord)?.pieceRequirements as AnyRecord[]) || [],
|
|
||||||
)
|
|
||||||
const productRequirements = computed(
|
|
||||||
() => ((machineType.value as AnyRecord)?.productRequirements as AnyRecord[]) || [],
|
|
||||||
)
|
|
||||||
const machineHasSkeletonRequirements = computed(() =>
|
|
||||||
componentRequirements.value.length > 0 ||
|
|
||||||
pieceRequirements.value.length > 0 ||
|
|
||||||
productRequirements.value.length > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
const componentTypeLabelMap = computed(() => {
|
const componentTypeLabelMap = computed(() => {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
componentTypeOptions.value.forEach((type) => {
|
componentTypeOptions.value.forEach((type) => {
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||||
})
|
})
|
||||||
componentRequirements.value.forEach((req: AnyRecord) => {
|
|
||||||
const type = req.typeComposant as AnyRecord | undefined
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,10 +196,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
pieceTypeOptions.value.forEach((type) => {
|
pieceTypeOptions.value.forEach((type) => {
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||||
})
|
})
|
||||||
pieceRequirements.value.forEach((req: AnyRecord) => {
|
|
||||||
const type = req.typePiece as AnyRecord | undefined
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -310,8 +292,7 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
|
|
||||||
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
|
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
|
||||||
return (piecesData || []).map((piece) => {
|
return (piecesData || []).map((piece) => {
|
||||||
const requirement = (piece.typeMachinePieceRequirement as AnyRecord) || {}
|
const typePiece = (piece.typePiece as AnyRecord) || {}
|
||||||
const typePiece = (requirement.typePiece as AnyRecord) || (piece.typePiece as AnyRecord) || {}
|
|
||||||
|
|
||||||
const normalizeStructureDefs = (structure: unknown) =>
|
const normalizeStructureDefs = (structure: unknown) =>
|
||||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||||
@@ -320,10 +301,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
|
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
|
||||||
normalizeStructureDefs((piece.typePiece as AnyRecord)?.structure),
|
normalizeStructureDefs((piece.typePiece as AnyRecord)?.structure),
|
||||||
normalizeStructureDefs(typePiece.structure),
|
normalizeStructureDefs(typePiece.structure),
|
||||||
normalizeStructureDefs(typePiece.pieceSkeleton),
|
|
||||||
normalizeStructureDefs((piece.typePiece as AnyRecord)?.pieceSkeleton),
|
|
||||||
normalizeStructureDefs(requirement.structure),
|
|
||||||
normalizeStructureDefs(requirement.pieceSkeleton),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const valueEntries = [
|
const valueEntries = [
|
||||||
@@ -347,17 +324,16 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
|
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
|
||||||
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
|
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
|
||||||
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
|
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
|
||||||
normalizeExistingCustomFieldDefinitions((requirement.typePiece as AnyRecord)?.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions(requirement.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions((requirement.definition as AnyRecord)?.customFields),
|
|
||||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const constructeurIds = uniqueConstructeurIds(
|
const constructeurIds = uniqueConstructeurIds(
|
||||||
|
piece.constructeurs,
|
||||||
piece.constructeurIds,
|
piece.constructeurIds,
|
||||||
piece.constructeurId,
|
piece.constructeurId,
|
||||||
piece.constructeur,
|
piece.constructeur,
|
||||||
|
(piece.originalPiece as AnyRecord)?.constructeurs,
|
||||||
(piece.originalPiece as AnyRecord)?.constructeurIds,
|
(piece.originalPiece as AnyRecord)?.constructeurIds,
|
||||||
(piece.originalPiece as AnyRecord)?.constructeurId,
|
(piece.originalPiece as AnyRecord)?.constructeurId,
|
||||||
(piece.originalPiece as AnyRecord)?.constructeur,
|
(piece.originalPiece as AnyRecord)?.constructeur,
|
||||||
@@ -396,7 +372,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
constructeurId: constructeurIds[0] || null,
|
constructeurId: constructeurIds[0] || null,
|
||||||
typePieceId:
|
typePieceId:
|
||||||
piece.typePieceId ||
|
piece.typePieceId ||
|
||||||
(piece.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
|
|
||||||
(piece.typePiece as AnyRecord)?.id ||
|
(piece.typePiece as AnyRecord)?.id ||
|
||||||
null,
|
null,
|
||||||
__productDisplay: productDisplay,
|
__productDisplay: productDisplay,
|
||||||
@@ -409,16 +384,12 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||||
|
|
||||||
return (componentsData || []).map((component) => {
|
return (componentsData || []).map((component) => {
|
||||||
const requirement = (component.typeMachineComponentRequirement as AnyRecord) || {}
|
const type = (component.typeComposant as AnyRecord) || {}
|
||||||
const type = (requirement.typeComposant as AnyRecord) || (component.typeComposant as AnyRecord) || {}
|
|
||||||
|
|
||||||
const normalizedStructureDefs = [
|
const normalizedStructureDefs = [
|
||||||
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
|
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
|
||||||
normalizeStructureDefs((component.typeComposant as AnyRecord)?.structure),
|
normalizeStructureDefs((component.typeComposant as AnyRecord)?.structure),
|
||||||
normalizeStructureDefs(type.structure),
|
normalizeStructureDefs(type.structure),
|
||||||
normalizeStructureDefs(type.componentSkeleton),
|
|
||||||
normalizeStructureDefs(requirement.structure),
|
|
||||||
normalizeStructureDefs(requirement.componentSkeleton),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const actualComponent = (component.originalComposant as AnyRecord) || component
|
const actualComponent = (component.originalComposant as AnyRecord) || component
|
||||||
@@ -445,9 +416,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
|
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
|
||||||
normalizeExistingCustomFieldDefinitions(type.customFields),
|
normalizeExistingCustomFieldDefinitions(type.customFields),
|
||||||
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
|
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
|
||||||
normalizeExistingCustomFieldDefinitions((requirement.typeComposant as AnyRecord)?.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions(requirement.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions((requirement.definition as AnyRecord)?.customFields),
|
|
||||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -464,9 +432,11 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
const constructeurIds = uniqueConstructeurIds(
|
const constructeurIds = uniqueConstructeurIds(
|
||||||
|
component.constructeurs,
|
||||||
component.constructeurIds,
|
component.constructeurIds,
|
||||||
component.constructeurId,
|
component.constructeurId,
|
||||||
component.constructeur,
|
component.constructeur,
|
||||||
|
actualComponent?.constructeurs,
|
||||||
actualComponent?.constructeurIds,
|
actualComponent?.constructeurIds,
|
||||||
actualComponent?.constructeurId,
|
actualComponent?.constructeurId,
|
||||||
actualComponent?.constructeur,
|
actualComponent?.constructeur,
|
||||||
@@ -505,7 +475,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
constructeurId: constructeurIds[0] || null,
|
constructeurId: constructeurIds[0] || null,
|
||||||
typeComposantId:
|
typeComposantId:
|
||||||
component.typeComposantId ||
|
component.typeComposantId ||
|
||||||
(component.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
|
|
||||||
(component.typeComposant as AnyRecord)?.id ||
|
(component.typeComposant as AnyRecord)?.id ||
|
||||||
null,
|
null,
|
||||||
__productDisplay: productDisplay,
|
__productDisplay: productDisplay,
|
||||||
@@ -573,6 +542,87 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const machineDirectProducts = computed(() => {
|
||||||
|
return machineProductLinks.value.map((link) => {
|
||||||
|
const productObj = link.product as AnyRecord | string | null
|
||||||
|
let resolved: AnyRecord | null = null
|
||||||
|
let productId: string | null = null
|
||||||
|
|
||||||
|
if (typeof productObj === 'string') {
|
||||||
|
productId = productObj.split('/').pop() || null
|
||||||
|
resolved = productId ? findProductById(productId) : null
|
||||||
|
} else if (productObj && typeof productObj === 'object') {
|
||||||
|
productId = (productObj as AnyRecord)?.id as string | null
|
||||||
|
// Prefer the embedded product from the structure endpoint — it has richer
|
||||||
|
// data (typeProduct as object, supplierPrice, constructeurs) than the
|
||||||
|
// global products cache which may store typeProduct as an IRI string.
|
||||||
|
const cached = productId ? findProductById(productId) : null
|
||||||
|
resolved = productObj as AnyRecord
|
||||||
|
if (cached) {
|
||||||
|
// Merge: use embedded as base, overlay any non-null cached fields
|
||||||
|
resolved = { ...resolved, ...Object.fromEntries(
|
||||||
|
Object.entries(cached as AnyRecord).filter(([, v]) => v != null && v !== ''),
|
||||||
|
) }
|
||||||
|
// But always prefer the embedded typeProduct when it's an object
|
||||||
|
if (productObj.typeProduct && typeof productObj.typeProduct === 'object') {
|
||||||
|
resolved.typeProduct = productObj.typeProduct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const constructeurIds = uniqueConstructeurIds(
|
||||||
|
resolved?.constructeurs,
|
||||||
|
resolved?.constructeurIds,
|
||||||
|
)
|
||||||
|
const resolvedConstructeurs = resolveConstructeurs(
|
||||||
|
constructeurIds,
|
||||||
|
resolved?.constructeurs as any[] || [],
|
||||||
|
constructeurs.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: (resolved?.id as string) || productId || null,
|
||||||
|
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
|
||||||
|
name: (resolved?.name as string) || 'Produit inconnu',
|
||||||
|
reference: (resolved?.reference as string) || null,
|
||||||
|
supplierLabel: resolvedConstructeurs.length
|
||||||
|
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
|
||||||
|
: getProductSuppliersLabel(resolved),
|
||||||
|
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
|
||||||
|
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
|
||||||
|
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadProductDocuments = async () => {
|
||||||
|
const productIds = machineProductLinks.value
|
||||||
|
.map((link) => {
|
||||||
|
const p = link.product as AnyRecord | string | null
|
||||||
|
if (typeof p === 'string') return p.split('/').pop() || null
|
||||||
|
return (p as AnyRecord)?.id as string | null
|
||||||
|
})
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
productIds.map(async (id) => {
|
||||||
|
const result: any = await loadDocumentsByProduct(id, { updateStore: false })
|
||||||
|
if (result.success && Array.isArray(result.data)) {
|
||||||
|
return { id, docs: result.data as AnyRecord[] }
|
||||||
|
}
|
||||||
|
return { id, docs: [] }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const map = new Map<string, AnyRecord[]>()
|
||||||
|
results.forEach((r) => {
|
||||||
|
if (r.status === 'fulfilled' && r.value.docs.length) {
|
||||||
|
map.set(r.value.id, r.value.docs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
productDocumentsMap.value = map
|
||||||
|
}
|
||||||
|
|
||||||
const machineDocumentsList = computed(
|
const machineDocumentsList = computed(
|
||||||
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
|
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
|
||||||
)
|
)
|
||||||
@@ -583,166 +633,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
return fields.filter((field) => shouldDisplayCustomField(field))
|
return fields.filter((field) => shouldDisplayCustomField(field))
|
||||||
})
|
})
|
||||||
|
|
||||||
const componentRequirementGroups = computed(() => {
|
|
||||||
const reqs = ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.componentRequirements as AnyRecord[] || []
|
|
||||||
if (!reqs.length) return []
|
|
||||||
|
|
||||||
const groups = reqs.map((requirement: AnyRecord) => ({
|
|
||||||
requirement,
|
|
||||||
components: [] as AnyRecord[],
|
|
||||||
}))
|
|
||||||
const map = new Map(groups.map((g) => [g.requirement.id, g]))
|
|
||||||
|
|
||||||
flattenedComponents.value.forEach((component) => {
|
|
||||||
const reqId = component.typeMachineComponentRequirementId as string
|
|
||||||
if (reqId && map.has(reqId)) {
|
|
||||||
map.get(reqId)!.components.push({
|
|
||||||
...component,
|
|
||||||
__productDisplay: getProductDisplay(component),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceRequirementGroups = computed(() => {
|
|
||||||
const reqs = ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.pieceRequirements as AnyRecord[] || []
|
|
||||||
if (!reqs.length) return []
|
|
||||||
|
|
||||||
const groups = reqs.map((requirement: AnyRecord) => ({
|
|
||||||
requirement,
|
|
||||||
pieces: [] as AnyRecord[],
|
|
||||||
}))
|
|
||||||
const map = new Map(groups.map((g) => [g.requirement.id, g]))
|
|
||||||
|
|
||||||
const collectPieces = (): AnyRecord[] => {
|
|
||||||
const collected: AnyRecord[] = []
|
|
||||||
machinePieces.value.forEach((piece) => {
|
|
||||||
collected.push({
|
|
||||||
...piece,
|
|
||||||
constructeurs: piece.constructeurs || [],
|
|
||||||
parentComponentName: null,
|
|
||||||
__productDisplay: getProductDisplay(piece),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
flattenedComponents.value.forEach((component) => {
|
|
||||||
if (Array.isArray(component.pieces) && (component.pieces as AnyRecord[]).length) {
|
|
||||||
;(component.pieces as AnyRecord[]).forEach((piece) => {
|
|
||||||
collected.push({
|
|
||||||
...piece,
|
|
||||||
constructeurs: piece.constructeurs || [],
|
|
||||||
parentComponentName: component.name,
|
|
||||||
__productDisplay: getProductDisplay(piece),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return collected
|
|
||||||
}
|
|
||||||
|
|
||||||
collectPieces().forEach((piece) => {
|
|
||||||
const reqId = piece.typeMachinePieceRequirementId as string
|
|
||||||
if (reqId && map.has(reqId)) {
|
|
||||||
map.get(reqId)!.pieces.push(piece)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
|
|
||||||
const productRequirementGroups = computed(() => {
|
|
||||||
const reqs = ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.productRequirements as AnyRecord[] || []
|
|
||||||
if (!reqs.length) return []
|
|
||||||
|
|
||||||
const componentAggregates = flattenedComponents.value || []
|
|
||||||
const pieceAggregates = collectPiecesForSkeleton()
|
|
||||||
const links = Array.isArray(machineProductLinks.value) ? machineProductLinks.value : []
|
|
||||||
|
|
||||||
return reqs.map((requirement: AnyRecord) => {
|
|
||||||
const typeProductId =
|
|
||||||
(requirement.typeProductId as string) ||
|
|
||||||
(requirement.typeProduct as AnyRecord)?.id as string ||
|
|
||||||
null
|
|
||||||
|
|
||||||
const directProducts = links
|
|
||||||
.filter((link) => {
|
|
||||||
const requirementId = resolveIdentifier(
|
|
||||||
link?.typeMachineProductRequirementId,
|
|
||||||
link?.requirementId,
|
|
||||||
)
|
|
||||||
return requirementId === requirement.id
|
|
||||||
})
|
|
||||||
.map((link) => {
|
|
||||||
const productId = resolveIdentifier(link?.productId, (link?.product as AnyRecord)?.id)
|
|
||||||
const product =
|
|
||||||
productId ? findProductById(productId as string) : (link?.product as AnyRecord) ?? null
|
|
||||||
|
|
||||||
const supplierLabel = Array.isArray((product as AnyRecord)?.constructeurs)
|
|
||||||
? ((product as AnyRecord).constructeurs as AnyRecord[])
|
|
||||||
.map((c) => c?.name)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ')
|
|
||||||
: (link?.constructeursLabel as string) || null
|
|
||||||
|
|
||||||
const priceValue =
|
|
||||||
(product as AnyRecord)?.supplierPrice ?? link?.supplierPrice ?? null
|
|
||||||
|
|
||||||
let priceLabel: string | null = null
|
|
||||||
if (priceValue !== undefined && priceValue !== null) {
|
|
||||||
const numericPrice = Number(priceValue)
|
|
||||||
if (!Number.isNaN(numericPrice)) {
|
|
||||||
priceLabel = `${numericPrice.toFixed(2)} €`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: productId || link?.id || null,
|
|
||||||
name: (product as AnyRecord)?.name || link?.productName || productId || 'Produit',
|
|
||||||
reference: (product as AnyRecord)?.reference || link?.reference || null,
|
|
||||||
supplierLabel,
|
|
||||||
priceLabel,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let componentCount = 0
|
|
||||||
componentAggregates.forEach((component) => {
|
|
||||||
const componentTypeProductId =
|
|
||||||
(component?.product as AnyRecord)?.typeProductId ||
|
|
||||||
((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
|
||||||
null
|
|
||||||
if (typeProductId && componentTypeProductId === typeProductId) componentCount += 1
|
|
||||||
})
|
|
||||||
|
|
||||||
let pieceCount = 0
|
|
||||||
pieceAggregates.forEach((piece) => {
|
|
||||||
const pieceTypeProductId =
|
|
||||||
(piece?.product as AnyRecord)?.typeProductId ||
|
|
||||||
((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
|
||||||
null
|
|
||||||
if (typeProductId && pieceTypeProductId === typeProductId) pieceCount += 1
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
requirement,
|
|
||||||
directProducts,
|
|
||||||
componentCount,
|
|
||||||
pieceCount,
|
|
||||||
totalCount: directProducts.length + componentCount + pieceCount,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const machineDirectProducts = computed(() => {
|
|
||||||
return productRequirementGroups.value.flatMap((group: AnyRecord) =>
|
|
||||||
((group.directProducts as AnyRecord[]) || []).map((product) => ({
|
|
||||||
...product,
|
|
||||||
groupLabel:
|
|
||||||
(group.requirement as AnyRecord).label ||
|
|
||||||
((group.requirement as AnyRecord).typeProduct as AnyRecord)?.name ||
|
|
||||||
'Produit requis',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Machine field methods
|
// Machine field methods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -784,9 +674,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
mergeCustomFieldValuesWithDefinitions(
|
mergeCustomFieldValuesWithDefinitions(
|
||||||
valueEntries,
|
valueEntries,
|
||||||
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
|
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
|
||||||
normalizeExistingCustomFieldDefinitions(
|
|
||||||
(machine.value.typeMachine as AnyRecord)?.customFields,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
|
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
|
||||||
machineCustomFields.value = merged
|
machineCustomFields.value = merged
|
||||||
@@ -977,6 +864,7 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
machine.value.componentLinks = machineComponentLinks.value
|
machine.value.componentLinks = machineComponentLinks.value
|
||||||
machine.value.pieceLinks = machinePieceLinks.value
|
machine.value.pieceLinks = machinePieceLinks.value
|
||||||
}
|
}
|
||||||
|
loadProductDocuments().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1126,6 +1014,11 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
collapseToggleToken.value += 1
|
collapseToggleToken.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleAllPieces = () => {
|
||||||
|
piecesCollapsed.value = !piecesCollapsed.value
|
||||||
|
pieceCollapseToggleToken.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Print wrappers
|
// Print wrappers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1146,16 +1039,118 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Piece aggregation (used by skeleton & product requirement groups)
|
// Structure link management
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const collectPiecesForSkeleton = (): AnyRecord[] => {
|
const reloadMachineStructure = async () => {
|
||||||
const aggregated: AnyRecord[] = []
|
const result: any = await get(`/machines/${machineId}/structure`)
|
||||||
machinePieces.value.forEach((piece) => aggregated.push(piece))
|
if (result.success) {
|
||||||
flattenedComponents.value.forEach((component) => {
|
const machinePayload =
|
||||||
;((component.pieces as AnyRecord[]) || []).forEach((piece) => aggregated.push(piece))
|
result.data?.machine && typeof result.data.machine === 'object'
|
||||||
|
? result.data.machine
|
||||||
|
: result.data
|
||||||
|
if (machinePayload && typeof machinePayload === 'object') {
|
||||||
|
machine.value = {
|
||||||
|
...machine.value,
|
||||||
|
...machinePayload,
|
||||||
|
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
|
||||||
|
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
|
||||||
|
}
|
||||||
|
const linksApplied = applyMachineLinks(result.data)
|
||||||
|
if (linksApplied && machine.value) {
|
||||||
|
machine.value.componentLinks = machineComponentLinks.value
|
||||||
|
machine.value.pieceLinks = machinePieceLinks.value
|
||||||
|
machine.value.productLinks = machineProductLinks.value
|
||||||
|
}
|
||||||
|
syncMachineCustomFields()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addComponentLink = async (composantId: string) => {
|
||||||
|
const result: any = await apiPost('/machine_component_links', {
|
||||||
|
machine: `/api/machines/${machineId}`,
|
||||||
|
composant: `/api/composants/${composantId}`,
|
||||||
})
|
})
|
||||||
return aggregated
|
if (result.success) {
|
||||||
|
toast.showSuccess('Composant ajouté à la machine')
|
||||||
|
await reloadMachineStructure()
|
||||||
|
} else {
|
||||||
|
toast.showError('Erreur lors de l\'ajout du composant')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeComponentLink = async (linkId: string) => {
|
||||||
|
const result: any = await apiDel(`/machine_component_links/${linkId}`)
|
||||||
|
if (result.success) {
|
||||||
|
toast.showSuccess('Composant retiré de la machine')
|
||||||
|
await reloadMachineStructure()
|
||||||
|
} else {
|
||||||
|
toast.showError('Erreur lors de la suppression du composant')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
|
||||||
|
const payload: any = {
|
||||||
|
machine: `/api/machines/${machineId}`,
|
||||||
|
piece: `/api/pieces/${pieceId}`,
|
||||||
|
}
|
||||||
|
if (parentComponentLinkId) {
|
||||||
|
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
||||||
|
}
|
||||||
|
const result: any = await apiPost('/machine_piece_links', payload)
|
||||||
|
if (result.success) {
|
||||||
|
toast.showSuccess('Pièce ajoutée à la machine')
|
||||||
|
await reloadMachineStructure()
|
||||||
|
} else {
|
||||||
|
toast.showError('Erreur lors de l\'ajout de la pièce')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePieceLink = async (linkId: string) => {
|
||||||
|
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
|
||||||
|
if (result.success) {
|
||||||
|
toast.showSuccess('Pièce retirée de la machine')
|
||||||
|
await reloadMachineStructure()
|
||||||
|
} else {
|
||||||
|
toast.showError('Erreur lors de la suppression de la pièce')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
|
||||||
|
const payload: any = {
|
||||||
|
machine: `/api/machines/${machineId}`,
|
||||||
|
product: `/api/products/${productId}`,
|
||||||
|
}
|
||||||
|
if (parentComponentLinkId) {
|
||||||
|
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
||||||
|
}
|
||||||
|
if (parentPieceLinkId) {
|
||||||
|
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
|
||||||
|
}
|
||||||
|
const result: any = await apiPost('/machine_product_links', payload)
|
||||||
|
if (result.success) {
|
||||||
|
toast.showSuccess('Produit ajouté à la machine')
|
||||||
|
await reloadMachineStructure()
|
||||||
|
} else {
|
||||||
|
toast.showError('Erreur lors de l\'ajout du produit')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeProductLink = async (linkId: string) => {
|
||||||
|
const result: any = await apiDel(`/machine_product_links/${linkId}`)
|
||||||
|
if (result.success) {
|
||||||
|
toast.showSuccess('Produit retiré de la machine')
|
||||||
|
await reloadMachineStructure()
|
||||||
|
} else {
|
||||||
|
toast.showError('Erreur lors de la suppression du produit')
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1165,7 +1160,7 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
const loadMachineData = async () => {
|
const loadMachineData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const machineResult: any = await get(`/machines/${machineId}/skeleton`)
|
const machineResult: any = await get(`/machines/${machineId}/structure`)
|
||||||
|
|
||||||
if (!machineResult.success) {
|
if (!machineResult.success) {
|
||||||
console.error('Machine non trouvée:', machineId, machineResult.error)
|
console.error('Machine non trouvée:', machineId, machineResult.error)
|
||||||
@@ -1233,6 +1228,9 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
|
|
||||||
collapseAllComponents()
|
collapseAllComponents()
|
||||||
|
|
||||||
|
// Load product documents in background
|
||||||
|
loadProductDocuments().catch(() => {})
|
||||||
|
|
||||||
// Wait for documents if still loading
|
// Wait for documents if still loading
|
||||||
await documentPromise
|
await documentPromise
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1256,7 +1254,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
|
|
||||||
watch(() => (machine.value as AnyRecord)?.customFieldValues, () => syncMachineCustomFields(), { deep: true })
|
watch(() => (machine.value as AnyRecord)?.customFieldValues, () => syncMachineCustomFields(), { deep: true })
|
||||||
watch(() => (machine.value as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
|
watch(() => (machine.value as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
|
||||||
watch(() => ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
|
|
||||||
watch(
|
watch(
|
||||||
() => [components.value.length, machinePieces.value.length],
|
() => [components.value.length, machinePieces.value.length],
|
||||||
() => ensurePrintSelectionEntries(),
|
() => ensurePrintSelectionEntries(),
|
||||||
@@ -1298,13 +1295,10 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
debug,
|
debug,
|
||||||
componentsCollapsed,
|
componentsCollapsed,
|
||||||
collapseToggleToken,
|
collapseToggleToken,
|
||||||
|
piecesCollapsed,
|
||||||
|
pieceCollapseToggleToken,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
machineType,
|
|
||||||
componentRequirements,
|
|
||||||
pieceRequirements,
|
|
||||||
productRequirements,
|
|
||||||
machineHasSkeletonRequirements,
|
|
||||||
componentTypeOptions,
|
componentTypeOptions,
|
||||||
pieceTypeOptions,
|
pieceTypeOptions,
|
||||||
componentTypeLabelMap,
|
componentTypeLabelMap,
|
||||||
@@ -1313,12 +1307,9 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
productById,
|
productById,
|
||||||
flattenedComponents,
|
flattenedComponents,
|
||||||
machinePieces,
|
machinePieces,
|
||||||
|
machineDirectProducts,
|
||||||
machineDocumentsList,
|
machineDocumentsList,
|
||||||
visibleMachineCustomFields,
|
visibleMachineCustomFields,
|
||||||
componentRequirementGroups,
|
|
||||||
pieceRequirementGroups,
|
|
||||||
productRequirementGroups,
|
|
||||||
machineDirectProducts,
|
|
||||||
|
|
||||||
// Product helpers
|
// Product helpers
|
||||||
findProductById,
|
findProductById,
|
||||||
@@ -1331,7 +1322,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
findComponentById,
|
findComponentById,
|
||||||
findPieceById,
|
findPieceById,
|
||||||
collectConstructeurs,
|
collectConstructeurs,
|
||||||
collectPiecesForSkeleton,
|
|
||||||
|
|
||||||
// Transform
|
// Transform
|
||||||
transformCustomFields,
|
transformCustomFields,
|
||||||
@@ -1370,6 +1360,7 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
toggleAllComponents,
|
toggleAllComponents,
|
||||||
collapseAllComponents,
|
collapseAllComponents,
|
||||||
|
toggleAllPieces,
|
||||||
|
|
||||||
// Print
|
// Print
|
||||||
printModalOpen,
|
printModalOpen,
|
||||||
@@ -1384,10 +1375,19 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
loadMachineData,
|
loadMachineData,
|
||||||
loadInitialData,
|
loadInitialData,
|
||||||
|
|
||||||
// External (needed by skeleton editor)
|
// Structure link management
|
||||||
|
addComponentLink,
|
||||||
|
removeComponentLink,
|
||||||
|
addPieceLink,
|
||||||
|
removePieceLink,
|
||||||
|
addProductLink,
|
||||||
|
removeProductLink,
|
||||||
|
reloadMachineStructure,
|
||||||
|
|
||||||
|
// External
|
||||||
constructeurs,
|
constructeurs,
|
||||||
loadProducts,
|
loadProducts,
|
||||||
reconfigureMachineSkeleton,
|
updateMachineStructure,
|
||||||
toast,
|
toast,
|
||||||
|
|
||||||
// Re-exports for template
|
// Re-exports for template
|
||||||
|
|||||||
@@ -153,8 +153,6 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
||||||
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
||||||
|
|
||||||
const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
|
|
||||||
|
|
||||||
const machinePieceLinkId = normalizePieceLinkId(link)
|
const machinePieceLinkId = normalizePieceLinkId(link)
|
||||||
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
||||||
|
|
||||||
@@ -170,11 +168,8 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
||||||
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
|
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
|
||||||
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
|
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
|
||||||
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
|
typePiece: appliedPiece.typePiece || null,
|
||||||
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || null,
|
||||||
typeMachinePieceRequirement: requirement,
|
|
||||||
typeMachinePieceRequirementId: requirement?.id || null,
|
|
||||||
requirementId: requirement?.id || null,
|
|
||||||
overrides,
|
overrides,
|
||||||
originalPiece,
|
originalPiece,
|
||||||
machinePieceLink: link,
|
machinePieceLink: link,
|
||||||
@@ -186,11 +181,8 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
||||||
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
||||||
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
||||||
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
|
||||||
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
|
||||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||||
customFields: appliedPiece.customFields || [],
|
customFields: appliedPiece.customFields || [],
|
||||||
skeletonOnly: !pieceId,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
||||||
@@ -215,8 +207,6 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
||||||
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
||||||
|
|
||||||
const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
|
|
||||||
|
|
||||||
const machineComponentLinkId = normalizeComponentLinkId(link)
|
const machineComponentLinkId = normalizeComponentLinkId(link)
|
||||||
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
||||||
|
|
||||||
@@ -245,11 +235,8 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
||||||
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
|
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
|
||||||
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
|
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
|
||||||
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
|
typeComposant: appliedComponent.typeComposant || null,
|
||||||
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || null,
|
||||||
typeMachineComponentRequirement: requirement,
|
|
||||||
typeMachineComponentRequirementId: requirement?.id || null,
|
|
||||||
requirementId: requirement?.id || null,
|
|
||||||
overrides: compOverrides,
|
overrides: compOverrides,
|
||||||
machineComponentLinkOverrides: compOverrides,
|
machineComponentLinkOverrides: compOverrides,
|
||||||
definitionOverrides: compOverrides,
|
definitionOverrides: compOverrides,
|
||||||
@@ -259,16 +246,12 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
componentLinkId: machineComponentLinkId,
|
componentLinkId: machineComponentLinkId,
|
||||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||||
parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId),
|
|
||||||
parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
|
||||||
parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
|
||||||
definition: appliedComponent.definition || originalComponent?.definition || {},
|
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||||
customFields: appliedComponent.customFields || [],
|
customFields: appliedComponent.customFields || [],
|
||||||
pieces,
|
pieces,
|
||||||
subComponents,
|
subComponents,
|
||||||
subcomponents: subComponents,
|
subcomponents: subComponents,
|
||||||
sousComposants: subComponents,
|
sousComposants: subComponents,
|
||||||
skeletonOnly: !composantId,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)
|
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)
|
||||||
|
|||||||
@@ -1,838 +0,0 @@
|
|||||||
/**
|
|
||||||
* Machine skeleton editor — selection state, validation & save logic.
|
|
||||||
*
|
|
||||||
* Extracted from pages/machine/[id].vue (F1.1).
|
|
||||||
* Manages the reactive selection state for component / piece / product
|
|
||||||
* skeleton requirements, validation, and reconfiguration API calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, reactive, computed } from 'vue'
|
|
||||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
|
||||||
import {
|
|
||||||
resolveIdentifier,
|
|
||||||
extractParentLinkIdentifiers,
|
|
||||||
} from '~/shared/utils/productDisplayUtils'
|
|
||||||
import {
|
|
||||||
uniqueConstructeurIds,
|
|
||||||
} from '~/shared/constructeurUtils'
|
|
||||||
import { resolveLinkArray } from '~/composables/useMachineHierarchy'
|
|
||||||
import type { Ref, ComputedRef } from 'vue'
|
|
||||||
|
|
||||||
type AnyRecord = Record<string, unknown>
|
|
||||||
|
|
||||||
export interface MachineSkeletonEditorDeps {
|
|
||||||
machine: Ref<AnyRecord | null>
|
|
||||||
components: Ref<AnyRecord[]>
|
|
||||||
pieces: Ref<AnyRecord[]>
|
|
||||||
machineComponentLinks: Ref<AnyRecord[]>
|
|
||||||
machinePieceLinks: Ref<AnyRecord[]>
|
|
||||||
machineProductLinks: Ref<AnyRecord[]>
|
|
||||||
machineType: ComputedRef<AnyRecord | null>
|
|
||||||
machineHasSkeletonRequirements: ComputedRef<boolean>
|
|
||||||
componentRequirements: ComputedRef<AnyRecord[]>
|
|
||||||
pieceRequirements: ComputedRef<AnyRecord[]>
|
|
||||||
productRequirements: ComputedRef<AnyRecord[]>
|
|
||||||
componentTypeLabelMap: ComputedRef<Map<string, string>>
|
|
||||||
pieceTypeLabelMap: ComputedRef<Map<string, string>>
|
|
||||||
productInventory: ComputedRef<AnyRecord[]>
|
|
||||||
flattenedComponents: ComputedRef<AnyRecord[]>
|
|
||||||
machinePieces: ComputedRef<AnyRecord[]>
|
|
||||||
machineDocumentsLoaded: Ref<boolean>
|
|
||||||
findProductById: (id: string | null | undefined) => AnyRecord | null
|
|
||||||
findComponentById: (items: AnyRecord[] | undefined, id: string) => AnyRecord | null
|
|
||||||
findPieceById: (id: string) => AnyRecord | null
|
|
||||||
transformCustomFields: (pieces: AnyRecord[]) => AnyRecord[]
|
|
||||||
transformComponentCustomFields: (components: AnyRecord[]) => AnyRecord[]
|
|
||||||
applyMachineLinks: (source: AnyRecord) => boolean
|
|
||||||
collapseAllComponents: () => void
|
|
||||||
initMachineFields: () => void
|
|
||||||
collectPiecesForSkeleton: () => AnyRecord[]
|
|
||||||
constructeurs: Ref<AnyRecord[]>
|
|
||||||
loadProducts: () => Promise<void>
|
|
||||||
reconfigureMachineSkeleton: (id: string, payload: AnyRecord) => Promise<AnyRecord>
|
|
||||||
toast: { showError: (msg: string) => void; showInfo: (msg: string) => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMachineSkeletonEditor(deps: MachineSkeletonEditorDeps) {
|
|
||||||
const {
|
|
||||||
machine,
|
|
||||||
components,
|
|
||||||
pieces,
|
|
||||||
machineComponentLinks,
|
|
||||||
machinePieceLinks,
|
|
||||||
machineProductLinks,
|
|
||||||
machineType,
|
|
||||||
machineHasSkeletonRequirements,
|
|
||||||
productRequirements,
|
|
||||||
componentTypeLabelMap,
|
|
||||||
pieceTypeLabelMap,
|
|
||||||
productInventory,
|
|
||||||
flattenedComponents,
|
|
||||||
machineDocumentsLoaded,
|
|
||||||
findProductById,
|
|
||||||
findComponentById,
|
|
||||||
findPieceById,
|
|
||||||
transformCustomFields,
|
|
||||||
transformComponentCustomFields,
|
|
||||||
applyMachineLinks,
|
|
||||||
collapseAllComponents,
|
|
||||||
initMachineFields,
|
|
||||||
collectPiecesForSkeleton,
|
|
||||||
loadProducts,
|
|
||||||
reconfigureMachineSkeleton,
|
|
||||||
toast,
|
|
||||||
} = deps
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// View state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const activeMachineView = ref<'details' | 'skeleton'>('details')
|
|
||||||
const isDetailsView = computed(() => activeMachineView.value === 'details')
|
|
||||||
const isSkeletonView = computed(() => activeMachineView.value === 'skeleton')
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Editor state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const skeletonEditor = reactive({
|
|
||||||
open: false,
|
|
||||||
loading: false,
|
|
||||||
submitting: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
|
||||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
|
||||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const isPlainObject = (value: unknown): boolean =>
|
|
||||||
Object.prototype.toString.call(value) === '[object Object]'
|
|
||||||
|
|
||||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
|
||||||
componentRequirementSelections[requirementId] || []
|
|
||||||
|
|
||||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
|
||||||
pieceRequirementSelections[requirementId] || []
|
|
||||||
|
|
||||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
|
||||||
productRequirementSelections[requirementId] || []
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Label resolvers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const resolveComponentRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
|
||||||
const typeId = (entry?.typeComposantId || requirement?.typeComposantId || null) as string | null
|
|
||||||
if (!typeId) return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
|
||||||
return componentTypeLabelMap.value.get(typeId) || ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePieceRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
|
||||||
const typeId = (entry?.typePieceId || requirement?.typePieceId || null) as string | null
|
|
||||||
if (!typeId) return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
|
||||||
return pieceTypeLabelMap.value.get(typeId) || ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveProductRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
|
||||||
const typeId =
|
|
||||||
(entry?.typeProductId as string) ||
|
|
||||||
(requirement?.typeProductId as string) ||
|
|
||||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
null
|
|
||||||
if (typeId) {
|
|
||||||
const typeMatch = productRequirements.value.find(
|
|
||||||
(req: AnyRecord) =>
|
|
||||||
req.typeProductId === typeId || (req.typeProduct as AnyRecord)?.id === typeId,
|
|
||||||
)
|
|
||||||
if (typeMatch && (typeMatch.typeProduct as AnyRecord)?.name) {
|
|
||||||
return (typeMatch.typeProduct as AnyRecord).name as string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ((requirement?.typeProduct as AnyRecord)?.name as string) || 'Catégorie non définie'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProductOptionsForRequirement = (requirement: AnyRecord): AnyRecord[] => {
|
|
||||||
const requirementTypeId =
|
|
||||||
(requirement?.typeProductId as string) ||
|
|
||||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
null
|
|
||||||
return (productInventory.value as AnyRecord[]).filter((product) => {
|
|
||||||
if (!product?.id) return false
|
|
||||||
if (!requirementTypeId) return true
|
|
||||||
const productTypeId =
|
|
||||||
(product.typeProductId as string) ||
|
|
||||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
null
|
|
||||||
return productTypeId === requirementTypeId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Selection entry factories
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
|
||||||
const link = (source?.machineComponentLink as AnyRecord) || null
|
|
||||||
|
|
||||||
const entry: AnyRecord = {
|
|
||||||
linkId: resolveIdentifier(link?.id, source?.machineComponentLinkId, source?.linkId),
|
|
||||||
composantId: resolveIdentifier(source?.composantId, source?.componentId, source?.id),
|
|
||||||
parentLinkId: resolveIdentifier(link?.parentLinkId, link?.parentComponentLinkId, source?.parentComponentLinkId, source?.parentLinkId),
|
|
||||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
|
||||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
|
||||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
|
||||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId),
|
|
||||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
|
||||||
typeComposantId:
|
|
||||||
(source?.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
|
|
||||||
source?.typeComposantId ||
|
|
||||||
(source?.typeComposant as AnyRecord)?.id ||
|
|
||||||
requirement?.typeComposantId ||
|
|
||||||
null,
|
|
||||||
definition: {
|
|
||||||
name: source?.name || source?.nom || (requirement?.typeComposant as AnyRecord)?.name || '',
|
|
||||||
reference: source?.reference || '',
|
|
||||||
constructeurIds: [] as string[],
|
|
||||||
constructeurId: null as string | null,
|
|
||||||
prix: source?.prix ?? source?.price ?? null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const defConstructeurIds = uniqueConstructeurIds(
|
|
||||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
|
||||||
(link?.overrides as AnyRecord)?.constructeurId,
|
|
||||||
source?.constructeurIds,
|
|
||||||
source?.constructeurId,
|
|
||||||
source?.constructeur,
|
|
||||||
)
|
|
||||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
|
||||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
|
||||||
|
|
||||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
|
||||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalConstructeurIds = uniqueConstructeurIds(
|
|
||||||
(entry.definition as AnyRecord).constructeurIds,
|
|
||||||
(entry.definition as AnyRecord).constructeurId,
|
|
||||||
(entry.definition as AnyRecord).constructeur,
|
|
||||||
)
|
|
||||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
|
||||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
|
||||||
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
|
||||||
const link = (source?.machinePieceLink as AnyRecord) || null
|
|
||||||
|
|
||||||
const entry: AnyRecord = {
|
|
||||||
linkId: resolveIdentifier(link?.id, source?.machinePieceLinkId, source?.linkId),
|
|
||||||
pieceId: resolveIdentifier(source?.pieceId, source?.id),
|
|
||||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
|
||||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId, source?.machineComponentLinkId),
|
|
||||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
|
||||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
|
||||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
|
||||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId, source?.composantId),
|
|
||||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
|
||||||
composantId: resolveIdentifier(source?.composantId, link?.composantId, link?.componentId),
|
|
||||||
typePieceId:
|
|
||||||
(source?.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
|
|
||||||
source?.typePieceId ||
|
|
||||||
(source?.typePiece as AnyRecord)?.id ||
|
|
||||||
requirement?.typePieceId ||
|
|
||||||
null,
|
|
||||||
definition: {
|
|
||||||
name: source?.name || source?.nom || (requirement?.typePiece as AnyRecord)?.name || '',
|
|
||||||
reference: source?.reference || '',
|
|
||||||
constructeurIds: [] as string[],
|
|
||||||
constructeurId: null as string | null,
|
|
||||||
prix: source?.prix ?? source?.price ?? null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const defConstructeurIds = uniqueConstructeurIds(
|
|
||||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
|
||||||
(link?.overrides as AnyRecord)?.constructeurId,
|
|
||||||
source?.constructeurIds,
|
|
||||||
source?.constructeurId,
|
|
||||||
source?.constructeur,
|
|
||||||
)
|
|
||||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
|
||||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
|
||||||
|
|
||||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
|
||||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalConstructeurIds = uniqueConstructeurIds(
|
|
||||||
(entry.definition as AnyRecord).constructeurIds,
|
|
||||||
(entry.definition as AnyRecord).constructeurId,
|
|
||||||
(entry.definition as AnyRecord).constructeur,
|
|
||||||
)
|
|
||||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
|
||||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
|
||||||
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
|
||||||
const link = (source?.machineProductLink as AnyRecord) || source || null
|
|
||||||
|
|
||||||
return {
|
|
||||||
linkId: resolveIdentifier(link?.id, source?.machineProductLinkId, source?.linkId),
|
|
||||||
productId: resolveIdentifier(source?.productId, link?.productId),
|
|
||||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
|
||||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId),
|
|
||||||
parentPieceLinkId: resolveIdentifier(link?.parentPieceLinkId, source?.parentPieceLinkId),
|
|
||||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
|
||||||
parentComponentRequirementId: resolveIdentifier(link?.parentComponentRequirementId, source?.parentComponentRequirementId, requirement?.parentComponentRequirementId),
|
|
||||||
parentPieceRequirementId: resolveIdentifier(link?.parentPieceRequirementId, source?.parentPieceRequirementId, requirement?.parentPieceRequirementId),
|
|
||||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
|
||||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
|
||||||
typeProductId: resolveIdentifier(link?.typeProductId, source?.typeProductId, requirement?.typeProductId, (requirement?.typeProduct as AnyRecord)?.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Selection CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const resetSkeletonRequirementSelections = () => {
|
|
||||||
Object.keys(componentRequirementSelections).forEach((k) => delete componentRequirementSelections[k])
|
|
||||||
Object.keys(pieceRequirementSelections).forEach((k) => delete pieceRequirementSelections[k])
|
|
||||||
Object.keys(productRequirementSelections).forEach((k) => delete productRequirementSelections[k])
|
|
||||||
}
|
|
||||||
|
|
||||||
const addComponentSelectionEntry = (requirement: AnyRecord) => {
|
|
||||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount as number | null) ?? null
|
|
||||||
if (max !== null && entries.length >= max) {
|
|
||||||
toast.showError(
|
|
||||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
componentRequirementSelections[requirement.id as string] = [
|
|
||||||
...entries,
|
|
||||||
createComponentSelectionEntry(requirement),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeComponentSelectionEntry = (requirementId: string, index: number) => {
|
|
||||||
const entries = getComponentRequirementEntries(requirementId)
|
|
||||||
componentRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setComponentRequirementType = (requirementId: string, index: number, value: string | null) => {
|
|
||||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
|
||||||
if (!entry) return
|
|
||||||
entry.typeComposantId = value || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const setComponentRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
|
||||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
|
||||||
if (!entry) return
|
|
||||||
const ids = uniqueConstructeurIds(value)
|
|
||||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
|
||||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const addPieceSelectionEntry = (requirement: AnyRecord) => {
|
|
||||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount as number | null) ?? null
|
|
||||||
if (max !== null && entries.length >= max) {
|
|
||||||
toast.showError(
|
|
||||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pieceRequirementSelections[requirement.id as string] = [
|
|
||||||
...entries,
|
|
||||||
createPieceSelectionEntry(requirement),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const removePieceSelectionEntry = (requirementId: string, index: number) => {
|
|
||||||
const entries = getPieceRequirementEntries(requirementId)
|
|
||||||
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPieceRequirementType = (requirementId: string, index: number, value: string | null) => {
|
|
||||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
|
||||||
if (!entry) return
|
|
||||||
entry.typePieceId = value || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPieceRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
|
||||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
|
||||||
if (!entry) return
|
|
||||||
const ids = uniqueConstructeurIds(value)
|
|
||||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
|
||||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const addProductSelectionEntry = (requirement: AnyRecord) => {
|
|
||||||
const entries = getProductRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount as number | null) ?? null
|
|
||||||
if (max !== null && entries.length >= max) {
|
|
||||||
toast.showError(
|
|
||||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
productRequirementSelections[requirement.id as string] = [
|
|
||||||
...entries,
|
|
||||||
createProductSelectionEntry(requirement),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeProductSelectionEntry = (requirementId: string, index: number) => {
|
|
||||||
const entries = getProductRequirementEntries(requirementId)
|
|
||||||
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setProductRequirementProduct = (requirementId: string, index: number, productId: string | null) => {
|
|
||||||
const entry = getProductRequirementEntries(requirementId)[index]
|
|
||||||
if (!entry) return
|
|
||||||
const normalizedProductId = productId || null
|
|
||||||
entry.productId = normalizedProductId
|
|
||||||
if (normalizedProductId) {
|
|
||||||
const product = findProductById(normalizedProductId)
|
|
||||||
entry.typeProductId =
|
|
||||||
(product?.typeProductId as string) ||
|
|
||||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
(entry.typeProductId as string) ||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setProductRequirementType = (requirementId: string, index: number, value: string | null) => {
|
|
||||||
const entry = getProductRequirementEntries(requirementId)[index]
|
|
||||||
if (!entry) return
|
|
||||||
entry.typeProductId = value || entry.typeProductId || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Skeleton initialization
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const initializeSkeletonRequirementSelections = async () => {
|
|
||||||
skeletonEditor.loading = true
|
|
||||||
try {
|
|
||||||
resetSkeletonRequirementSelections()
|
|
||||||
const type = machineType.value as AnyRecord
|
|
||||||
if (!type) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadProducts()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement des produits pour le squelette:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
;((type.componentRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
|
||||||
const existing = flattenedComponents.value.filter(
|
|
||||||
(c) => c.typeMachineComponentRequirementId === requirement.id,
|
|
||||||
)
|
|
||||||
const entries = existing.map((c) => createComponentSelectionEntry(requirement, c))
|
|
||||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
|
||||||
while (entries.length < min) entries.push(createComponentSelectionEntry(requirement))
|
|
||||||
if (entries.length) componentRequirementSelections[requirement.id as string] = entries
|
|
||||||
})
|
|
||||||
|
|
||||||
const allPieces = collectPiecesForSkeleton()
|
|
||||||
;((type.pieceRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
|
||||||
const existing = allPieces.filter(
|
|
||||||
(p) => p.typeMachinePieceRequirementId === requirement.id,
|
|
||||||
)
|
|
||||||
const entries = existing.map((p) => createPieceSelectionEntry(requirement, p))
|
|
||||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
|
||||||
while (entries.length < min) entries.push(createPieceSelectionEntry(requirement))
|
|
||||||
if (entries.length) pieceRequirementSelections[requirement.id as string] = entries
|
|
||||||
})
|
|
||||||
|
|
||||||
const existingProductLinks = Array.isArray(machineProductLinks.value)
|
|
||||||
? machineProductLinks.value
|
|
||||||
: Array.isArray(machine.value?.productLinks)
|
|
||||||
? (machine.value.productLinks as AnyRecord[])
|
|
||||||
: []
|
|
||||||
|
|
||||||
;((type.productRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
|
||||||
const matches = existingProductLinks.filter((link) => {
|
|
||||||
const reqId = resolveIdentifier(link?.typeMachineProductRequirementId, link?.requirementId)
|
|
||||||
return reqId === requirement.id
|
|
||||||
})
|
|
||||||
const entries = matches.map((link) => createProductSelectionEntry(requirement, link))
|
|
||||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
|
||||||
while (entries.length < min) entries.push(createProductSelectionEntry(requirement))
|
|
||||||
if (entries.length) productRequirementSelections[requirement.id as string] = entries
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
skeletonEditor.loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Editor open/close
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const openSkeletonEditor = async () => {
|
|
||||||
if (skeletonEditor.open) return
|
|
||||||
skeletonEditor.open = true
|
|
||||||
await initializeSkeletonRequirementSelections()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSkeletonEditor = () => {
|
|
||||||
if (!skeletonEditor.open) return
|
|
||||||
if (skeletonEditor.submitting) return
|
|
||||||
skeletonEditor.open = false
|
|
||||||
skeletonEditor.loading = false
|
|
||||||
skeletonEditor.submitting = false
|
|
||||||
resetSkeletonRequirementSelections()
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeMachineView = async (view: 'details' | 'skeleton') => {
|
|
||||||
if (view === activeMachineView.value) return
|
|
||||||
|
|
||||||
if (view === 'skeleton') {
|
|
||||||
if (!machineHasSkeletonRequirements.value) {
|
|
||||||
toast.showInfo('Aucun squelette configuré pour cette machine.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeMachineView.value = 'skeleton'
|
|
||||||
if (!skeletonEditor.open) {
|
|
||||||
try {
|
|
||||||
await openSkeletonEditor()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Impossible d'ouvrir l'éditeur de squelette:", error)
|
|
||||||
toast.showError('Impossible de charger les éléments du squelette.')
|
|
||||||
activeMachineView.value = 'details'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSkeletonEditor()
|
|
||||||
activeMachineView.value = 'details'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Validation & save
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const computeSkeletonProductUsage = (type: AnyRecord): Map<string, number> => {
|
|
||||||
const usage = new Map<string, number>()
|
|
||||||
|
|
||||||
const increment = (typeProductId: string | null) => {
|
|
||||||
if (!typeProductId) return
|
|
||||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
|
||||||
getComponentRequirementEntries(requirement.id as string).forEach((entry) => {
|
|
||||||
if (!entry?.composantId) return
|
|
||||||
const component = findComponentById(components.value, entry.composantId as string)
|
|
||||||
const typeProductId =
|
|
||||||
((component?.product as AnyRecord)?.typeProductId as string) ||
|
|
||||||
(((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
null
|
|
||||||
increment(typeProductId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
|
||||||
getPieceRequirementEntries(requirement.id as string).forEach((entry) => {
|
|
||||||
if (!entry?.pieceId) return
|
|
||||||
const piece = findPieceById(entry.pieceId as string)
|
|
||||||
const typeProductId =
|
|
||||||
((piece?.product as AnyRecord)?.typeProductId as string) ||
|
|
||||||
(((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
null
|
|
||||||
increment(typeProductId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
|
||||||
getProductRequirementEntries(requirement.id as string).forEach((entry) => {
|
|
||||||
if (!entry?.productId) return
|
|
||||||
const product = findProductById(entry.productId as string)
|
|
||||||
const typeProductId =
|
|
||||||
((product?.typeProductId as string) ||
|
|
||||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
(entry?.typeProductId as string) ||
|
|
||||||
(requirement?.typeProductId as string) ||
|
|
||||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
null)
|
|
||||||
increment(typeProductId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateSkeletonSelections = (type: AnyRecord) => {
|
|
||||||
const errors: string[] = []
|
|
||||||
const componentLinksPayload: AnyRecord[] = []
|
|
||||||
const pieceLinksPayload: AnyRecord[] = []
|
|
||||||
const productLinksPayload: AnyRecord[] = []
|
|
||||||
|
|
||||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
|
||||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
|
||||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
|
||||||
const max = (requirement.maxCount as number | null) ?? null
|
|
||||||
|
|
||||||
if (entries.length < min) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
|
||||||
}
|
|
||||||
if (max !== null && entries.length > max) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const resolvedTypeId = (entry.typeComposantId || requirement.typeComposantId || null) as string | null
|
|
||||||
if (!resolvedTypeId) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite un type de composant.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const payload: AnyRecord = { requirementId: requirement.id, typeComposantId: resolvedTypeId }
|
|
||||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
|
||||||
if (entry.composantId) payload.composantId = entry.composantId
|
|
||||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
|
||||||
if (overrides) payload.overrides = overrides
|
|
||||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
|
||||||
componentLinksPayload.push(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
|
||||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
|
||||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
|
||||||
const max = (requirement.maxCount as number | null) ?? null
|
|
||||||
|
|
||||||
if (entries.length < min) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
|
||||||
}
|
|
||||||
if (max !== null && entries.length > max) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const resolvedTypeId = (entry.typePieceId || requirement.typePieceId || null) as string | null
|
|
||||||
if (!resolvedTypeId) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite un type de pièce.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const payload: AnyRecord = { requirementId: requirement.id, typePieceId: resolvedTypeId }
|
|
||||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
|
||||||
if (entry.pieceId) payload.pieceId = entry.pieceId
|
|
||||||
if (entry.composantId) payload.composantId = entry.composantId
|
|
||||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
|
||||||
if (overrides) payload.overrides = overrides
|
|
||||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
|
||||||
pieceLinksPayload.push(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const productUsage = computeSkeletonProductUsage(type)
|
|
||||||
|
|
||||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
|
||||||
const entries = getProductRequirementEntries(requirement.id as string)
|
|
||||||
const max = (requirement.maxCount as number | null) ?? null
|
|
||||||
if (max !== null && entries.length > max) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s) directe(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeProductId = (requirement.typeProductId as string) || ((requirement.typeProduct as AnyRecord)?.id as string) || null
|
|
||||||
const count = typeProductId ? productUsage.get(typeProductId) ?? 0 : 0
|
|
||||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
|
||||||
|
|
||||||
if (count < min) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" nécessite au moins ${min} sélection(s).`)
|
|
||||||
}
|
|
||||||
if (max !== null && count > max) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.productId) {
|
|
||||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const product = findProductById(entry.productId as string)
|
|
||||||
if (!product) {
|
|
||||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const productTypeId =
|
|
||||||
(product.typeProductId as string) ||
|
|
||||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
|
||||||
(entry.typeProductId as string) ||
|
|
||||||
null
|
|
||||||
if (typeProductId && productTypeId && productTypeId !== typeProductId) {
|
|
||||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
|
||||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
|
||||||
if (entry.typeProductId) payload.typeProductId = entry.typeProductId
|
|
||||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
|
||||||
productLinksPayload.push(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length > 0) return { valid: false as const, error: errors[0] }
|
|
||||||
return {
|
|
||||||
valid: true as const,
|
|
||||||
componentLinks: componentLinksPayload,
|
|
||||||
pieceLinks: pieceLinksPayload,
|
|
||||||
productLinks: productLinksPayload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Apply reconfiguration result
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const applySkeletonReconfigurationResult = async (data: AnyRecord) => {
|
|
||||||
if (!data) return
|
|
||||||
|
|
||||||
const updatedMachine = (data.machine as AnyRecord) || data
|
|
||||||
if (updatedMachine) {
|
|
||||||
machine.value = {
|
|
||||||
...machine.value,
|
|
||||||
...updatedMachine,
|
|
||||||
documents: (updatedMachine.documents as AnyRecord[]) || (machine.value?.documents as AnyRecord[]) || [],
|
|
||||||
}
|
|
||||||
initMachineFields()
|
|
||||||
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
const linksApplied = applyMachineLinks(data) || applyMachineLinks(updatedMachine)
|
|
||||||
if (linksApplied) {
|
|
||||||
if (machine.value) {
|
|
||||||
machine.value.componentLinks = machineComponentLinks.value
|
|
||||||
machine.value.pieceLinks = machinePieceLinks.value
|
|
||||||
machine.value.productLinks = machineProductLinks.value
|
|
||||||
}
|
|
||||||
collapseAllComponents()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newComponents = (data.components ?? updatedMachine?.components ?? null) as AnyRecord[] | null
|
|
||||||
if (Array.isArray(newComponents)) {
|
|
||||||
components.value = transformComponentCustomFields(newComponents)
|
|
||||||
collapseAllComponents()
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPieces = (data.pieces ?? updatedMachine?.pieces ?? null) as AnyRecord[] | null
|
|
||||||
if (Array.isArray(newPieces)) {
|
|
||||||
pieces.value = transformCustomFields(newPieces)
|
|
||||||
}
|
|
||||||
|
|
||||||
const prodLinks =
|
|
||||||
resolveLinkArray(data, ['productLinks', 'machineProductLinks']) ??
|
|
||||||
resolveLinkArray(updatedMachine, ['productLinks', 'machineProductLinks'])
|
|
||||||
if (Array.isArray(prodLinks)) {
|
|
||||||
machineProductLinks.value = prodLinks as AnyRecord[]
|
|
||||||
if (machine.value) machine.value.productLinks = prodLinks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Save
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const saveSkeletonConfiguration = async () => {
|
|
||||||
if (!machine.value?.id) return
|
|
||||||
|
|
||||||
const type = machineType.value as AnyRecord
|
|
||||||
let payload: AnyRecord = { componentLinks: [], pieceLinks: [], productLinks: [] }
|
|
||||||
|
|
||||||
if (type && machineHasSkeletonRequirements.value) {
|
|
||||||
const validation = validateSkeletonSelections(type)
|
|
||||||
if (!validation.valid) {
|
|
||||||
toast.showError((validation as AnyRecord).error as string)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload = {
|
|
||||||
componentLinks: (validation as AnyRecord).componentLinks,
|
|
||||||
pieceLinks: (validation as AnyRecord).pieceLinks,
|
|
||||||
productLinks: (validation as AnyRecord).productLinks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
skeletonEditor.submitting = true
|
|
||||||
try {
|
|
||||||
const result = await reconfigureMachineSkeleton(machine.value.id as string, payload)
|
|
||||||
if ((result as AnyRecord).success) {
|
|
||||||
await applySkeletonReconfigurationResult((result as AnyRecord).data as AnyRecord)
|
|
||||||
await changeMachineView('details')
|
|
||||||
} else if ((result as AnyRecord).error) {
|
|
||||||
toast.showError((result as AnyRecord).error as string)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
|
||||||
toast.showError('Erreur lors de la mise à jour des éléments du squelette')
|
|
||||||
} finally {
|
|
||||||
skeletonEditor.submitting = false
|
|
||||||
skeletonEditor.loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
return {
|
|
||||||
// View state
|
|
||||||
activeMachineView,
|
|
||||||
isDetailsView,
|
|
||||||
isSkeletonView,
|
|
||||||
|
|
||||||
// Editor state
|
|
||||||
skeletonEditor,
|
|
||||||
componentRequirementSelections,
|
|
||||||
pieceRequirementSelections,
|
|
||||||
productRequirementSelections,
|
|
||||||
|
|
||||||
// Entry getters
|
|
||||||
getComponentRequirementEntries,
|
|
||||||
getPieceRequirementEntries,
|
|
||||||
getProductRequirementEntries,
|
|
||||||
|
|
||||||
// Label resolvers
|
|
||||||
resolveComponentRequirementTypeLabel,
|
|
||||||
resolvePieceRequirementTypeLabel,
|
|
||||||
resolveProductRequirementTypeLabel,
|
|
||||||
getProductOptionsForRequirement,
|
|
||||||
|
|
||||||
// Selection CRUD
|
|
||||||
addComponentSelectionEntry,
|
|
||||||
removeComponentSelectionEntry,
|
|
||||||
setComponentRequirementType,
|
|
||||||
setComponentRequirementConstructeur,
|
|
||||||
addPieceSelectionEntry,
|
|
||||||
removePieceSelectionEntry,
|
|
||||||
setPieceRequirementType,
|
|
||||||
setPieceRequirementConstructeur,
|
|
||||||
addProductSelectionEntry,
|
|
||||||
removeProductSelectionEntry,
|
|
||||||
setProductRequirementProduct,
|
|
||||||
setProductRequirementType,
|
|
||||||
|
|
||||||
// Editor lifecycle
|
|
||||||
openSkeletonEditor,
|
|
||||||
closeSkeletonEditor,
|
|
||||||
changeMachineView,
|
|
||||||
initializeSkeletonRequirementSelections,
|
|
||||||
|
|
||||||
// Validation & save
|
|
||||||
validateSkeletonSelections,
|
|
||||||
saveSkeletonConfiguration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { useToast } from './useToast'
|
|
||||||
import { useApi, type ApiResponse } from './useApi'
|
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
|
||||||
|
|
||||||
export interface MachineTypeRequirement {
|
|
||||||
id?: string
|
|
||||||
label?: string
|
|
||||||
minCount?: number
|
|
||||||
maxCount?: number
|
|
||||||
required?: boolean
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MachineType {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
componentRequirements: MachineTypeRequirement[]
|
|
||||||
pieceRequirements: MachineTypeRequirement[]
|
|
||||||
productRequirements: MachineTypeRequirement[]
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const machineTypes = ref<MachineType[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const loaded = ref(false)
|
|
||||||
|
|
||||||
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return value.map((entry: Record<string, unknown>, _index: number) => {
|
|
||||||
if (!entry || typeof entry !== 'object') {
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
const normalized = { ...entry }
|
|
||||||
const relationField = relationKey.replace('Id', '')
|
|
||||||
const relationValue = normalized[relationField]
|
|
||||||
if (relationKey && !normalized[relationKey]) {
|
|
||||||
const relationId = extractRelationId(relationValue)
|
|
||||||
if (relationId) {
|
|
||||||
normalized[relationKey] = relationId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return normalized as MachineTypeRequirement
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
|
|
||||||
if (!type || typeof type !== 'object') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...type,
|
|
||||||
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
|
|
||||||
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
|
|
||||||
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
|
|
||||||
} as MachineType
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMachineTypesApi() {
|
|
||||||
const { showSuccess } = useToast()
|
|
||||||
const { get, post, put, delete: del } = useApi()
|
|
||||||
|
|
||||||
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
|
|
||||||
if (!options.force && loaded.value) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await get('/type_machines')
|
|
||||||
if (result.success) {
|
|
||||||
const items = extractCollection(result.data)
|
|
||||||
machineTypes.value = items
|
|
||||||
.map((item) => normalizeMachineType(item as Record<string, unknown>))
|
|
||||||
.filter((item): item is MachineType => item !== null)
|
|
||||||
loaded.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement des types de machines:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await post('/type_machines', typeData)
|
|
||||||
if (result.success) {
|
|
||||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
|
||||||
if (normalized) machineTypes.value.push(normalized)
|
|
||||||
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la création du type de machine:', error)
|
|
||||||
return { success: false, error: (error as Error).message }
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await put(`/type_machines/${id}`, typeData)
|
|
||||||
if (result.success) {
|
|
||||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
|
||||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
|
||||||
if (index !== -1 && normalized) {
|
|
||||||
machineTypes.value[index] = normalized
|
|
||||||
}
|
|
||||||
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la mise à jour du type de machine:', error)
|
|
||||||
return { success: false, error: (error as Error).message }
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteMachineType = async (id: string): Promise<ApiResponse> => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await del(`/type_machines/${id}`)
|
|
||||||
if (result.success) {
|
|
||||||
const deletedType = machineTypes.value.find((type) => type.id === id)
|
|
||||||
machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
|
|
||||||
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la suppression du type de machine:', error)
|
|
||||||
return { success: false, error: (error as Error).message }
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMachineTypeById = async (id: string, forceRefresh = false): Promise<ApiResponse> => {
|
|
||||||
// D'abord chercher dans le cache local (sauf si forceRefresh)
|
|
||||||
if (!forceRefresh) {
|
|
||||||
const localType = machineTypes.value.find((type) => type.id === id)
|
|
||||||
if (localType) {
|
|
||||||
return { success: true, data: localType }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer depuis l'API
|
|
||||||
try {
|
|
||||||
const result = await get(`/type_machines/${id}`)
|
|
||||||
if (result.success) {
|
|
||||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
|
||||||
// Mettre à jour le cache local
|
|
||||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
|
||||||
if (index !== -1 && normalized) {
|
|
||||||
machineTypes.value[index] = normalized
|
|
||||||
} else if (normalized) {
|
|
||||||
machineTypes.value.push(normalized)
|
|
||||||
}
|
|
||||||
return { success: true, data: normalized }
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la récupération du type de machine:', error)
|
|
||||||
return { success: false, error: (error as Error).message }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMachineTypes = (): MachineType[] => machineTypes.value
|
|
||||||
const isLoading = (): boolean => loading.value
|
|
||||||
|
|
||||||
return {
|
|
||||||
machineTypes,
|
|
||||||
loading,
|
|
||||||
loadMachineTypes,
|
|
||||||
createMachineType,
|
|
||||||
updateMachineType,
|
|
||||||
deleteMachineType,
|
|
||||||
getMachineTypeById,
|
|
||||||
getMachineTypes,
|
|
||||||
isLoading,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ export interface Machine {
|
|||||||
id: string
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
siteId?: string | null
|
siteId?: string | null
|
||||||
typeMachineId?: string | null
|
|
||||||
componentLinks?: unknown[]
|
componentLinks?: unknown[]
|
||||||
pieceLinks?: unknown[]
|
pieceLinks?: unknown[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
@@ -53,13 +52,6 @@ const normalizeMachineResponse = (payload: unknown): Machine | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalized.typeMachineId) {
|
|
||||||
const typeMachineId = extractRelationId(container.typeMachine)
|
|
||||||
if (typeMachineId) {
|
|
||||||
normalized.typeMachineId = typeMachineId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
|
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
|
||||||
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
||||||
[]
|
[]
|
||||||
@@ -121,15 +113,6 @@ export function useMachines() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
|
|
||||||
const machineWithStructure = {
|
|
||||||
...machineData,
|
|
||||||
typeMachineId: typeMachine.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
return await createMachine(machineWithStructure)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -157,14 +140,14 @@ export function useMachines() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reconfigureSkeleton = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
const updateStructure = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
||||||
if (!machineId) {
|
if (!machineId) {
|
||||||
return { success: false, error: 'Identifiant de machine manquant' }
|
return { success: false, error: 'Identifiant de machine manquant' }
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/machines/${machineId}/skeleton`, payload)
|
const result = await patch(`/machines/${machineId}/structure`, payload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -180,7 +163,29 @@ export function useMachines() {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
console.error('Erreur lors de la mise à jour de la structure de la machine:', error)
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await post(`/machines/${sourceId}/clone`, data)
|
||||||
|
if (result.success) {
|
||||||
|
const clonedMachine = normalizeMachineResponse(result.data) ||
|
||||||
|
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||||
|
null
|
||||||
|
if (clonedMachine) {
|
||||||
|
machines.value.push(clonedMachine)
|
||||||
|
}
|
||||||
|
showSuccess(`Machine "${clonedMachine?.name || data.name}" clonée avec succès`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du clonage de la machine:', error)
|
||||||
return { success: false, error: (error as Error).message }
|
return { success: false, error: (error as Error).message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -241,10 +246,6 @@ export function useMachines() {
|
|||||||
return machines.value.filter((machine) => machine.siteId === siteId)
|
return machines.value.filter((machine) => machine.siteId === siteId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMachinesByType = (typeMachineId: string): Machine[] => {
|
|
||||||
return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMachines = (): Machine[] => machines.value
|
const getMachines = (): Machine[] => machines.value
|
||||||
const isLoading = (): boolean => loading.value
|
const isLoading = (): boolean => loading.value
|
||||||
|
|
||||||
@@ -253,13 +254,12 @@ export function useMachines() {
|
|||||||
loading,
|
loading,
|
||||||
loadMachines,
|
loadMachines,
|
||||||
createMachine,
|
createMachine,
|
||||||
createMachineFromType,
|
|
||||||
updateMachine: updateMachineData,
|
updateMachine: updateMachineData,
|
||||||
reconfigureSkeleton,
|
updateStructure,
|
||||||
|
cloneMachine,
|
||||||
deleteMachine,
|
deleteMachine,
|
||||||
getMachineById,
|
getMachineById,
|
||||||
getMachinesBySite,
|
getMachinesBySite,
|
||||||
getMachinesByType,
|
|
||||||
getMachines,
|
getMachines,
|
||||||
isLoading,
|
isLoading,
|
||||||
addMissingCustomFields,
|
addMissingCustomFields,
|
||||||
|
|||||||
41
app/composables/usePermissions.ts
Normal file
41
app/composables/usePermissions.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useProfileSession } from './useProfileSession'
|
||||||
|
|
||||||
|
const ROLE_HIERARCHY: Record<string, string[]> = {
|
||||||
|
ROLE_ADMIN: ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_GESTIONNAIRE: ['ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_VIEWER: ['ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_USER: ['ROLE_USER'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const { activeProfile } = useProfileSession()
|
||||||
|
|
||||||
|
const effectiveRoles = computed<string[]>(() => {
|
||||||
|
const roles = (activeProfile.value?.roles as string[] | undefined) ?? ['ROLE_USER']
|
||||||
|
const all = new Set<string>()
|
||||||
|
for (const role of roles) {
|
||||||
|
const inherited = ROLE_HIERARCHY[role] ?? [role]
|
||||||
|
for (const r of inherited) {
|
||||||
|
all.add(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...all]
|
||||||
|
})
|
||||||
|
|
||||||
|
const isGranted = (role: string): boolean => {
|
||||||
|
return effectiveRoles.value.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = computed(() => isGranted('ROLE_ADMIN'))
|
||||||
|
const canEdit = computed(() => isGranted('ROLE_GESTIONNAIRE'))
|
||||||
|
const canView = computed(() => isGranted('ROLE_VIEWER'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
canEdit,
|
||||||
|
canView,
|
||||||
|
isGranted,
|
||||||
|
effectiveRoles,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
import { useState, useRuntimeConfig } from '#imports'
|
||||||
import type { Profile } from './useProfiles'
|
import type { Profile } from './useProfiles'
|
||||||
|
|
||||||
const buildUrl = (path: string): string => {
|
const buildUrl = (path: string): string => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const baseUrl = import.meta.server
|
const base = ((config.public.apiBaseUrl as string) || '').replace(/\/$/, '')
|
||||||
? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
|
|
||||||
: ((config.public.apiBaseUrl as string) || '')
|
|
||||||
const base = baseUrl.replace(/\/$/, '')
|
|
||||||
return `${base}${path}`
|
return `${base}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,19 +12,12 @@ export function useProfileSession() {
|
|||||||
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
||||||
const loading = useState<boolean>('profileSession:loading', () => false)
|
const loading = useState<boolean>('profileSession:loading', () => false)
|
||||||
|
|
||||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
|
||||||
if (!import.meta.server) { return undefined }
|
|
||||||
const headers = useRequestHeaders(['cookie'])
|
|
||||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { status?: number }
|
const err = error as { status?: number }
|
||||||
@@ -51,12 +41,15 @@ export function useProfileSession() {
|
|||||||
return Promise.resolve(activeProfile.value)
|
return Promise.resolve(activeProfile.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activateProfile = async (profileId: string): Promise<void> => {
|
const activateProfile = async (profileId: string, password?: string): Promise<void> => {
|
||||||
|
const body: Record<string, string> = { profileId }
|
||||||
|
if (password) {
|
||||||
|
body.password = password
|
||||||
|
}
|
||||||
await $fetch(buildUrl('/session/profile'), {
|
await $fetch(buildUrl('/session/profile'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: { profileId },
|
body,
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
await fetchCurrentProfile()
|
await fetchCurrentProfile()
|
||||||
}
|
}
|
||||||
@@ -66,7 +59,6 @@ export function useProfileSession() {
|
|||||||
await $fetch(buildUrl('/session/profile'), {
|
await $fetch(buildUrl('/session/profile'), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
activeProfile.value = null
|
activeProfile.value = null
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
import { useState, useRuntimeConfig } from '#imports'
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string
|
id: string
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
|
email?: string | null
|
||||||
|
isActive?: boolean
|
||||||
|
hasPassword?: boolean
|
||||||
|
roles?: string[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,19 +22,12 @@ export function useProfiles() {
|
|||||||
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
||||||
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
||||||
|
|
||||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
|
||||||
if (!import.meta.server) { return undefined }
|
|
||||||
const headers = useRequestHeaders(['cookie'])
|
|
||||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchProfiles = async (): Promise<Profile[]> => {
|
const fetchProfiles = async (): Promise<Profile[]> => {
|
||||||
loadingProfiles.value = true
|
loadingProfiles.value = true
|
||||||
try {
|
try {
|
||||||
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
profilesLoaded.value = true
|
profilesLoaded.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -43,32 +40,10 @@ export function useProfiles() {
|
|||||||
return profiles.value
|
return profiles.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const createProfile = async ({ firstName, lastName }: { firstName: string; lastName: string }): Promise<Profile> => {
|
|
||||||
const profile = await $fetch<Profile>(buildUrl('/session/profiles'), {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: { firstName, lastName },
|
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
|
||||||
await fetchProfiles()
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteProfile = async (profileId: string): Promise<void> => {
|
|
||||||
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
|
||||||
await fetchProfiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profiles,
|
profiles,
|
||||||
loadingProfiles,
|
loadingProfiles,
|
||||||
profilesLoaded,
|
profilesLoaded,
|
||||||
fetchProfiles,
|
fetchProfiles,
|
||||||
createProfile,
|
|
||||||
deleteProfile,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
116
app/composables/useUrlState.ts
Normal file
116
app/composables/useUrlState.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { ref, watch, nextTick, type Ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from '#imports'
|
||||||
|
|
||||||
|
interface ParamDef<T extends string | number = string | number> {
|
||||||
|
default: T
|
||||||
|
type?: 'string' | 'number'
|
||||||
|
/** Debounce URL writes (ms). Default: 0 (immediate). */
|
||||||
|
debounce?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParamDefs = Record<string, ParamDef>
|
||||||
|
|
||||||
|
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
|
||||||
|
|
||||||
|
type StateRefs<T extends ParamDefs> = {
|
||||||
|
[K in keyof T]: InferRef<T[K]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseUrlStateOptions {
|
||||||
|
/** Called when state is restored from URL (back/forward navigation). */
|
||||||
|
onRestore?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUrlState<T extends ParamDefs>(
|
||||||
|
params: T,
|
||||||
|
options?: UseUrlStateOptions,
|
||||||
|
): StateRefs<T> {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const keys = Object.keys(params) as (keyof T & string)[]
|
||||||
|
const refs: Record<string, Ref<string | number>> = {}
|
||||||
|
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
refs[key] = ref(parseValue(route.query[key], params[key]!))
|
||||||
|
timers[key] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
let isProgrammatic = false
|
||||||
|
|
||||||
|
const buildQuery = (): Record<string, string> => {
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
for (const key of keys) {
|
||||||
|
const val = refs[key]!.value
|
||||||
|
if (val !== params[key]!.default) {
|
||||||
|
q[key] = String(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushToUrl = () => {
|
||||||
|
if (isProgrammatic) return
|
||||||
|
isProgrammatic = true
|
||||||
|
const query = buildQuery()
|
||||||
|
router
|
||||||
|
.replace({ path: route.path, query })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
isProgrammatic = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const ms = params[key]!.debounce ?? 0
|
||||||
|
watch(refs[key]!, () => {
|
||||||
|
if (isProgrammatic) return
|
||||||
|
if (ms > 0) {
|
||||||
|
if (timers[key]) clearTimeout(timers[key]!)
|
||||||
|
timers[key] = setTimeout(pushToUrl, ms)
|
||||||
|
} else {
|
||||||
|
pushToUrl()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ({ ...route.query }),
|
||||||
|
(newQuery) => {
|
||||||
|
if (isProgrammatic) return
|
||||||
|
isProgrammatic = true
|
||||||
|
let changed = false
|
||||||
|
for (const key of keys) {
|
||||||
|
const parsed = parseValue(newQuery[key], params[key]!)
|
||||||
|
if (refs[key]!.value !== parsed) {
|
||||||
|
refs[key]!.value = parsed
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
isProgrammatic = false
|
||||||
|
if (changed && options?.onRestore) {
|
||||||
|
options.onRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return refs as StateRefs<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(
|
||||||
|
raw: unknown,
|
||||||
|
def: ParamDef,
|
||||||
|
): string | number {
|
||||||
|
const str = typeof raw === 'string' ? raw : null
|
||||||
|
if (str === null) return def.default
|
||||||
|
if (def.type === 'number' || typeof def.default === 'number') {
|
||||||
|
const n = Number(str)
|
||||||
|
return Number.isFinite(n) ? n : def.default
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useProfileSession } from "#imports";
|
import { useProfileSession, usePermissions } from "#imports";
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
const { ensureSession, fetchCurrentProfile, activeProfile } =
|
const { ensureSession, activeProfile } = useProfileSession();
|
||||||
useProfileSession();
|
|
||||||
await ensureSession();
|
await ensureSession();
|
||||||
|
|
||||||
const rawPath = to?.path ?? "";
|
const rawPath = to?.path ?? "";
|
||||||
@@ -14,11 +13,21 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
fullPath.startsWith("/profiles") ||
|
fullPath.startsWith("/profiles") ||
|
||||||
routeName.startsWith("profiles");
|
routeName.startsWith("profiles");
|
||||||
|
|
||||||
if (process.client && !activeProfile.value) {
|
// Redirect to login if no active profile
|
||||||
await fetchCurrentProfile();
|
if (!activeProfile.value && !isProfilesRoute) {
|
||||||
}
|
|
||||||
|
|
||||||
if (process.client && !activeProfile.value && !isProfilesRoute) {
|
|
||||||
return navigateTo("/profiles");
|
return navigateTo("/profiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permission checks
|
||||||
|
if (activeProfile.value) {
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
if (normalizedPath.startsWith("/admin")) {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
return navigateTo("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
270
app/pages/admin/index.vue
Normal file
270
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto p-6 max-w-6xl">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Administration des profils
|
||||||
|
</h1>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="showCreateDialog = true">
|
||||||
|
Nouveau profil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="sortedProfiles"
|
||||||
|
:loading="isLoading"
|
||||||
|
:sort="sortState"
|
||||||
|
:show-counter="false"
|
||||||
|
table-class="table-zebra"
|
||||||
|
empty-message="Aucun profil."
|
||||||
|
@sort="handleSort"
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-medium">{{ row.firstName }} {{ row.lastName }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-email="{ row }">
|
||||||
|
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-role="{ row }">
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-xs"
|
||||||
|
:value="primaryRole(row)"
|
||||||
|
@change="handleRoleChange(row.id, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="ROLE_ADMIN">Admin</option>
|
||||||
|
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||||
|
<option value="ROLE_VIEWER">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-password="{ row }">
|
||||||
|
<span v-if="row.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||||
|
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs ml-1"
|
||||||
|
@click="openPasswordDialog(row.id)"
|
||||||
|
>
|
||||||
|
{{ row.hasPassword ? 'Changer' : 'Definir' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="row.isActive ? 'badge-success' : 'badge-error'"
|
||||||
|
>
|
||||||
|
{{ row.isActive ? 'Actif' : 'Inactif' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<button
|
||||||
|
v-if="row.isActive"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="handleDeactivate(row.id)"
|
||||||
|
>
|
||||||
|
Desactiver
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Create Profile Dialog -->
|
||||||
|
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Nouveau profil
|
||||||
|
</h3>
|
||||||
|
<form @submit.prevent="handleCreate">
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Prenom</span></label>
|
||||||
|
<input v-model="createForm.firstName" type="text" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
|
<input v-model="createForm.lastName" type="text" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Email</span></label>
|
||||||
|
<input v-model="createForm.email" type="email" class="input input-bordered">
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Mot de passe</span></label>
|
||||||
|
<input v-model="createForm.password" type="password" class="input input-bordered">
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Role</span></label>
|
||||||
|
<select v-model="createForm.role" class="select select-bordered">
|
||||||
|
<option value="ROLE_ADMIN">Admin</option>
|
||||||
|
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||||
|
<option value="ROLE_VIEWER">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showCreateDialog = false">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="creating">
|
||||||
|
<span v-if="creating" class="loading loading-spinner loading-xs" />
|
||||||
|
Creer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button type="button" @click="showCreateDialog = false">
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Set Password Dialog -->
|
||||||
|
<dialog ref="passwordDialog" class="modal" :open="showPasswordDialog || undefined">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Definir le mot de passe
|
||||||
|
</h3>
|
||||||
|
<form @submit.prevent="handleSetPassword">
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Nouveau mot de passe</span></label>
|
||||||
|
<input v-model="newPassword" type="password" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showPasswordDialog = false">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="settingPassword">
|
||||||
|
<span v-if="settingPassword" class="loading loading-spinner loading-xs" />
|
||||||
|
Valider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button type="button" @click="showPasswordDialog = false">
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
|
import { useAdminProfiles } from '#imports'
|
||||||
|
|
||||||
|
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||||
|
|
||||||
|
const loaded = ref(false)
|
||||||
|
const isLoading = computed(() => loading.value || !loaded.value)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'email', label: 'Email', sortable: true },
|
||||||
|
{ key: 'role', label: 'Role', sortable: true },
|
||||||
|
{ key: 'password', label: 'Mot de passe' },
|
||||||
|
{ key: 'status', label: 'Statut', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortState = ref({ field: 'name', direction: 'asc' })
|
||||||
|
|
||||||
|
const handleSort = (sort) => {
|
||||||
|
sortState.value = sort
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedProfiles = computed(() => {
|
||||||
|
const { field, direction } = sortState.value
|
||||||
|
const dir = direction === 'desc' ? -1 : 1
|
||||||
|
return [...profiles.value].sort((a, b) => {
|
||||||
|
let valA, valB
|
||||||
|
if (field === 'name') {
|
||||||
|
valA = `${a.firstName} ${a.lastName}`.toLowerCase()
|
||||||
|
valB = `${b.firstName} ${b.lastName}`.toLowerCase()
|
||||||
|
}
|
||||||
|
else if (field === 'role') {
|
||||||
|
valA = primaryRole(a)
|
||||||
|
valB = primaryRole(b)
|
||||||
|
}
|
||||||
|
else if (field === 'status') {
|
||||||
|
valA = a.isActive ? '1' : '0'
|
||||||
|
valB = b.isActive ? '1' : '0'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
valA = (a[field] || '').toLowerCase()
|
||||||
|
valB = (b[field] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
return dir * valA.localeCompare(valB)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showPasswordDialog = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const settingPassword = ref(false)
|
||||||
|
const passwordProfileId = ref(null)
|
||||||
|
const newPassword = ref('')
|
||||||
|
|
||||||
|
const createForm = ref({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'ROLE_VIEWER',
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryRole = (profile) => {
|
||||||
|
const roles = profile.roles || []
|
||||||
|
if (roles.includes('ROLE_ADMIN')) { return 'ROLE_ADMIN' }
|
||||||
|
if (roles.includes('ROLE_GESTIONNAIRE')) { return 'ROLE_GESTIONNAIRE' }
|
||||||
|
return 'ROLE_VIEWER'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...createForm.value }
|
||||||
|
if (!data.email) { delete data.email }
|
||||||
|
if (!data.password) { delete data.password }
|
||||||
|
await createProfile(data)
|
||||||
|
showCreateDialog.value = false
|
||||||
|
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoleChange = async (profileId, role) => {
|
||||||
|
await updateRole(profileId, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPasswordDialog = (profileId) => {
|
||||||
|
passwordProfileId.value = profileId
|
||||||
|
newPassword.value = ''
|
||||||
|
showPasswordDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetPassword = async () => {
|
||||||
|
if (!passwordProfileId.value) { return }
|
||||||
|
settingPassword.value = true
|
||||||
|
try {
|
||||||
|
await setPassword(passwordProfileId.value, newPassword.value)
|
||||||
|
showPasswordDialog.value = false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
settingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivate = async (profileId) => {
|
||||||
|
await deactivateProfile(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchAll()
|
||||||
|
loaded.value = true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
213
app/pages/changelog.vue
Normal file
213
app/pages/changelog.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
|
||||||
|
<header class="space-y-2">
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Historique des modifications et nouvelles fonctionnalités de l'application.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="release in releases"
|
||||||
|
:key="release.version"
|
||||||
|
class="card border border-base-200 bg-base-100 shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="card-body space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-xl font-bold text-base-content">
|
||||||
|
{{ release.version }}
|
||||||
|
</h2>
|
||||||
|
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="(item, i) in release.changes"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 text-sm text-base-content/80"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm mt-0.5 shrink-0"
|
||||||
|
:class="badgeClass(item.type)"
|
||||||
|
>
|
||||||
|
{{ item.type }}
|
||||||
|
</span>
|
||||||
|
<span>{{ item.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useHead } from '#imports'
|
||||||
|
|
||||||
|
useHead({ title: 'Changelog' })
|
||||||
|
|
||||||
|
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore'
|
||||||
|
|
||||||
|
interface Change {
|
||||||
|
type: ChangeType
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
version: string
|
||||||
|
date: string
|
||||||
|
changes: Change[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeClass = (type: ChangeType) => {
|
||||||
|
const map: Record<ChangeType, string> = {
|
||||||
|
feat: 'badge-primary',
|
||||||
|
fix: 'badge-error',
|
||||||
|
perf: 'badge-warning',
|
||||||
|
chore: 'badge-ghost',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'badge-ghost'
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
date: '2026-02-12',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
|
||||||
|
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
|
||||||
|
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.6.0',
|
||||||
|
date: '2026-02-12',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
|
||||||
|
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
|
||||||
|
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
|
||||||
|
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.5.0',
|
||||||
|
date: '2026-02-11',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
|
||||||
|
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
|
||||||
|
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
|
||||||
|
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
|
||||||
|
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
|
||||||
|
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
|
||||||
|
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
|
||||||
|
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
|
||||||
|
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
|
||||||
|
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
|
||||||
|
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
|
||||||
|
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
|
||||||
|
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
|
||||||
|
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.4.0',
|
||||||
|
date: '2026-02-04',
|
||||||
|
changes: [
|
||||||
|
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
|
||||||
|
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.3.0',
|
||||||
|
date: '2026-01-28',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
|
||||||
|
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
|
||||||
|
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
|
||||||
|
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
|
||||||
|
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
|
||||||
|
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
|
||||||
|
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
|
||||||
|
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
|
||||||
|
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
|
||||||
|
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
|
||||||
|
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.2.0',
|
||||||
|
date: '2026-01-21',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
|
||||||
|
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
|
||||||
|
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
|
||||||
|
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.1.1',
|
||||||
|
date: '2026-01-14',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
|
||||||
|
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.1.0',
|
||||||
|
date: '2026-01-07',
|
||||||
|
changes: [
|
||||||
|
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
|
||||||
|
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
|
||||||
|
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.0.0',
|
||||||
|
date: '2025-12-15',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
|
||||||
|
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
|
||||||
|
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
|
||||||
|
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
|
||||||
|
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
||||||
|
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
||||||
|
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
||||||
|
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
|
||||||
|
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
||||||
|
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
||||||
|
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
||||||
|
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
256
app/pages/comments.vue
Normal file
256
app/pages/comments.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-3xl font-semibold text-base-content">
|
||||||
|
Commentaires
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Liste de tous les commentaires et tickets ouverts sur les fiches.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="comments"
|
||||||
|
:loading="loadingList"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucun commentaire trouvé."
|
||||||
|
no-results-message="Aucun commentaire trouvé."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="comment-status"
|
||||||
|
>
|
||||||
|
Statut
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-status"
|
||||||
|
v-model="statusFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="table.handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="open">Ouverts</option>
|
||||||
|
<option value="resolved">Résolus</option>
|
||||||
|
<option value="">Tous</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="comment-entity-type"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-entity-type"
|
||||||
|
v-model="entityTypeFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="table.handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option value="machine">Machine</option>
|
||||||
|
<option value="piece">Pièce</option>
|
||||||
|
<option value="composant">Composant</option>
|
||||||
|
<option value="product">Produit</option>
|
||||||
|
<option value="piece_category">Catégorie pièce</option>
|
||||||
|
<option value="component_category">Catégorie composant</option>
|
||||||
|
<option value="product_category">Catégorie produit</option>
|
||||||
|
<option value="machine_skeleton">Squelette machine</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-content="{ row }">
|
||||||
|
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-entityType="{ row }">
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
{{ entityTypeLabel(row.entityType) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-entity="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="getEntityRoute(row)"
|
||||||
|
:to="getEntityRoute(row)!"
|
||||||
|
class="link link-primary text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ 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>
|
||||||
|
<span v-else class="text-xs text-base-content/50">
|
||||||
|
{{ row.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, type Ref } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
|
import { useComments, type Comment } from '~/composables/useComments'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
fetchAllComments,
|
||||||
|
resolveComment,
|
||||||
|
} = useComments()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loadingList = ref(true)
|
||||||
|
|
||||||
|
const table = useDataTable(
|
||||||
|
{ fetchData: loadComments },
|
||||||
|
{
|
||||||
|
defaultSort: 'createdAt',
|
||||||
|
defaultDirection: 'desc',
|
||||||
|
defaultPerPage: 20,
|
||||||
|
persistToUrl: true,
|
||||||
|
extraParams: {
|
||||||
|
status: { default: 'open' },
|
||||||
|
entityType: { default: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusFilter = table.filters.status as Ref<string>
|
||||||
|
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||||
|
|
||||||
|
const commentsOnPage = computed(() => comments.value.length)
|
||||||
|
const paginationState = table.pagination(total, commentsOnPage)
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
const cols = [
|
||||||
|
{ key: 'content', label: 'Contenu', class: 'max-w-xs' },
|
||||||
|
{ key: 'entityType', label: 'Type' },
|
||||||
|
{ key: 'entity', label: 'Item', filterable: true, filterPlaceholder: 'Rechercher…' },
|
||||||
|
{ key: 'authorName', label: 'Auteur', sortable: true, sortKey: 'authorName' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||||
|
{ key: 'status', label: 'Statut', sortable: true, sortKey: 'status' },
|
||||||
|
]
|
||||||
|
if (canEdit.value) {
|
||||||
|
cols.push({ key: 'actions', label: 'Actions' })
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
})
|
||||||
|
|
||||||
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
machine: 'Machine',
|
||||||
|
piece: 'Pièce',
|
||||||
|
composant: 'Composant',
|
||||||
|
product: 'Produit',
|
||||||
|
piece_category: 'Cat. pièce',
|
||||||
|
component_category: 'Cat. composant',
|
||||||
|
product_category: 'Cat. produit',
|
||||||
|
machine_skeleton: 'Squelette',
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityTypeLabel = (type: string): string =>
|
||||||
|
ENTITY_TYPE_LABELS[type] ?? type
|
||||||
|
|
||||||
|
const formatCommentDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
loadingList.value = true
|
||||||
|
const result = await fetchAllComments({
|
||||||
|
status: statusFilter.value || undefined,
|
||||||
|
entityType: entityTypeFilter.value || undefined,
|
||||||
|
entityName: table.columnFilters.value.entity || undefined,
|
||||||
|
page: table.currentPage.value,
|
||||||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
|
orderBy: table.sortField.value,
|
||||||
|
orderDir: table.sortDirection.value,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = result.data ?? []
|
||||||
|
total.value = result.total ?? 0
|
||||||
|
}
|
||||||
|
loadingList.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (commentId: string) => {
|
||||||
|
const result = await resolveComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
|
||||||
|
machine: (id: string) => `/machine/${id}`,
|
||||||
|
piece: (id: string) => `/pieces/${id}/edit`,
|
||||||
|
composant: (id: string) => `/component/${id}/edit`,
|
||||||
|
product: (id: string) => `/product/${id}/edit`,
|
||||||
|
piece_category: (id: string) => `/piece-category/${id}/edit`,
|
||||||
|
component_category: (id: string) => `/component-category/${id}/edit`,
|
||||||
|
product_category: (id: string) => `/product-category/${id}/edit`,
|
||||||
|
machine_skeleton: (id: string) => `/type/${id}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEntityRoute = (comment: Comment): string | null => {
|
||||||
|
const builder = ENTITY_ROUTE_MAP[comment.entityType]
|
||||||
|
return builder ? builder(comment.entityId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadComments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -26,331 +26,219 @@
|
|||||||
</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
|
|
||||||
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, ref } 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 { usePersistedSort } from '~/composables/usePersistedSort'
|
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 { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||||
|
|
||||||
// Pagination state
|
const table = useDataTable(
|
||||||
const currentPage = ref(1)
|
{ fetchData: fetchComposants },
|
||||||
const itemsPerPage = ref(30)
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||||
const composantsTotal = computed(() => total.value)
|
|
||||||
const composantsOnPage = computed(() => composants.value.length)
|
|
||||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
|
||||||
|
|
||||||
// Search state with debounce
|
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchComposants()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort state
|
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
|
||||||
'component-catalog',
|
|
||||||
{ field: 'name', direction: 'asc' },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enrichir les composants avec les types de composants complets
|
const columns = [
|
||||||
|
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const composantsOnPage = computed(() => componentRows.value.length)
|
||||||
|
const paginationState = table.pagination(total, composantsOnPage)
|
||||||
|
|
||||||
|
// Enrich composants with full type data
|
||||||
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
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
|
typeName: table.columnFilters.value.typeComposant || undefined,
|
||||||
|
force: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchComposants()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchComposants()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePerPageChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchComposants()
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
const 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>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
|
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -34,6 +35,16 @@
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="component_category"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="initialData?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -128,6 +140,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="COMPONENT"
|
initial-category="COMPONENT"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de composant',
|
title: 'Nouvelle catégorie de composant',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -19,9 +20,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
@@ -33,9 +34,9 @@
|
|||||||
Mettez à jour les informations du composant et ses champs personnalisés.
|
Mettez à jour les informations du composant et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -72,13 +73,26 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</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">
|
||||||
@@ -88,7 +102,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +114,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="component?.constructeurs || []"
|
:initial-options="component?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -118,7 +132,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +291,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -286,14 +300,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -304,24 +318,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -329,7 +343,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +361,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
@@ -419,6 +433,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments"
|
:disabled="uploadingDocuments"
|
||||||
@@ -511,6 +526,16 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="composant"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="component?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -566,6 +591,7 @@ interface ComponentCatalogType extends ModelType {
|
|||||||
customFields?: Array<Record<string, any>>
|
customFields?: Array<Record<string, any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
@@ -613,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,
|
||||||
@@ -746,6 +773,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
component.value &&
|
component.value &&
|
||||||
editionForm.name &&
|
editionForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
@@ -792,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,
|
||||||
@@ -832,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()
|
||||||
@@ -857,7 +887,6 @@ const submitEdition = async () => {
|
|||||||
updatedComponent.id,
|
updatedComponent.id,
|
||||||
[
|
[
|
||||||
updatedComponent?.typeComposant?.customFields,
|
updatedComponent?.typeComposant?.customFields,
|
||||||
updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields,
|
|
||||||
],
|
],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
@@ -1136,9 +1165,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Defer bulk catalog loads — not needed for initial render
|
// Defer bulk catalog loads — not needed for initial render
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
loadPieces({ itemsPerPage: 500 }),
|
loadPieces({ itemsPerPage: 200 }),
|
||||||
loadProducts({ itemsPerPage: 500 }),
|
loadProducts({ itemsPerPage: 200 }),
|
||||||
loadComposants({ itemsPerPage: 500 }),
|
loadComposants({ itemsPerPage: 200 }),
|
||||||
]).catch(() => {})
|
]).catch(() => {})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</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">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,13 +45,26 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</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">
|
||||||
@@ -61,7 +74,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +86,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +103,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,7 +257,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -253,14 +266,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -271,24 +284,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -296,7 +309,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +327,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -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'
|
||||||
@@ -401,12 +415,14 @@ const {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
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,
|
||||||
@@ -755,6 +771,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name &&
|
creationForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
@@ -887,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 = ''
|
||||||
@@ -904,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
|
||||||
@@ -976,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
|
||||||
@@ -1196,7 +1219,6 @@ const saveCustomFieldValues = async (createdComponent: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerDefinitions(createdComponent?.typeComposant?.customFields)
|
registerDefinitions(createdComponent?.typeComposant?.customFields)
|
||||||
registerDefinitions(createdComponent?.typeMachineComponentRequirement?.typeComposant?.customFields)
|
|
||||||
|
|
||||||
const resolveDefinitionId = (field: CustomFieldInput) => {
|
const resolveDefinitionId = (field: CustomFieldInput) => {
|
||||||
if (field.customFieldId) {
|
if (field.customFieldId) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Gérez les fournisseurs et leurs coordonnées.
|
Gérez les fournisseurs et leurs coordonnées.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="openCreateModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
Nouveau fournisseur
|
Nouveau fournisseur
|
||||||
</button>
|
</button>
|
||||||
@@ -17,95 +17,70 @@
|
|||||||
|
|
||||||
<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)">
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
|
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Nom</span></label>
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
<input v-model="form.name" type="text" class="input input-bordered" required>
|
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FieldEmail v-model="form.email" label="Email" />
|
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
||||||
<FieldPhone v-model="form.phone" label="Téléphone" />
|
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="closeModal">
|
<button type="button" class="btn" @click="closeModal">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
|
||||||
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
||||||
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -117,7 +92,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } 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'
|
||||||
@@ -126,25 +102,50 @@ import { usePersistedValue } from '~/composables/usePersistedValue'
|
|||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
|
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)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,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) {
|
||||||
@@ -160,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)
|
||||||
@@ -183,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
|
||||||
}
|
}
|
||||||
@@ -194,14 +206,25 @@ const closeModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveConstructeur = async () => {
|
const saveConstructeur = async () => {
|
||||||
|
const trimmedName = form.value.name.trim()
|
||||||
|
const duplicate = constructeurs.value.find(
|
||||||
|
c => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||||
|
&& c.id !== editingConstructeur.value?.id,
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
showError(`Un fournisseur "${duplicate.name}" existe déjà.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const payload = { ...form.value }
|
const payload = { ...form.value, name: trimmedName }
|
||||||
if (!payload.email) { delete payload.email }
|
if (!payload.email) { delete payload.email }
|
||||||
if (!payload.phone) { delete payload.phone }
|
if (!payload.phone) { delete payload.phone }
|
||||||
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
|
||||||
@@ -221,8 +244,5 @@ const confirmDelete = async (constructeur) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadConstructeurs()
|
onMounted(() => loadConstructeurs())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,211 +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 { useDataTable } from '~/composables/useDataTable'
|
||||||
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 searchTerm = ref('')
|
const table = useDataTable(
|
||||||
const attachmentFilter = ref('all')
|
{ fetchData: fetchDocuments },
|
||||||
const previewDocument = ref(null)
|
{
|
||||||
|
defaultSort: 'createdAt',
|
||||||
|
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()
|
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.siteId) ||
|
page: table.currentPage.value,
|
||||||
(filter === 'machine' && document.machineId) ||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
(filter === 'composant' && document.composantId) ||
|
orderBy: table.sortField.value,
|
||||||
(filter === 'piece' && document.pieceId)
|
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 })
|
||||||
|
|
||||||
const downloadDocument = (doc) => {
|
const downloadDocument = (doc: any) => {
|
||||||
if (!doc?.path) { return }
|
if (doc?.downloadUrl) window.open(doc.downloadUrl, '_blank')
|
||||||
|
|
||||||
if (doc.path.startsWith('data:')) {
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = doc.path
|
|
||||||
link.download = doc.filename || doc.name || 'document'
|
|
||||||
link.click()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(doc.path, '_blank')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPreview = (doc) => {
|
const openPreview = (doc: any) => {
|
||||||
if (!canPreviewDocument(doc)) { return }
|
if (!canPreviewDocument(doc)) return
|
||||||
previewDocument.value = doc
|
previewDocument.value = doc
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -216,4 +198,8 @@ const closePreview = () => {
|
|||||||
previewVisible.value = false
|
previewVisible.value = false
|
||||||
previewDocument.value = null
|
previewDocument.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDocuments()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,35 +49,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Type de machine</span>
|
<span class="label-text">Site</span>
|
||||||
</label>
|
</label>
|
||||||
<select v-model="selectedType" class="select select-bordered">
|
<select v-model="selectedSiteFilter" class="select select-bordered">
|
||||||
<option value="">
|
<option value="">
|
||||||
Tous les types
|
Tous les sites
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="type in machineTypes"
|
v-for="site in sites"
|
||||||
:key="type.id"
|
:key="site.id"
|
||||||
:value="type.id"
|
:value="site.id"
|
||||||
>
|
>
|
||||||
{{ type.name }}
|
{{ site.name }}
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Catégorie</span>
|
|
||||||
</label>
|
|
||||||
<select v-model="selectedCategory" class="select select-bordered">
|
|
||||||
<option value="">
|
|
||||||
Toutes les catégories
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="category in categories"
|
|
||||||
:key="category"
|
|
||||||
:value="category"
|
|
||||||
>
|
|
||||||
{{ category }}
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,10 +87,11 @@
|
|||||||
Commencez par ajouter des sites et des machines.
|
Commencez par ajouter des sites et des machines.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
<button class="btn btn-primary" @click="showAddSiteModal = true">
|
<button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@click="showAddMachineModal = true"
|
@click="showAddMachineModal = true"
|
||||||
>
|
>
|
||||||
@@ -207,27 +191,9 @@
|
|||||||
<h4 class="font-semibold text-sm">
|
<h4 class="font-semibold text-sm">
|
||||||
{{ machine.name }}
|
{{ machine.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
|
||||||
class="badge badge-sm"
|
|
||||||
:class="
|
|
||||||
getCategoryBadgeClass(machine.typeMachine?.category)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ machine.typeMachine?.category || "N/A" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1 text-xs text-gray-600">
|
<div class="space-y-1 text-xs text-gray-600">
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<IconLucideSettings2
|
|
||||||
class="w-3 h-3"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>{{
|
|
||||||
machine.typeMachine?.name || "Type inconnu"
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="machine.reference"
|
v-if="machine.reference"
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1"
|
||||||
@@ -239,12 +205,14 @@
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-3">
|
<div class="card-actions justify-end mt-3">
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-xs btn-outline"
|
class="btn btn-xs btn-outline"
|
||||||
@click.stop="editMachine(machine)"
|
@click.stop="editMachine(machine)"
|
||||||
>
|
>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-xs btn-error"
|
class="btn btn-xs btn-error"
|
||||||
@click.stop="confirmDeleteMachine(machine)"
|
@click.stop="confirmDeleteMachine(machine)"
|
||||||
>
|
>
|
||||||
@@ -277,6 +245,7 @@
|
|||||||
Aucune machine dans ce site
|
Aucune machine dans ce site
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
@click="addMachineToSite(site)"
|
@click="addMachineToSite(site)"
|
||||||
>
|
>
|
||||||
@@ -304,11 +273,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Usine de production"
|
placeholder="Ex: Usine de production"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="newSite" />
|
<SiteContactFormFields :form="newSite" :disabled="!canEdit" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button
|
<button
|
||||||
@@ -318,7 +288,7 @@
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||||
Créer le site
|
Créer le site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,6 +313,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Presse hydraulique #1"
|
placeholder="Ex: Presse hydraulique #1"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +325,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="newMachine.siteId"
|
v-model="newMachine.siteId"
|
||||||
class="select select-bordered"
|
class="select select-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -366,76 +338,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div class="form-control mb-4">
|
||||||
<div class="form-control">
|
<label class="label">
|
||||||
<label class="label">
|
<span class="label-text">Référence</span>
|
||||||
<span class="label-text">Type de machine</span>
|
</label>
|
||||||
</label>
|
<input
|
||||||
<select
|
v-model="newMachine.reference"
|
||||||
v-model="newMachine.typeMachineId"
|
type="text"
|
||||||
class="select select-bordered"
|
placeholder="Ex: PRESS-001"
|
||||||
required
|
class="input input-bordered"
|
||||||
>
|
:disabled="!canEdit"
|
||||||
<option value="">
|
>
|
||||||
Sélectionner un type
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="type in machineTypes"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }} ({{ type.category }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="newMachine.reference"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex: PRESS-001"
|
|
||||||
class="input input-bordered"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type Preview -->
|
|
||||||
<div
|
|
||||||
v-if="selectedMachineType"
|
|
||||||
class="mb-4 p-4 bg-gray-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<h4 class="font-semibold text-sm mb-2">
|
|
||||||
Structure du type sélectionné :
|
|
||||||
</h4>
|
|
||||||
<div class="text-xs space-y-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">Familles de composants :</span>
|
|
||||||
<span class="badge badge-sm">{{
|
|
||||||
selectedMachineType.componentRequirements?.length || 0
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">Groupes de pièces :</span>
|
|
||||||
<span class="badge badge-sm">{{
|
|
||||||
selectedMachineType.pieceRequirements?.length || 0
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">Produits requis :</span>
|
|
||||||
<span class="badge badge-sm">{{
|
|
||||||
selectedMachineType.productRequirements?.length || 0
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">Catégorie :</span>
|
|
||||||
<span class="badge badge-outline badge-sm">{{
|
|
||||||
selectedMachineType.category
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
@@ -446,7 +359,7 @@
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||||
Créer la machine
|
Créer la machine
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -460,30 +373,28 @@
|
|||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
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'
|
||||||
import IconLucidePhone from '~icons/lucide/phone'
|
import IconLucidePhone from '~icons/lucide/phone'
|
||||||
import IconLucideMapPinned from '~icons/lucide/map-pinned'
|
import IconLucideMapPinned from '~icons/lucide/map-pinned'
|
||||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
import IconLucideTag from '~icons/lucide/tag'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { sites, loading, loadSites, createSite } = useSites()
|
const { sites, loading, loadSites, createSite } = useSites()
|
||||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
|
||||||
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const showAddSiteModal = ref(false)
|
const showAddSiteModal = ref(false)
|
||||||
const showAddMachineModal = ref(false)
|
const showAddMachineModal = ref(false)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedType = ref('')
|
const selectedSiteFilter = ref('')
|
||||||
const selectedCategory = ref('')
|
|
||||||
const collapsedSites = ref([])
|
const collapsedSites = ref([])
|
||||||
|
|
||||||
const newSite = reactive({
|
const newSite = reactive({
|
||||||
@@ -498,48 +409,14 @@ const newSite = reactive({
|
|||||||
const newMachine = reactive({
|
const newMachine = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
siteId: '',
|
siteId: '',
|
||||||
typeMachineId: '',
|
|
||||||
reference: ''
|
reference: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const selectedMachineType = computed(() => {
|
|
||||||
if (!newMachine.typeMachineId) { return null }
|
|
||||||
return machineTypes.value.find(
|
|
||||||
type => type.id === newMachine.typeMachineId
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = computed(() => {
|
|
||||||
const cats = new Set()
|
|
||||||
machineTypes.value.forEach((type) => {
|
|
||||||
if (type.category) { cats.add(type.category) }
|
|
||||||
})
|
|
||||||
return Array.from(cats)
|
|
||||||
})
|
|
||||||
|
|
||||||
const machinesWithType = computed(() => {
|
|
||||||
return machines.value.map((machine) => {
|
|
||||||
const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
|
|
||||||
const resolvedTypeMachine = resolvedTypeMachineId
|
|
||||||
? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
|
||||||
...machine,
|
|
||||||
typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
|
|
||||||
typeMachine:
|
|
||||||
machine.typeMachine && typeof machine.typeMachine === 'object'
|
|
||||||
? machine.typeMachine
|
|
||||||
: resolvedTypeMachine
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const machinesBySiteId = computed(() => {
|
const machinesBySiteId = computed(() => {
|
||||||
const map = new Map()
|
const map = new Map()
|
||||||
|
|
||||||
machinesWithType.value.forEach((machine) => {
|
machines.value.forEach((machine) => {
|
||||||
const siteId = machine.siteId || extractRelationId(machine.site)
|
const siteId = machine.siteId || extractRelationId(machine.site)
|
||||||
if (!siteId) { return }
|
if (!siteId) { return }
|
||||||
|
|
||||||
@@ -577,6 +454,11 @@ const formatPhoneDisplay = (value) => {
|
|||||||
const filteredSites = computed(() => {
|
const filteredSites = computed(() => {
|
||||||
let filtered = sitesWithMachines.value
|
let filtered = sitesWithMachines.value
|
||||||
|
|
||||||
|
// Filtrer par site
|
||||||
|
if (selectedSiteFilter.value) {
|
||||||
|
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Filtrer par terme de recherche
|
// Filtrer par terme de recherche
|
||||||
if (searchTerm.value) {
|
if (searchTerm.value) {
|
||||||
filtered = filtered.filter((site) => {
|
filtered = filtered.filter((site) => {
|
||||||
@@ -605,33 +487,6 @@ const filteredSites = computed(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrer par type de machine
|
|
||||||
if (selectedType.value) {
|
|
||||||
filtered = filtered
|
|
||||||
.map(site => ({
|
|
||||||
...site,
|
|
||||||
machines:
|
|
||||||
site.machines?.filter(
|
|
||||||
machine => machine.typeMachineId === selectedType.value
|
|
||||||
) || []
|
|
||||||
}))
|
|
||||||
.filter(site => site.machines.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer par catégorie
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
filtered = filtered
|
|
||||||
.map(site => ({
|
|
||||||
...site,
|
|
||||||
machines:
|
|
||||||
site.machines?.filter(
|
|
||||||
machine =>
|
|
||||||
machine.typeMachine?.category === selectedCategory.value
|
|
||||||
) || []
|
|
||||||
}))
|
|
||||||
.filter(site => site.machines.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -659,27 +514,15 @@ const handleCreateSite = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateMachine = async () => {
|
const handleCreateMachine = async () => {
|
||||||
if (!selectedMachineType.value) {
|
const result = await createMachine({
|
||||||
console.error('Aucun type de machine sélectionné')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const machineData = {
|
|
||||||
name: newMachine.name,
|
name: newMachine.name,
|
||||||
siteId: newMachine.siteId,
|
siteId: newMachine.siteId,
|
||||||
reference: newMachine.reference
|
reference: newMachine.reference
|
||||||
}
|
})
|
||||||
|
|
||||||
const result = await createMachineFromType(
|
|
||||||
machineData,
|
|
||||||
selectedMachineType.value
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Reset form
|
|
||||||
newMachine.name = ''
|
newMachine.name = ''
|
||||||
newMachine.siteId = ''
|
newMachine.siteId = ''
|
||||||
newMachine.typeMachineId = ''
|
|
||||||
newMachine.reference = ''
|
newMachine.reference = ''
|
||||||
showAddMachineModal.value = false
|
showAddMachineModal.value = false
|
||||||
await loadMachines()
|
await loadMachines()
|
||||||
@@ -721,10 +564,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)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,19 +577,8 @@ const addMachineToSite = (site) => {
|
|||||||
showAddMachineModal.value = true
|
showAddMachineModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCategoryBadgeClass = (category) => {
|
|
||||||
const classes = {
|
|
||||||
Production: 'badge-primary',
|
|
||||||
Transformation: 'badge-secondary',
|
|
||||||
Manutention: 'badge-accent',
|
|
||||||
Traitement: 'badge-info',
|
|
||||||
Contrôle: 'badge-warning'
|
|
||||||
}
|
|
||||||
return classes[category] || 'badge-neutral'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
|
await Promise.all([loadSites(), loadMachines()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
|
||||||
<!-- Machine Types List -->
|
|
||||||
<div class="my-8">
|
|
||||||
<!-- Header with Add Button -->
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-800">Squelettes de machine</h2>
|
|
||||||
<NuxtLink to="/machine-skeleton/new" class="btn btn-primary">
|
|
||||||
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
|
||||||
Créer un type
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Categories Tabs -->
|
|
||||||
<div class="tabs tabs-boxed mb-6">
|
|
||||||
<a
|
|
||||||
v-for="category in categories"
|
|
||||||
:key="category"
|
|
||||||
class="tab"
|
|
||||||
:class="{ 'tab-active': selectedCategory === category }"
|
|
||||||
@click="selectedCategory = category"
|
|
||||||
>
|
|
||||||
{{ category }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Machine Types Grid -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<div
|
|
||||||
v-for="type in filteredTypes"
|
|
||||||
:key="type.id"
|
|
||||||
class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="card-title text-lg">
|
|
||||||
{{ type.name }}
|
|
||||||
</h3>
|
|
||||||
<div class="badge badge-primary">
|
|
||||||
{{ type.category }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-600 mb-4">
|
|
||||||
{{ type.description }}
|
|
||||||
</p>
|
|
||||||
<div class="space-y-2 text-sm text-gray-500">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucidePackage class="w-4 h-4" aria-hidden="true" />
|
|
||||||
<span
|
|
||||||
>{{ type.componentRequirements?.length || 0 }} famille(s) de
|
|
||||||
composants</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucideLayoutGrid class="w-4 h-4" aria-hidden="true" />
|
|
||||||
<span
|
|
||||||
>{{ type.pieceRequirements?.length || 0 }} groupe(s) de
|
|
||||||
pièces</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucideBox class="w-4 h-4" aria-hidden="true" />
|
|
||||||
<span
|
|
||||||
>{{ type.productRequirements?.length || 0 }} produit(s)
|
|
||||||
requis</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-actions justify-end mt-4">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-error"
|
|
||||||
@click.stop="confirmDeleteType(type)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<NuxtLink :to="`/type/${type.id}`" class="btn btn-sm btn-outline">
|
|
||||||
Voir détails
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="filteredTypes.length === 0" class="text-center py-12">
|
|
||||||
<div class="avatar placeholder">
|
|
||||||
<div class="bg-neutral text-neutral-content rounded-full w-16">
|
|
||||||
<IconLucideLayoutGrid class="w-8 h-8" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-600 mt-4">
|
|
||||||
Aucun type trouvé
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-500">
|
|
||||||
Aucun type de machine ne correspond à cette catégorie.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from "vue";
|
|
||||||
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
|
|
||||||
import { useToast } from "~/composables/useToast";
|
|
||||||
import IconLucidePlus from "~icons/lucide/plus";
|
|
||||||
import IconLucidePackage from "~icons/lucide/package";
|
|
||||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
|
||||||
import IconLucideBox from "~icons/lucide/box";
|
|
||||||
|
|
||||||
const { machineTypes, loadMachineTypes, deleteMachineType } =
|
|
||||||
useMachineTypesApi();
|
|
||||||
|
|
||||||
const categories = ref([
|
|
||||||
"Toutes",
|
|
||||||
"Production",
|
|
||||||
"Transformation",
|
|
||||||
"Manutention",
|
|
||||||
"Traitement",
|
|
||||||
"Contrôle",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const selectedCategory = ref("Toutes");
|
|
||||||
|
|
||||||
const filteredTypes = computed(() => {
|
|
||||||
if (selectedCategory.value === "Toutes") {
|
|
||||||
return machineTypes.value;
|
|
||||||
}
|
|
||||||
return machineTypes.value.filter(
|
|
||||||
(type) => type.category === selectedCategory.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { confirm: confirmDialog } = useConfirm();
|
|
||||||
|
|
||||||
const confirmDeleteType = async (type) => {
|
|
||||||
const { showError, showSuccess } = useToast();
|
|
||||||
|
|
||||||
if (
|
|
||||||
await confirmDialog({
|
|
||||||
message: `Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.`,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const result = await deleteMachineType(type.id);
|
|
||||||
if (result.success) {
|
|
||||||
showSuccess(`Type "${type.name}" supprimé avec succès`);
|
|
||||||
} else {
|
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load machine types on mount
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadMachineTypes();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="container mx-auto px-6 py-8 space-y-8">
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body space-y-6">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
<IconLucidePlus
|
|
||||||
class="h-5 w-5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 class="card-title text-2xl">
|
|
||||||
Nouveau type de machine
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Complétez les informations puis enregistrez pour générer le nouveau type.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TypeEditForm
|
|
||||||
:key="formKey"
|
|
||||||
v-model="draftType"
|
|
||||||
:saving="creating"
|
|
||||||
:resettable="false"
|
|
||||||
submit-label="Créer le type"
|
|
||||||
submit-loading-label="Création..."
|
|
||||||
@submit="handleSubmit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="space-y-4">
|
|
||||||
<div v-if="initialLoading" class="text-center py-12 text-sm text-gray-500">
|
|
||||||
Chargement des types existants...
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="recentTypes.length" class="space-y-4">
|
|
||||||
<h3 class="text-xl font-semibold">
|
|
||||||
Types générés récemment
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<article
|
|
||||||
v-for="type in recentTypes"
|
|
||||||
:key="type.id"
|
|
||||||
class="card bg-base-100 shadow-md border border-base-200"
|
|
||||||
>
|
|
||||||
<div class="card-body space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="card-title text-base">
|
|
||||||
{{ type.name }}
|
|
||||||
</h4>
|
|
||||||
<span v-if="type.category" class="badge badge-outline badge-sm">{{ type.category }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 line-clamp-3">
|
|
||||||
{{ type.description || 'Aucune description' }}
|
|
||||||
</p>
|
|
||||||
<div class="text-xs text-gray-500 flex items-center gap-2">
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<IconLucideClipboardList class="h-4 w-4" aria-hidden="true" />
|
|
||||||
{{ type.componentRequirements?.length || 0 }} famille(s)
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<IconLucideList class="h-4 w-4" aria-hidden="true" />
|
|
||||||
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<IconLucideBox class="h-4 w-4" aria-hidden="true" />
|
|
||||||
{{ type.productRequirements?.length || 0 }} produit(s)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center py-12 text-sm text-gray-500">
|
|
||||||
Aucun type généré récemment.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
|
||||||
import IconLucideList from '~icons/lucide/list'
|
|
||||||
import IconLucideBox from '~icons/lucide/box'
|
|
||||||
|
|
||||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
|
||||||
const { showError } = useToast()
|
|
||||||
|
|
||||||
const formKey = ref(0)
|
|
||||||
const creating = ref(false)
|
|
||||||
const initialLoading = ref(true)
|
|
||||||
|
|
||||||
const createEmptyType = () => ({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
category: '',
|
|
||||||
maintenanceFrequency: '',
|
|
||||||
customFields: [],
|
|
||||||
componentRequirements: [],
|
|
||||||
pieceRequirements: [],
|
|
||||||
productRequirements: []
|
|
||||||
})
|
|
||||||
|
|
||||||
const draftType = ref(createEmptyType())
|
|
||||||
|
|
||||||
const recentTypes = computed(() => machineTypes.value.slice(-3).reverse())
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!machineTypes.value.length) {
|
|
||||||
try {
|
|
||||||
initialLoading.value = true
|
|
||||||
await loadMachineTypes()
|
|
||||||
} finally {
|
|
||||||
initialLoading.value = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
initialLoading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const parseOptions = (field = {}) => {
|
|
||||||
if (field.type !== 'select') { return [] }
|
|
||||||
if (field.optionsText && typeof field.optionsText === 'string') {
|
|
||||||
return field.optionsText
|
|
||||||
.split('\n')
|
|
||||||
.map(option => option.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
if (Array.isArray(field.options)) {
|
|
||||||
return field.options
|
|
||||||
.map(option => String(option).trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const toModelTypeIri = (value) => {
|
|
||||||
if (!value) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
const relationId = extractRelationId(value)
|
|
||||||
if (relationId) {
|
|
||||||
return `/api/model_types/${relationId}`
|
|
||||||
}
|
|
||||||
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeCustomFields = (fields = []) =>
|
|
||||||
fields
|
|
||||||
.filter(field => field?.name && field.name.trim() !== '')
|
|
||||||
.map((field, index) => ({
|
|
||||||
name: field.name,
|
|
||||||
type: field.type || '',
|
|
||||||
required: !!field.required,
|
|
||||||
options: parseOptions(field),
|
|
||||||
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
|
||||||
|
|
||||||
const toIntegerOrNull = (value, fallback = null) => {
|
|
||||||
if (value === '' || value === undefined || value === null) {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
const parsed = Number(value)
|
|
||||||
return Number.isFinite(parsed) ? parsed : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeComponentRequirements = (requirements = []) =>
|
|
||||||
requirements
|
|
||||||
.filter(req => req?.typeComposantId || req?.typeComposant)
|
|
||||||
.map((req, index) => ({
|
|
||||||
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
|
|
||||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
|
||||||
minCount: toIntegerOrNull(req.minCount, 1),
|
|
||||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
|
||||||
required: req.required ?? true,
|
|
||||||
allowNewModels: req.allowNewModels ?? true,
|
|
||||||
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
.map((req, index) => ({ ...req, orderIndex: index }))
|
|
||||||
|
|
||||||
const normalizePieceRequirements = (requirements = []) =>
|
|
||||||
requirements
|
|
||||||
.filter(req => req?.typePieceId || req?.typePiece)
|
|
||||||
.map((req, index) => ({
|
|
||||||
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
|
|
||||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
|
||||||
minCount: toIntegerOrNull(req.minCount, 0),
|
|
||||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
|
||||||
required: req.required ?? false,
|
|
||||||
allowNewModels: req.allowNewModels ?? true,
|
|
||||||
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
.map((req, index) => ({ ...req, orderIndex: index }))
|
|
||||||
|
|
||||||
const normalizeProductRequirements = (requirements = []) =>
|
|
||||||
requirements
|
|
||||||
.filter(req => req?.typeProductId || req?.typeProduct)
|
|
||||||
.map((req, index) => ({
|
|
||||||
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
|
|
||||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
|
||||||
minCount: toIntegerOrNull(req.minCount, 0),
|
|
||||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
|
||||||
required: req.required ?? false,
|
|
||||||
allowNewModels: req.allowNewModels ?? true,
|
|
||||||
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
.map((req, index) => ({ ...req, orderIndex: index }))
|
|
||||||
|
|
||||||
const buildPayload = typeData => ({
|
|
||||||
name: typeData.name,
|
|
||||||
description: typeData.description,
|
|
||||||
category: typeData.category,
|
|
||||||
maintenanceFrequency: typeData.maintenanceFrequency,
|
|
||||||
customFields: normalizeCustomFields(typeData.customFields),
|
|
||||||
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
|
|
||||||
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
|
|
||||||
productRequirements: normalizeProductRequirements(typeData.productRequirements)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
draftType.value = createEmptyType()
|
|
||||||
formKey.value += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!draftType.value.name?.trim()) {
|
|
||||||
showError('Le nom du type est requis.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildPayload(draftType.value)
|
|
||||||
|
|
||||||
creating.value = true
|
|
||||||
const result = await createMachineType(payload)
|
|
||||||
creating.value = false
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
resetForm()
|
|
||||||
} else if (result?.error) {
|
|
||||||
showError(result.error)
|
|
||||||
} else {
|
|
||||||
showError('Impossible de créer le type.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -10,113 +10,127 @@
|
|||||||
<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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Header with actions -->
|
<!-- Header with actions -->
|
||||||
<MachineDetailHeader
|
<MachineDetailHeader
|
||||||
:title="machineViewTitle"
|
:title="machineViewTitle"
|
||||||
:is-details-view="s.isDetailsView.value"
|
|
||||||
:is-skeleton-view="s.isSkeletonView.value"
|
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
:has-skeleton-requirements="d.machineHasSkeletonRequirements.value"
|
|
||||||
@change-view="s.changeMachineView"
|
|
||||||
@toggle-edit="d.toggleEditMode"
|
@toggle-edit="d.toggleEditMode"
|
||||||
@open-print="d.openPrintModal"
|
@open-print="d.openPrintModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="s.isDetailsView.value">
|
<!-- Debug info -->
|
||||||
<!-- Debug info -->
|
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
|
||||||
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
|
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
|
||||||
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
|
<p>Components count: {{ d.components.value.length }}</p>
|
||||||
<p>Components count: {{ d.components.value.length }}</p>
|
<p>Pieces count: {{ d.pieces.value.length }}</p>
|
||||||
<p>Pieces count: {{ d.pieces.value.length }}</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<PageHero
|
<PageHero
|
||||||
:title="d.machine.value.name"
|
:title="d.machine.value.name"
|
||||||
:subtitle="d.machine.value.description || d.machine.value.typeMachine?.description"
|
:subtitle="d.machine.value.description"
|
||||||
min-height="min-h-[20vh]"
|
min-height="min-h-[20vh]"
|
||||||
max-width="max-w-md"
|
max-width="max-w-md"
|
||||||
rounded
|
rounded
|
||||||
>
|
>
|
||||||
<div class="flex justify-center gap-4">
|
<div class="flex justify-center gap-4">
|
||||||
<div v-if="d.machine.value.typeMachine?.category" class="badge badge-outline">
|
<div v-if="d.machine.value.site?.name" class="badge badge-outline">
|
||||||
{{ d.machine.value.typeMachine?.category }}
|
{{ d.machine.value.site?.name }}
|
||||||
</div>
|
|
||||||
<div v-if="d.machine.value.site?.name" class="badge badge-outline">
|
|
||||||
{{ d.machine.value.site?.name }}
|
|
||||||
</div>
|
|
||||||
<div v-if="d.machine.value.reference" class="badge badge-outline">
|
|
||||||
{{ d.machine.value.reference }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PageHero>
|
<div v-if="d.machine.value.reference" class="badge badge-outline">
|
||||||
|
{{ d.machine.value.reference }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageHero>
|
||||||
|
|
||||||
<!-- Machine Info Card -->
|
<!-- Machine Info Card -->
|
||||||
<MachineInfoCard
|
<MachineInfoCard
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
:machine-name="d.machineName.value"
|
:machine-name="d.machineName.value"
|
||||||
:machine-reference="d.machineReference.value"
|
:machine-reference="d.machineReference.value"
|
||||||
:machine-constructeur-ids="d.machineConstructeurIds.value"
|
:machine-constructeur-ids="d.machineConstructeurIds.value"
|
||||||
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
|
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
|
||||||
:has-machine-constructeur="d.hasMachineConstructeur.value"
|
:has-machine-constructeur="d.hasMachineConstructeur.value"
|
||||||
:visible-custom-fields="d.visibleMachineCustomFields.value"
|
:visible-custom-fields="d.visibleMachineCustomFields.value"
|
||||||
:get-machine-field-id="d.getMachineFieldId"
|
:get-machine-field-id="d.getMachineFieldId"
|
||||||
@update:machine-name="d.machineName.value = $event"
|
@update:machine-name="d.machineName.value = $event"
|
||||||
@update:machine-reference="d.machineReference.value = $event"
|
@update:machine-reference="d.machineReference.value = $event"
|
||||||
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
||||||
@blur-field="d.updateMachineInfo"
|
@blur-field="d.updateMachineInfo"
|
||||||
@set-custom-field-value="d.setMachineCustomFieldValue"
|
@set-custom-field-value="d.setMachineCustomFieldValue"
|
||||||
@update-custom-field="d.updateMachineCustomField"
|
@update-custom-field="d.updateMachineCustomField"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Documents -->
|
||||||
|
<MachineDocumentsCard
|
||||||
|
:documents="d.machineDocumentsList.value"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
|
:uploading="d.machineDocumentsUploading.value"
|
||||||
|
:files="d.machineDocumentFiles.value"
|
||||||
|
@update:files="d.machineDocumentFiles.value = $event"
|
||||||
|
@files-added="d.handleMachineFilesAdded"
|
||||||
|
@preview="d.openPreview"
|
||||||
|
@download="d.downloadDocument"
|
||||||
|
@remove="d.removeMachineDocument"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Produits associés -->
|
||||||
|
<MachineProductsCard
|
||||||
|
:products="d.machineDirectProducts.value"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
|
@add-product="openAddModal('product')"
|
||||||
|
@remove-product="d.removeProductLink"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Components Section -->
|
||||||
|
<MachineComponentsCard
|
||||||
|
:components="d.components.value"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
|
:collapsed="d.componentsCollapsed.value"
|
||||||
|
:collapse-toggle-token="d.collapseToggleToken.value"
|
||||||
|
@toggle-collapse="d.toggleAllComponents"
|
||||||
|
@update-component="d.updateComponent"
|
||||||
|
@edit-piece="d.updatePieceFromComponent"
|
||||||
|
@custom-field-update="d.updatePieceCustomField"
|
||||||
|
@add-component="openAddModal('component')"
|
||||||
|
@remove-component="d.removeComponentLink"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Machine Pieces Section -->
|
||||||
|
<MachinePiecesCard
|
||||||
|
:pieces="d.machinePieces.value"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
|
:collapsed="d.piecesCollapsed.value"
|
||||||
|
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
|
||||||
|
@update-piece="d.updatePieceInfo"
|
||||||
|
@edit-piece="d.editPiece"
|
||||||
|
@custom-field-update="d.updatePieceCustomField"
|
||||||
|
@add-piece="openAddModal('piece')"
|
||||||
|
@remove-piece="d.removePieceLink"
|
||||||
|
@toggle-collapse="d.toggleAllPieces"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Add Entity Modal -->
|
||||||
|
<AddEntityToMachineModal
|
||||||
|
:open="addModalOpen"
|
||||||
|
:entity-kind="addModalKind"
|
||||||
|
@close="addModalOpen = false"
|
||||||
|
@confirm="handleAddEntity"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="machine"
|
||||||
|
:entity-id="String(machineId)"
|
||||||
|
:entity-name="d.machine.value?.name"
|
||||||
|
show-resolved
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<!-- Documents -->
|
|
||||||
<MachineDocumentsCard
|
|
||||||
:documents="d.machineDocumentsList.value"
|
|
||||||
:is-edit-mode="d.isEditMode.value"
|
|
||||||
:uploading="d.machineDocumentsUploading.value"
|
|
||||||
:files="d.machineDocumentFiles.value"
|
|
||||||
@update:files="d.machineDocumentFiles.value = $event"
|
|
||||||
@files-added="d.handleMachineFilesAdded"
|
|
||||||
@preview="d.openPreview"
|
|
||||||
@download="d.downloadDocument"
|
|
||||||
@remove="d.removeMachineDocument"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Produits associés -->
|
|
||||||
<MachineProductsCard :products="d.machineDirectProducts.value" />
|
|
||||||
|
|
||||||
<!-- Components Section -->
|
|
||||||
<MachineComponentsCard
|
|
||||||
:components="d.components.value"
|
|
||||||
:is-edit-mode="d.isEditMode.value"
|
|
||||||
:collapsed="d.componentsCollapsed.value"
|
|
||||||
:collapse-toggle-token="d.collapseToggleToken.value"
|
|
||||||
@toggle-collapse="d.toggleAllComponents"
|
|
||||||
@update-component="d.updateComponent"
|
|
||||||
@edit-piece="d.updatePieceFromComponent"
|
|
||||||
@custom-field-update="d.updatePieceCustomField"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Machine Pieces Section -->
|
|
||||||
<MachinePiecesCard
|
|
||||||
:pieces="d.machinePieces.value"
|
|
||||||
:is-edit-mode="d.isEditMode.value"
|
|
||||||
@update-piece="d.updatePieceInfo"
|
|
||||||
@edit-piece="d.editPiece"
|
|
||||||
@custom-field-update="d.updatePieceCustomField"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<MachineSkeletonSummary
|
|
||||||
:component-requirement-groups="d.componentRequirementGroups.value"
|
|
||||||
:piece-requirement-groups="d.pieceRequirementGroups.value"
|
|
||||||
:product-requirement-groups="d.productRequirementGroups.value"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
@@ -145,10 +159,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||||
import { useMachineSkeletonEditor } from '~/composables/useMachineSkeletonEditor'
|
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import PageHero from '~/components/PageHero.vue'
|
import PageHero from '~/components/PageHero.vue'
|
||||||
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
|
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
|
||||||
@@ -158,11 +171,12 @@ import MachineDocumentsCard from '~/components/machine/MachineDocumentsCard.vue'
|
|||||||
import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
|
import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
|
||||||
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
|
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
|
||||||
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
|
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
|
||||||
import MachineSkeletonSummary from '~/components/machine/MachineSkeletonSummary.vue'
|
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
|
||||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const machineId = route.params.id
|
const machineId = route.params.id
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
if (!machineId) {
|
if (!machineId) {
|
||||||
console.error('ID de machine manquant')
|
console.error('ID de machine manquant')
|
||||||
@@ -170,41 +184,25 @@ if (!machineId) {
|
|||||||
|
|
||||||
const d = useMachineDetailData(machineId)
|
const d = useMachineDetailData(machineId)
|
||||||
|
|
||||||
const s = useMachineSkeletonEditor({
|
const addModalOpen = ref(false)
|
||||||
machine: d.machine,
|
const addModalKind = ref('component')
|
||||||
components: d.components,
|
|
||||||
pieces: d.pieces,
|
const openAddModal = (kind) => {
|
||||||
machineComponentLinks: d.machineComponentLinks,
|
addModalKind.value = kind
|
||||||
machinePieceLinks: d.machinePieceLinks,
|
addModalOpen.value = true
|
||||||
machineProductLinks: d.machineProductLinks,
|
}
|
||||||
machineType: d.machineType,
|
|
||||||
machineHasSkeletonRequirements: d.machineHasSkeletonRequirements,
|
const handleAddEntity = async (entityId) => {
|
||||||
componentRequirements: d.componentRequirements,
|
if (addModalKind.value === 'component') {
|
||||||
pieceRequirements: d.pieceRequirements,
|
await d.addComponentLink(entityId)
|
||||||
productRequirements: d.productRequirements,
|
} else if (addModalKind.value === 'piece') {
|
||||||
componentTypeLabelMap: d.componentTypeLabelMap,
|
await d.addPieceLink(entityId)
|
||||||
pieceTypeLabelMap: d.pieceTypeLabelMap,
|
} else {
|
||||||
productInventory: d.productInventory,
|
await d.addProductLink(entityId)
|
||||||
flattenedComponents: d.flattenedComponents,
|
}
|
||||||
machinePieces: d.machinePieces,
|
}
|
||||||
machineDocumentsLoaded: d.machineDocumentsLoaded,
|
|
||||||
findProductById: d.findProductById,
|
|
||||||
findComponentById: d.findComponentById,
|
|
||||||
findPieceById: d.findPieceById,
|
|
||||||
transformCustomFields: d.transformCustomFields,
|
|
||||||
transformComponentCustomFields: d.transformComponentCustomFields,
|
|
||||||
applyMachineLinks: d.applyMachineLinks,
|
|
||||||
collapseAllComponents: d.collapseAllComponents,
|
|
||||||
initMachineFields: d.initMachineFields,
|
|
||||||
collectPiecesForSkeleton: d.collectPiecesForSkeleton,
|
|
||||||
constructeurs: d.constructeurs,
|
|
||||||
loadProducts: d.loadProducts,
|
|
||||||
reconfigureMachineSkeleton: d.reconfigureMachineSkeleton,
|
|
||||||
toast: d.toast,
|
|
||||||
})
|
|
||||||
|
|
||||||
const machineViewTitle = computed(() => {
|
const machineViewTitle = computed(() => {
|
||||||
if (s.isSkeletonView.value) return 'Squelette de la machine'
|
|
||||||
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
|
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,7 +210,7 @@ onMounted(() => {
|
|||||||
d.loadMachineData()
|
d.loadMachineData()
|
||||||
d.loadInitialData()
|
d.loadInitialData()
|
||||||
|
|
||||||
if (route.query.edit === 'true') {
|
if (route.query.edit === 'true' && canEdit.value) {
|
||||||
d.isEditMode.value = true
|
d.isEditMode.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<div class="card bg-base-100 shadow-lg mb-6">
|
<div class="card bg-base-100 shadow-lg mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Site</span>
|
<span class="label-text">Site</span>
|
||||||
@@ -29,29 +29,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Type de machine</span>
|
<span class="label-text">Recherche</span>
|
||||||
</label>
|
</label>
|
||||||
<select v-model="selectedType" class="select select-bordered">
|
<input
|
||||||
<option value="">
|
v-model="searchQuery"
|
||||||
Tous les types
|
type="text"
|
||||||
</option>
|
placeholder="Rechercher par nom ou référence..."
|
||||||
<option v-for="type in machineTypes" :key="type.id" :value="type.id">
|
class="input input-bordered"
|
||||||
{{ type.name }}
|
>
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Catégorie</span>
|
|
||||||
</label>
|
|
||||||
<select v-model="selectedCategory" class="select select-bordered">
|
|
||||||
<option value="">
|
|
||||||
Toutes les catégories
|
|
||||||
</option>
|
|
||||||
<option v-for="category in categories" :key="category" :value="category">
|
|
||||||
{{ category }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,26 +73,14 @@
|
|||||||
<h3 class="card-title text-lg">
|
<h3 class="card-title text-lg">
|
||||||
{{ machine.name }}
|
{{ machine.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="badge badge-primary badge-sm">
|
|
||||||
{{ machine.typeMachine?.category || 'N/A' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 mb-3">
|
|
||||||
{{ machine.description || machine.typeMachine?.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<IconLucideMapPin class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
<IconLucideMapPin class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
||||||
<span class="text-gray-600">{{ machine.site?.name || 'Site inconnu' }}</span>
|
<span class="text-gray-600">{{ machine.site?.name || 'Site inconnu' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucideSettings2 class="w-4 h-4 text-green-500" aria-hidden="true" />
|
|
||||||
<span class="text-gray-600">{{ machine.typeMachine?.name || 'Type inconnu' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="machine.reference" class="flex items-center gap-2">
|
<div v-if="machine.reference" class="flex items-center gap-2">
|
||||||
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
|
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
|
||||||
<span class="text-gray-600">{{ machine.reference }}</span>
|
<span class="text-gray-600">{{ machine.reference }}</span>
|
||||||
@@ -118,7 +91,7 @@
|
|||||||
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
|
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
<button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
|
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
|
||||||
@@ -136,42 +109,28 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|
||||||
import { 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'
|
||||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
import IconLucideTag from '~icons/lucide/tag'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const selectedSite = ref('')
|
const selectedSite = ref('')
|
||||||
const selectedType = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedCategory = ref('')
|
|
||||||
|
|
||||||
const categories = computed(() => {
|
// Enrichir les machines avec les objets site complets
|
||||||
const cats = new Set()
|
|
||||||
machineTypes.value.forEach((type) => {
|
|
||||||
if (type.category) {
|
|
||||||
cats.add(type.category)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Array.from(cats)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Enrichir les machines avec les objets site et typeMachine complets
|
|
||||||
const enrichedMachines = computed(() => {
|
const enrichedMachines = computed(() => {
|
||||||
return machines.value.map((machine) => {
|
return machines.value.map((machine) => {
|
||||||
const site = sites.value.find(s => s.id === machine.siteId)
|
const site = sites.value.find(s => s.id === machine.siteId)
|
||||||
const typeMachine = machineTypes.value.find(t => t.id === machine.typeMachineId)
|
|
||||||
return {
|
return {
|
||||||
...machine,
|
...machine,
|
||||||
site: site || null,
|
site: site || null,
|
||||||
typeMachine: typeMachine || null
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -183,12 +142,12 @@ const filteredMachines = computed(() => {
|
|||||||
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedType.value) {
|
if (searchQuery.value.trim()) {
|
||||||
filtered = filtered.filter(machine => machine.typeMachineId === selectedType.value)
|
const term = searchQuery.value.trim().toLowerCase()
|
||||||
}
|
filtered = filtered.filter(machine =>
|
||||||
|
machine.name?.toLowerCase().includes(term)
|
||||||
if (selectedCategory.value) {
|
|| machine.reference?.toLowerCase().includes(term),
|
||||||
filtered = filtered.filter(machine => machine.typeMachine?.category === selectedCategory.value)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
@@ -213,10 +172,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)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +184,6 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadMachines(),
|
loadMachines(),
|
||||||
loadSites(),
|
loadSites(),
|
||||||
loadMachineTypes()
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container mx-auto px-6 py-8">
|
<main class="container mx-auto px-6 py-8">
|
||||||
<div class="max-w-5xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-2xl font-bold">
|
||||||
Nouvelle machine
|
Nouvelle machine
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
Renseignez les informations et la configuration avant de créer la machine.
|
Renseignez les informations de base pour créer la machine.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/machines" class="btn btn-ghost">
|
<NuxtLink to="/machines" class="btn btn-ghost">
|
||||||
@@ -15,30 +15,41 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
|
<div v-if="c.loading" class="flex justify-center items-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<!-- Basic fields -->
|
<!-- Basic fields -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="machine-field-name">
|
<label class="label" for="machine-field-name">
|
||||||
<span class="label-text">Nom de la machine</span>
|
<span class="label-text">Nom de la machine <span class="text-error">*</span></span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="machine-field-name"
|
id="machine-field-name"
|
||||||
v-model="c.newMachine.name"
|
v-model="c.newMachine.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Presse hydraulique #1"
|
placeholder="Ex: Presse hydraulique #1"
|
||||||
class="input input-bordered"
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="machine-field-site">
|
<label class="label" for="machine-field-site">
|
||||||
<span class="label-text">Site</span>
|
<span class="label-text">Site <span class="text-error">*</span></span>
|
||||||
</label>
|
</label>
|
||||||
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" required>
|
<select
|
||||||
|
id="machine-field-site"
|
||||||
|
v-model="c.newMachine.siteId"
|
||||||
|
class="select select-bordered select-sm md:select-md"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
required
|
||||||
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Sélectionner un site
|
Sélectionner un site
|
||||||
</option>
|
</option>
|
||||||
@@ -51,127 +62,43 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label" for="machine-field-reference">
|
||||||
<span class="label-text">Type de machine</span>
|
<span class="label-text">Référence</span>
|
||||||
</label>
|
</label>
|
||||||
<SearchSelect
|
<input
|
||||||
v-model="c.newMachine.typeMachineId"
|
id="machine-field-reference"
|
||||||
:options="c.machineTypes"
|
v-model="c.newMachine.reference"
|
||||||
:loading="c.machineTypesLoading"
|
type="text"
|
||||||
placeholder="Rechercher un type…"
|
placeholder="Ex: PRESS-001"
|
||||||
empty-text="Aucun type trouvé"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:option-label="c.machineTypeLabel"
|
:disabled="!canEdit"
|
||||||
:option-description="c.machineTypeDescription"
|
>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Référence</span>
|
<span class="label-text">Cloner depuis une machine</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<SearchSelect
|
||||||
v-model="c.newMachine.reference"
|
v-model="c.newMachine.cloneFromMachineId"
|
||||||
type="text"
|
:options="c.machines"
|
||||||
placeholder="Ex: PRESS-001"
|
:disabled="!canEdit"
|
||||||
class="input input-bordered"
|
:clearable="true"
|
||||||
>
|
placeholder="Rechercher une machine..."
|
||||||
|
empty-text="Aucune machine trouvée"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Type structure summary -->
|
|
||||||
<div v-if="c.selectedMachineType" class="p-4 bg-gray-50 rounded-lg space-y-2 text-sm">
|
|
||||||
<h4 class="font-semibold text-sm">
|
|
||||||
Structure du type sélectionné :
|
|
||||||
</h4>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span class="font-medium">Familles de composants :</span>
|
|
||||||
<span class="badge badge-sm">{{ c.selectedMachineType.componentRequirements?.length || 0 }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span class="font-medium">Groupes de pièces :</span>
|
|
||||||
<span class="badge badge-sm">{{ c.selectedMachineType.pieceRequirements?.length || 0 }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span class="font-medium">Produits requis :</span>
|
|
||||||
<span class="badge badge-sm">{{ c.selectedMachineType.productRequirements?.length || 0 }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span class="font-medium">Catégorie :</span>
|
|
||||||
<span class="badge badge-outline badge-sm">{{ c.selectedMachineType.category || 'N/A' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="(c.selectedMachineType.componentRequirements?.length || 0) === 0 && (c.selectedMachineType.pieceRequirements?.length || 0) === 0 && (c.selectedMachineType.productRequirements?.length || 0) === 0"
|
|
||||||
class="text-xs text-gray-500"
|
|
||||||
>
|
|
||||||
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Requirement selectors -->
|
|
||||||
<RequirementComponentSelector
|
|
||||||
:requirements="c.selectedMachineType?.componentRequirements || []"
|
|
||||||
:loading="c.composantsLoading"
|
|
||||||
:get-entries="c.getComponentRequirementEntries"
|
|
||||||
:get-options="c.getComponentOptions"
|
|
||||||
:resolve-type-label="c.resolveComponentRequirementTypeLabel"
|
|
||||||
:find-by-id="c.findComponentById"
|
|
||||||
:option-label="c.componentOptionLabel"
|
|
||||||
:option-description="c.componentOptionDescription"
|
|
||||||
@add-entry="c.addComponentSelectionEntry"
|
|
||||||
@remove-entry="c.removeComponentSelectionEntry"
|
|
||||||
@set-component="c.setComponentRequirementComponent"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RequirementPieceSelector
|
|
||||||
:requirements="c.selectedMachineType?.pieceRequirements || []"
|
|
||||||
:loading="c.piecesLoading"
|
|
||||||
:piece-loading-by-key="c.pieceLoadingByKey"
|
|
||||||
:get-entries="c.getPieceRequirementEntries"
|
|
||||||
:get-options="c.getPieceOptions"
|
|
||||||
:get-piece-key="c.getPieceKey"
|
|
||||||
:resolve-type-label="c.resolvePieceRequirementTypeLabel"
|
|
||||||
:find-by-id="c.findPieceById"
|
|
||||||
:option-label="c.pieceOptionLabel"
|
|
||||||
:option-description="c.pieceOptionDescription"
|
|
||||||
@add-entry="c.addPieceSelectionEntry"
|
|
||||||
@remove-entry="c.removePieceSelectionEntry"
|
|
||||||
@set-piece="c.setPieceRequirementPiece"
|
|
||||||
@search="c.fetchPieceOptions"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RequirementProductSelector
|
|
||||||
:requirements="c.selectedMachineType?.productRequirements || []"
|
|
||||||
:products-loading="c.productsLoading"
|
|
||||||
:get-entries="c.getProductRequirementEntries"
|
|
||||||
:get-product-options="c.getProductOptions"
|
|
||||||
:find-by-id="c.findProductById"
|
|
||||||
@add-entry="c.addProductSelectionEntry"
|
|
||||||
@remove-entry="c.removeProductSelectionEntry"
|
|
||||||
@set-product="c.setProductRequirementProduct"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Preview -->
|
|
||||||
<MachineCreatePreview :preview="c.machinePreview" />
|
|
||||||
|
|
||||||
<!-- Blocking issues warning -->
|
|
||||||
<div
|
|
||||||
v-if="c.blockingPreviewIssues.length"
|
|
||||||
class="text-xs text-error bg-error/10 border border-error/20 rounded-md px-3 py-2"
|
|
||||||
>
|
|
||||||
Compléter les informations bloquantes avant de créer la machine.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
|
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
|
||||||
<NuxtLink to="/machines" class="btn btn-outline">
|
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
|
||||||
Annuler
|
Annuler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-sm md:btn-md btn-primary"
|
||||||
:disabled="!c.canCreateMachine || c.submitting"
|
:disabled="!canEdit || !c.newMachine.name?.trim() || c.submitting"
|
||||||
:class="{ loading: c.submitting }"
|
:class="{ loading: c.submitting }"
|
||||||
>
|
>
|
||||||
Créer la machine
|
Créer la machine
|
||||||
@@ -188,10 +115,7 @@
|
|||||||
import { proxyRefs } from 'vue'
|
import { proxyRefs } from 'vue'
|
||||||
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
|
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
import RequirementComponentSelector from '~/components/machine/create/RequirementComponentSelector.vue'
|
|
||||||
import RequirementPieceSelector from '~/components/machine/create/RequirementPieceSelector.vue'
|
|
||||||
import RequirementProductSelector from '~/components/machine/create/RequirementProductSelector.vue'
|
|
||||||
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
|
|
||||||
|
|
||||||
const c = proxyRefs(useMachineCreatePage())
|
const c = proxyRefs(useMachineCreatePage())
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -34,6 +35,16 @@
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="piece_category"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="initialData?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -126,6 +138,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="PIECE"
|
initial-category="PIECE"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de pièce',
|
title: 'Nouvelle catégorie de pièce',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -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,293 +26,205 @@
|
|||||||
</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
|
|
||||||
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, ref } 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 { usePersistedSort } from '~/composables/usePersistedSort'
|
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 { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||||
|
|
||||||
// Pagination state
|
const table = useDataTable(
|
||||||
const currentPage = ref(1)
|
{ fetchData: fetchPieces },
|
||||||
const itemsPerPage = ref(30)
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||||
const piecesTotal = computed(() => total.value)
|
|
||||||
const piecesOnPage = computed(() => pieces.value.length)
|
|
||||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
|
||||||
|
|
||||||
// Search state with debounce
|
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchPieces()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort state
|
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
|
||||||
'pieces-catalog',
|
|
||||||
{ field: 'name', direction: 'asc' },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enrichir les pièces avec les types de pièces complets
|
const columns = [
|
||||||
|
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||||
|
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const piecesOnPage = computed(() => pieceRows.value.length)
|
||||||
|
const paginationState = table.pagination(total, piecesOnPage)
|
||||||
|
|
||||||
|
// Enrich pieces with full type data
|
||||||
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
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
|
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 '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,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)
|
||||||
@@ -389,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">
|
||||||
@@ -19,9 +20,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
@@ -33,9 +34,9 @@
|
|||||||
Ajustez les informations de la pièce et ses champs personnalisés.
|
Ajustez les informations de la pièce et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -72,13 +73,26 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</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">
|
||||||
@@ -88,7 +102,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +114,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="piece?.constructeurs || []"
|
:initial-options="piece?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -118,7 +132,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +173,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<ProductSelect
|
<ProductSelect
|
||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
helper-text="Un produit valide est requis pour cette pièce."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
@@ -224,7 +238,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -233,14 +247,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -251,24 +265,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -276,7 +290,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,7 +308,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
@@ -366,6 +380,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments"
|
:disabled="uploadingDocuments"
|
||||||
@@ -458,6 +473,16 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="piece"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="piece?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -511,6 +536,7 @@ interface PieceCatalogType extends ModelType {
|
|||||||
customFields?: Array<Record<string, any>>
|
customFields?: Array<Record<string, any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
@@ -556,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,
|
||||||
@@ -731,6 +758,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
|
canEdit.value &&
|
||||||
piece.value &&
|
piece.value &&
|
||||||
editionForm.name &&
|
editionForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
@@ -810,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,
|
||||||
@@ -882,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,7 +945,6 @@ const submitEdition = async () => {
|
|||||||
updatedPiece.id,
|
updatedPiece.id,
|
||||||
[
|
[
|
||||||
updatedPiece?.typePiece?.pieceCustomFields,
|
updatedPiece?.typePiece?.pieceCustomFields,
|
||||||
updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
|
|
||||||
],
|
],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</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">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,13 +45,26 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</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">
|
||||||
@@ -61,7 +74,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +86,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +103,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +144,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<ProductSelect
|
<ProductSelect
|
||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit est requis pour cette pièce."
|
helper-text="Un produit est requis pour cette pièce."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
@@ -196,7 +209,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -205,14 +218,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -223,24 +236,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -248,7 +261,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +279,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -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'
|
||||||
@@ -329,12 +343,14 @@ const { createPiece } = usePieces()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
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,
|
||||||
@@ -478,6 +494,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name &&
|
creationForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
@@ -488,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 = ''
|
||||||
@@ -511,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
|
||||||
@@ -550,7 +573,6 @@ const submitCreation = async () => {
|
|||||||
createdPiece.id,
|
createdPiece.id,
|
||||||
[
|
[
|
||||||
createdPiece?.typePiece?.pieceCustomFields,
|
createdPiece?.typePiece?.pieceCustomFields,
|
||||||
createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
|
|
||||||
],
|
],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
@@ -577,7 +599,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,119 +32,131 @@
|
|||||||
</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
|
|
||||||
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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } 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 { usePersistedSort } from '~/composables/usePersistedSort'
|
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'
|
||||||
|
|
||||||
useHead(() => ({
|
const { canEdit } = usePermissions()
|
||||||
title: 'Catalogue des produits',
|
|
||||||
}))
|
useHead(() => ({ title: 'Catalogue des produits' }))
|
||||||
|
|
||||||
const {
|
const {
|
||||||
products,
|
products,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
loaded,
|
|
||||||
error,
|
error,
|
||||||
loadProducts,
|
loadProducts,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
@@ -195,65 +164,54 @@ const {
|
|||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const table = useDataTable(
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
{ fetchData: fetchProducts },
|
||||||
'product-catalog',
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||||
{ field: 'name', direction: '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',
|
||||||
@@ -262,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
|
||||||
@@ -279,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)
|
||||||
@@ -343,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({ force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
@@ -397,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é`)
|
||||||
@@ -408,9 +308,6 @@ const confirmDelete = async (product: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([fetchProducts(), loadProductTypes()])
|
||||||
loadProducts(),
|
|
||||||
loadProductTypes()
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -34,6 +35,16 @@
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="product_category"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="initialData?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -126,6 +138,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="PRODUCT"
|
initial-category="PRODUCT"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de produit',
|
title: 'Nouvelle catégorie de produit',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -19,9 +20,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
||||||
@@ -33,9 +34,9 @@
|
|||||||
Mettez à jour les informations du produit et ses champs personnalisés.
|
Mettez à jour les informations du produit et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="product?.constructeurs || []"
|
:initial-options="product?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +149,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -157,14 +158,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -175,24 +176,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -200,7 +201,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +219,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
@@ -286,6 +287,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments || saving"
|
:disabled="uploadingDocuments || saving"
|
||||||
@@ -381,6 +383,16 @@
|
|||||||
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="product"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="product?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -395,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'
|
||||||
@@ -424,6 +437,7 @@ import {
|
|||||||
historyDiffEntries as _historyDiffEntries,
|
historyDiffEntries as _historyDiffEntries,
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
} from '~/shared/utils/historyDisplayUtils'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -489,7 +503,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
||||||
@@ -687,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</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">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -144,14 +144,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -162,24 +162,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -267,6 +267,7 @@ const { createProduct } = useProducts()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -346,6 +347,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name.trim().length >= 2 &&
|
creationForm.name.trim().length >= 2 &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -1,55 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||||
<div class="w-full max-w-2xl">
|
<div class="w-full max-w-md">
|
||||||
<div class="card bg-base-100 shadow-2xl">
|
<div class="card bg-base-100 shadow-2xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h1 class="text-2xl font-bold mb-2">
|
<h1 class="text-2xl font-bold mb-6 text-center">
|
||||||
Choisir un profil
|
Connexion
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-base-content/70 mb-6">
|
|
||||||
Sélectionnez votre profil pour accéder à l'application. La création et la gestion se font via le menu utilisateur.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section class="space-y-4">
|
<div v-if="loadingProfiles" class="flex justify-center py-8">
|
||||||
<header class="flex items-center justify-between">
|
<span class="loading loading-spinner loading-lg" />
|
||||||
<h2 class="font-semibold">
|
</div>
|
||||||
Profils disponibles
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
:disabled="loadingProfiles"
|
|
||||||
@click="refreshProfiles"
|
|
||||||
>
|
|
||||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Rafraîchir</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="profiles.length" class="space-y-2 max-h-64 overflow-y-auto">
|
<form v-else @submit.prevent="handleLogin">
|
||||||
<button
|
<div class="form-control mb-4">
|
||||||
v-for="profile in profiles"
|
<label class="label">
|
||||||
:key="profile.id"
|
<span class="label-text">Profil</span>
|
||||||
type="button"
|
</label>
|
||||||
class="btn btn-outline btn-sm w-full justify-between"
|
<select
|
||||||
@click="selectProfile(profile.id)"
|
v-model="selectedProfileId"
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<span>{{ profile.firstName }} {{ profile.lastName }}</span>
|
<option value="" disabled>
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
Choisir un profil...
|
||||||
</button>
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="profile in profiles"
|
||||||
|
:key="profile.id"
|
||||||
|
:value="profile.id"
|
||||||
|
>
|
||||||
|
{{ profile.firstName }} {{ profile.lastName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-sm text-base-content/60">
|
|
||||||
Aucun profil enregistré.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer v-if="activeProfile" class="mt-6 flex justify-between items-center">
|
<div class="form-control mb-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Mot de passe</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="passwordInput"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
:class="{ 'input-error': loginError }"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loginError" class="text-error text-sm mb-4">
|
||||||
|
{{ loginError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full mt-4"
|
||||||
|
:disabled="!selectedProfileId || submitting"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
Se connecter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer v-if="activeProfile" class="mt-6 pt-4 border-t border-base-300 flex justify-between items-center">
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
Profil actuel :
|
Connecte :
|
||||||
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
|
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
|
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
|
||||||
Déconnexion
|
Deconnexion
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,32 +78,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useProfiles, useProfileSession } from '#imports'
|
import { useProfiles, useProfileSession } from '#imports'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
|
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
|
||||||
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
||||||
|
|
||||||
const refreshProfiles = async () => {
|
const selectedProfileId = ref('')
|
||||||
await fetchProfiles()
|
const password = ref('')
|
||||||
}
|
const loginError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const passwordInput = ref(null)
|
||||||
|
|
||||||
const selectProfile = async (profileId) => {
|
const handleLogin = async () => {
|
||||||
|
if (!selectedProfileId.value) { return }
|
||||||
|
submitting.value = true
|
||||||
|
loginError.value = ''
|
||||||
try {
|
try {
|
||||||
await activateProfile(profileId)
|
await activateProfile(selectedProfileId.value, password.value || undefined)
|
||||||
await fetchProfiles()
|
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la sélection du profil', error)
|
const err = error
|
||||||
|
if (err?.status === 401 || err?.statusCode === 401) {
|
||||||
|
loginError.value = 'Mot de passe incorrect.'
|
||||||
|
} else {
|
||||||
|
loginError.value = 'Erreur lors de la connexion.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
await router.push('/profiles')
|
selectedProfileId.value = ''
|
||||||
|
password.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">
|
|
||||||
Gestion des profils
|
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Sélectionnez, créez ou supprimez des profils.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NuxtLink to="/" class="btn btn-ghost btn-sm">
|
|
||||||
Retour
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<section class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<header class="flex items-center justify-between">
|
|
||||||
<h2 class="card-title text-lg">
|
|
||||||
Profils existants
|
|
||||||
</h2>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" :disabled="loadingProfiles" @click="refresh">
|
|
||||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Rafraîchir</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="profiles.length" class="space-y-2 max-h-80 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-for="profile in profiles"
|
|
||||||
:key="profile.id"
|
|
||||||
class="flex items-center justify-between rounded-lg border border-base-200 bg-base-100 px-3 py-2"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium">
|
|
||||||
{{ profile.firstName }} {{ profile.lastName }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-base-content/60">
|
|
||||||
ID : {{ profile.id }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="profile.id === activeProfile?.id ? 'btn-primary' : 'btn-outline'"
|
|
||||||
@click="select(profile.id)"
|
|
||||||
>
|
|
||||||
{{ profile.id === activeProfile?.id ? 'Actif' : 'Activer' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-error btn-sm"
|
|
||||||
@click="remove(profile.id)"
|
|
||||||
>
|
|
||||||
<span v-if="deleting === profile.id" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Supprimer</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-sm text-base-content/60">
|
|
||||||
Aucun profil enregistré.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<h2 class="card-title text-lg">
|
|
||||||
Créer un profil
|
|
||||||
</h2>
|
|
||||||
<form class="space-y-3" @submit.prevent="create">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Prénom</span></label>
|
|
||||||
<input
|
|
||||||
v-model="createForm.firstName"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
placeholder="Prénom"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Nom</span></label>
|
|
||||||
<input
|
|
||||||
v-model="createForm.lastName"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
placeholder="Nom"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary w-full" :disabled="creating">
|
|
||||||
<span v-if="creating" class="loading loading-spinner loading-sm" />
|
|
||||||
<span v-else>Créer et activer</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeProfile" class="mt-8 flex items-center justify-between bg-base-100 shadow-lg rounded-lg p-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Profil actif :
|
|
||||||
</p>
|
|
||||||
<p class="font-semibold text-base-content">
|
|
||||||
{{ activeProfile.firstName }} {{ activeProfile.lastName }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-outline" @click="handleLogout">
|
|
||||||
Déconnexion
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useProfiles, useProfileSession } from '#imports'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const { profiles, loadingProfiles, fetchProfiles, createProfile, deleteProfile } = useProfiles()
|
|
||||||
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
|
||||||
|
|
||||||
const createForm = reactive({
|
|
||||||
firstName: '',
|
|
||||||
lastName: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const creating = ref(false)
|
|
||||||
const deleting = ref(null)
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
await fetchProfiles()
|
|
||||||
await fetchCurrentProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
const select = async (profileId) => {
|
|
||||||
try {
|
|
||||||
await activateProfile(profileId)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la sélection du profil', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const create = async () => {
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const profile = await createProfile({
|
|
||||||
firstName: createForm.firstName,
|
|
||||||
lastName: createForm.lastName
|
|
||||||
})
|
|
||||||
createForm.firstName = ''
|
|
||||||
createForm.lastName = ''
|
|
||||||
await activateProfile(profile.id)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la création du profil', error)
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
|
||||||
|
|
||||||
const remove = async (profileId) => {
|
|
||||||
if (!await confirm({ message: 'Supprimer ce profil ?' })) { return }
|
|
||||||
deleting.value = profileId
|
|
||||||
try {
|
|
||||||
await deleteProfile(profileId)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la suppression du profil', error)
|
|
||||||
} finally {
|
|
||||||
deleting.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout()
|
|
||||||
await refresh()
|
|
||||||
await router.push('/profiles')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await refresh()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="siteDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
<h2 class="text-2xl font-bold">
|
<h2 class="text-2xl font-bold">
|
||||||
Sites
|
Sites
|
||||||
</h2>
|
</h2>
|
||||||
<button class="btn btn-primary" @click="openCreateSiteModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
|
||||||
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
<p class="text-gray-500 mb-4">
|
<p class="text-gray-500 mb-4">
|
||||||
Commencez par ajouter votre premier site.
|
Commencez par ajouter votre premier site.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn btn-primary" @click="openCreateSiteModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
<SiteCreateModal
|
<SiteCreateModal
|
||||||
:visible="showAddSiteModal"
|
:visible="showAddSiteModal"
|
||||||
:site="newSite"
|
:site="newSite"
|
||||||
|
:disabled="!canEdit"
|
||||||
@close="closeCreateModal"
|
@close="closeCreateModal"
|
||||||
@submit="handleCreateSite"
|
@submit="handleCreateSite"
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
:can-preview-document="canPreviewDocument"
|
:can-preview-document="canPreviewDocument"
|
||||||
:document-icon="documentIcon"
|
:document-icon="documentIcon"
|
||||||
:format-size="formatSize"
|
:format-size="formatSize"
|
||||||
|
:disabled="!canEdit"
|
||||||
@close="closeEditModal"
|
@close="closeEditModal"
|
||||||
@submit="handleUpdateSite"
|
@submit="handleUpdateSite"
|
||||||
@remove-document="handleRemoveSiteDocument"
|
@remove-document="handleRemoveSiteDocument"
|
||||||
@@ -83,6 +86,8 @@ import SiteCreateModal from '~/components/sites/SiteCreateModal.vue'
|
|||||||
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
|
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
|
||||||
import { useSiteManagement } from '~/composables/useSiteManagement'
|
import { useSiteManagement } from '~/composables/useSiteManagement'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sites,
|
sites,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="container mx-auto px-6 py-8">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="loading" class="my-8 text-center">
|
|
||||||
<div class="loading loading-spinner loading-lg" />
|
|
||||||
<p class="mt-4 text-gray-600">
|
|
||||||
Chargement du type...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type Details -->
|
|
||||||
<div v-else-if="type" 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">
|
|
||||||
{{ typePageTitle }}
|
|
||||||
</h2>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<NuxtLink :to="`/type/edit/${type.id}`" class="btn btn-secondary">
|
|
||||||
<IconLucideSquarePen class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Éditer complètement
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
|
|
||||||
Retour
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Type Info -->
|
|
||||||
<TypeInfoDisplay :type="type" />
|
|
||||||
|
|
||||||
<!-- Familles de composants -->
|
|
||||||
<div v-if="componentRequirementCount > 0" class="mb-8 space-y-3">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
Familles de composants
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="requirement in type.componentRequirements"
|
|
||||||
:key="requirement.id"
|
|
||||||
class="border border-base-200 rounded-lg p-4 bg-base-100"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold">
|
|
||||||
{{ requirement.label || requirement.typeComposant?.name || 'Famille' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ requirement.typeComposant?.name || 'Non défini' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-outline badge-sm">
|
|
||||||
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }} •
|
|
||||||
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
|
||||||
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Groupes de pièces -->
|
|
||||||
<div v-if="pieceRequirementCount > 0" class="mb-8 space-y-3">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
Groupes de pièces
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="requirement in type.pieceRequirements"
|
|
||||||
:key="requirement.id"
|
|
||||||
class="border border-base-200 rounded-lg p-4 bg-base-100"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold">
|
|
||||||
{{ requirement.label || requirement.typePiece?.name || 'Groupe' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ requirement.typePiece?.name || 'Non défini' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-outline badge-sm">
|
|
||||||
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }} •
|
|
||||||
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
|
||||||
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Produits requis -->
|
|
||||||
<div v-if="productRequirementCount > 0" class="mb-8 space-y-3">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
Produits requis
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="requirement in type.productRequirements"
|
|
||||||
:key="requirement.id || requirement.typeProductId"
|
|
||||||
class="border border-base-200 rounded-lg p-4 bg-base-100"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold">
|
|
||||||
{{ requirement.label || requirement.typeProduct?.name || 'Produit' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Type : {{ requirement.typeProduct?.name || 'Non défini' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-outline badge-sm">
|
|
||||||
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }} •
|
|
||||||
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
|
||||||
{{ requirement.allowNewModels ? 'Création de produits autorisée' : 'Produits existants uniquement' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else class="my-8 text-center">
|
|
||||||
<div class="alert alert-error">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-bold">
|
|
||||||
Type non trouvé
|
|
||||||
</h3>
|
|
||||||
<p>Le type de machine demandé n'existe pas.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<NuxtLink to="/machine-skeleton" class="btn btn-primary mt-4">
|
|
||||||
Retour aux types
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { getMachineTypeById } = useMachineTypesApi()
|
|
||||||
const { showError } = useToast()
|
|
||||||
|
|
||||||
const type = ref(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
const isEditView = computed(() => {
|
|
||||||
const editQuery = Array.isArray(route.query.edit) ? route.query.edit[0] : route.query.edit
|
|
||||||
const modeQuery = Array.isArray(route.query.mode) ? route.query.mode[0] : route.query.mode
|
|
||||||
return editQuery === 'true' || editQuery === '1' || modeQuery === 'edit'
|
|
||||||
})
|
|
||||||
|
|
||||||
const typePageTitle = computed(() => {
|
|
||||||
const currentName = type.value?.name || 'Type de squelette'
|
|
||||||
return isEditView.value ? `Modification : ${currentName}` : currentName
|
|
||||||
})
|
|
||||||
|
|
||||||
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
|
|
||||||
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
|
|
||||||
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
|
|
||||||
|
|
||||||
const toDisplayCount = (value, fallback) => {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const typeId = route.params.id
|
|
||||||
if (!typeId) {
|
|
||||||
showError('Aucun identifiant de type fourni')
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getMachineTypeById(typeId)
|
|
||||||
if (result.success) {
|
|
||||||
type.value = result.data
|
|
||||||
} else {
|
|
||||||
showError('Type non trouvé')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement:', error)
|
|
||||||
showError('Erreur lors du chargement')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user