12 Commits

Author SHA1 Message Date
Matthieu
32d03b480d refactor(machines) : remove TypeMachine skeleton system, simplify machine creation
- Remove TypeEdit*, TypeInfoDisplay, MachineSkeletonSummary, MachineCreatePreview components
- Remove machine-skeleton pages and type pages
- Remove useMachineTypesApi, useMachineSkeletonEditor, useMachineCreateSelections composables
- Add AddEntityToMachineModal for direct entity linking
- Update machine detail/create pages for direct custom fields
- Fix SearchSelect, category display, and ipartial search filters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:25:23 +01:00
Matthieu
6f1bac381d refacto(tables) : composant DataTable global + migration de toutes les tables
- Nouveau composant DataTable réutilisable avec tri par en-têtes, pagination, filtres colonnes
- Nouveau composable useDataTable (sort/page/search/perPage/columnFilters + persistance URL)
- Migration des 9 tables : constructeurs, comments, admin, pieces-catalog, component-catalog, product-catalog, documents, activity-log, ManagementView (catégories)
- Filtres "Type de" server-side (ipartial) pour pièces, composants, produits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:05:00 +01:00
Matthieu
89dc2e93b8 docs(readme) : comprehensive project documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:45:32 +01:00
Matthieu
8f5f25b3e7 docs(readme) : replace default Nuxt template with project documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:37:59 +01:00
Matthieu
c06c852493 chore : remove obsolete migration and refactoring docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:08:32 +01:00
Matthieu
41f5319b67 chore(changelog) : add v1.7.0 and v1.8.0 entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:00:18 +01:00
Matthieu
c7fd8328d6 fix(errors) : humanize backend error messages for end users
Add centralized error translation layer (humanizeError) that converts
raw Symfony/Doctrine/API Platform messages into user-friendly French.
Fix useApi to extract errors from all backend response formats
(violations, error, message, hydra:description, detail).
Add toast deduplication to prevent double display. Replace error toast
icon (X → CircleX) to distinguish from the dismiss button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:48:51 +01:00
Matthieu
55e2a4fafe fix(navbar) : reorder nav groups and add lucide icons
- Reorder: Composants, Pieces, Produits (was Pieces, Produits, Composants)
- Add icons to all nav links and dropdown groups
- Dashboard, Factory, ClipboardList, Cpu, Puzzle, Package, Link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:26 +01:00
Matthieu
e88ed5b8f2 feat(documents): migrate storage to filesystem, add server-side pagination
- Replace Base64 data URIs with file-based storage served via dedicated endpoints
- Add DocumentPreviewModal navigation, DocumentThumbnail fileUrl support
- Refactor documents page with server-side pagination, search, sort and filters
- Update all components to use fileUrl/downloadUrl instead of raw path
- Add pagination composable support (total, page, itemsPerPage, attachmentFilter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:17:59 +01:00
Matthieu
546cc37a09 feat(catalog): add description column with hover popover + skeleton edit guard
- Add description column to pieces and component catalog tables
- Show full text in a popover on hover for truncated descriptions
- Block skeleton editing when machines are linked (warning alert)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:13:06 +01:00
Matthieu
efd0fbe407 feat(catalog) : add description textarea to piece and component forms
Add description field (textarea) between name and reference/fournisseur
on create and edit pages for both pieces and components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:35:52 +01:00
Matthieu
607f84fc3d fix(sites): remove toRefs shadowing causing [object Object] in site name field 2026-03-02 16:33:30 +01:00
85 changed files with 3845 additions and 8540 deletions

178
README.md
View File

@@ -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
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
## Développement
```bash
# npm
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
# npm
npm run build
| Commande | Description |
|----------|-------------|
| `npm run dev` | Serveur de développement avec HMR |
| `npm run build` | Build de production |
| `npm run lint:fix` | Correction automatique ESLint |
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
| `npm run test` | Tests unitaires Vitest |
| `npm run test:watch` | Tests unitaires en mode watch |
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
# pnpm
pnpm build
## Fonctionnalités
# yarn
yarn build
### Gestion du parc
# bun
bun run build
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
- **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
- **Sites** : gestion multi-sites avec coordonnées de contact
### Catalogues
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
### Documents et traçabilité
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
### Administration
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
- **Profils** : gestion des utilisateurs et attribution des rôles
- **Notifications** : badge compteur de commentaires ouverts avec polling
## Architecture
```
app/
├── pages/ # 36 pages (file-based routing)
├── components/ # 57 composants Vue (auto-imported par Nuxt)
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
│ ├── form/ # Champs de formulaire (email, téléphone)
│ ├── layout/ # Navbar principale
│ ├── machine/ # Vue détail et création de machines
│ │ └── create/ # Wizard de création machine
│ ├── model-types/ # Gestion des types/catégories
│ └── sites/ # Modales site (création, édition)
├── composables/ # 45 composables (logique métier)
├── shared/ # Types, utilitaires, validation
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
│ ├── validation/ # Validation email, téléphone
│ └── model/ # Définitions de structures
├── services/ # Service layer (wrappers API spécialisés)
├── middleware/ # Middleware d'auth global (session cookie)
└── utils/ # Formatage dates, montants, événements
```
Locally preview production build:
## Conventions de code
```bash
# npm
npm run preview
### Composables
# pnpm
pnpm preview
Pattern avec injection de dépendances explicite :
# yarn
yarn preview
```typescript
interface Deps {
machineId: Ref<string>
onSave: () => void
}
# bun
bun run preview
export function useMachineDetail(deps: Deps) {
// ...
}
```
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

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
@@ -25,12 +26,6 @@
{{ component.name }}
</h3>
<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>
<template v-if="componentConstructeursDisplay.length">
<span
@@ -48,12 +43,6 @@
>
Produit&nbsp;: {{ displayProductName }}
</span>
<span
v-if="component.typeMachineComponentRequirement"
class="badge badge-outline badge-sm"
>
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
</span>
</div>
</div>
</div>
@@ -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"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -332,8 +321,8 @@
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -400,8 +389,7 @@
v-for="piece in component.pieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode && !piece.skeletonOnly"
:is-edit-mode="isEditMode"
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@@ -419,7 +407,7 @@
v-for="subComponent in childComponents"
:key="subComponent.id"
:component="subComponent"
:is-edit-mode="isEditMode && !subComponent.skeletonOnly"
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
@update="$emit('update', $event)"

View File

@@ -10,9 +10,12 @@
<div class="min-w-0">
<h3 class="font-bold text-xl truncate">
Prévisualisation
<span v-if="navTotal > 1" class="text-base font-normal text-gray-500">
{{ activeIndex + 1 }} / {{ navTotal }}
</span>
</h3>
<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"> &bull; {{ documentDescription }}</span>
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
@@ -20,15 +23,35 @@
</button>
</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">
<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 v-else-if="previewType === 'pdf'">
<iframe
:src="document?.path"
:src="documentSrc"
class="w-full h-full bg-white"
frameborder="0"
title="Aperçu PDF"
@@ -36,11 +59,11 @@
</template>
<template v-else-if="previewType === 'audio'">
<audio :src="document?.path" controls class="w-full" />
<audio :src="documentSrc" controls class="w-full" />
</template>
<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 v-else-if="previewType === 'text'">
@@ -80,31 +103,110 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getPreviewType, describeDocument } from '~/utils/documentPreview'
import { ref, computed, watch, onUnmounted } from 'vue'
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
const props = defineProps({
document: {
type: Object,
default: null
default: null,
},
visible: {
type: Boolean,
default: false
}
default: false,
},
documents: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['close'])
const previewType = computed(() => getPreviewType(props.document))
const documentDescription = computed(() => describeDocument(props.document))
// --- Carousel navigation ---
const previewableDocuments = computed(() => {
if (!props.documents?.length) return []
return props.documents.filter((doc) => canPreviewDocument(doc))
})
const navTotal = computed(() => previewableDocuments.value.length)
const activeIndex = ref(0)
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
watch(
() => props.document,
(doc) => {
if (!doc || !previewableDocuments.value.length) {
activeIndex.value = 0
return
}
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
activeIndex.value = idx >= 0 ? idx : 0
},
{ immediate: true },
)
const activeDoc = computed(() => {
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
return previewableDocuments.value[activeIndex.value]
}
return props.document
})
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
const goToPrev = () => {
if (hasPrev.value) activeIndex.value--
}
const goToNext = () => {
if (hasNext.value) activeIndex.value++
}
// Keyboard navigation
const handleKeydown = (e) => {
if (!props.visible) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
goToPrev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
goToNext()
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
watch(
() => props.visible,
(val) => {
if (val) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
},
)
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// --- Preview logic (uses activeDoc) ---
const previewType = computed(() => getPreviewType(activeDoc.value))
const documentDescription = computed(() => describeDocument(activeDoc.value))
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
const textContent = ref('')
const textLoading = ref(false)
const textError = ref('')
watch(
() => props.document,
activeDoc,
async (doc) => {
textContent.value = ''
textError.value = ''
@@ -115,22 +217,17 @@ watch(
try {
textLoading.value = true
const path = doc.path || ''
if (path.startsWith('data:')) {
const base64Part = path.split(',')[1] || ''
if (!base64Part) {
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 url = doc.fileUrl || doc.path || ''
if (!url) {
textError.value = 'Aucune URL de document disponible.'
return
}
const response = await fetch(url, { credentials: 'include' })
if (!response.ok) {
throw new Error('Téléchargement du document impossible')
}
textContent.value = await response.text()
} catch (error) {
console.error('Erreur lors du chargement du texte:', error)
textError.value = error.message || 'Impossible de lire ce document.'
@@ -138,7 +235,7 @@ watch(
textLoading.value = false
}
},
{ immediate: true }
{ immediate: true },
)
const close = () => {
@@ -146,11 +243,8 @@ const close = () => {
}
const download = () => {
if (!props.document?.path) { return }
const link = document.createElement('a')
link.href = props.document.path
link.download = props.document.filename || props.document.name || 'document'
link.target = '_blank'
link.click()
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
if (!url) { return }
window.open(url, '_blank')
}
</script>

View File

@@ -40,6 +40,8 @@ type GenericDocument = {
filename?: string | null;
mimeType?: string | null;
path?: string | null;
fileUrl?: string | null;
downloadUrl?: string | null;
size?: number | null;
};
@@ -52,7 +54,7 @@ const normalizedDocument = computed(() => props.document ?? null);
const canRenderImage = computed(() => {
const doc = normalizedDocument.value;
return !!(doc && isImageDocument(doc) && doc.path);
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
});
const canRenderPdf = computed(() => {
@@ -73,13 +75,14 @@ const appendPdfViewerParams = (src: string) => {
const previewSrc = computed(() => {
const doc = normalizedDocument.value;
if (!doc || !doc.path) {
const url = doc?.fileUrl || doc?.path;
if (!doc || !url) {
return '';
}
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'));

View File

@@ -1,63 +1,59 @@
<template>
<div class="border border-gray-200 rounded-lg p-4">
<div class="space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" />
<input
v-if="isEditMode"
:id="`piece-name-${piece.id}`"
v-model="pieceData.name"
type="text"
class="font-semibold text-lg input input-sm input-bordered"
@blur="updatePiece"
/>
<div
v-else
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
<div class="flex items-start gap-3 w-full">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
:class="{ 'rotate-90': !isCollapsed }"
:aria-expanded="!isCollapsed"
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
@click="toggleCollapse"
>
{{ 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&nbsp;: {{ displayProductName }}
</span>
</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&nbsp;: {{ displayProductName }}
</span>
</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>
<span class="font-medium">Référence:</span>
<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"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -235,6 +231,7 @@
</div>
</div>
</div>
</div>
<!-- Champs personnalisés de la pièce -->
<div
@@ -413,8 +410,8 @@
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -479,16 +476,17 @@
Aucun document lié à cette pièce.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, watch, computed } from 'vue'
import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts'
@@ -522,6 +520,8 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
@@ -575,6 +575,23 @@ const {
updateCustomField,
} = 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 ---
const { constructeurs } = useConstructeurs()

View File

@@ -24,7 +24,7 @@
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideX
<IconLucideCircleX
v-else-if="toast.type === 'error'"
class="w-4 h-4"
aria-hidden="true"
@@ -64,6 +64,7 @@
import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import IconLucideCircleX from '~icons/lucide/circle-x'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideInfo from '~icons/lucide/info'

View File

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

View File

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

View File

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

View File

@@ -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&#10;Option 2&#10;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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,308 @@
<template>
<div class="space-y-4">
<!-- Toolbar + counter row -->
<div
v-if="$slots.toolbar || showCounter || showPerPage"
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<slot name="toolbar" />
</div>
<div class="flex items-center gap-3">
<div v-if="showPerPage && pagination?.perPageOptions?.length" class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="dt-per-page"
>
Par page
</label>
<select
id="dt-per-page"
:value="pagination.perPage"
class="select select-bordered select-sm"
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="opt in pagination.perPageOptions" :key="opt" :value="opt">
{{ opt }}
</option>
</select>
</div>
<p v-if="showCounter && pagination" class="text-xs text-base-content/50 whitespace-nowrap">
{{ pagination.pageItems }} / {{ pagination.totalItems }}
résultat{{ pagination.totalItems > 1 ? 's' : '' }}
</p>
</div>
</div>
<!-- Loading state (full spinner only when no filterable columns to keep visible) -->
<div v-if="loading && !hasFilterableColumns" class="flex justify-center py-8">
<slot name="loading">
<span class="loading loading-spinner" aria-hidden="true" />
</slot>
</div>
<!-- Empty state (no data at all, no filterable columns to keep visible) -->
<template v-else-if="isEmpty && !hasFilterableColumns">
<slot name="empty">
<p class="text-sm text-base-content/70 py-8 text-center">
{{ emptyMessage }}
</p>
</slot>
</template>
<!-- No results without filterable columns -->
<template v-else-if="rows.length === 0 && !hasFilterableColumns">
<slot name="no-results">
<p class="text-sm text-base-content/70 py-8 text-center">
{{ noResultsMessage }}
</p>
</slot>
</template>
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
<template v-else>
<div class="overflow-x-auto 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>

View File

@@ -18,6 +18,15 @@
@keydown.enter.prevent="selectHighlighted"
@input="handleInput"
>
<button
v-if="clearable && modelValue"
type="button"
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
aria-label="Effacer la sélection"
@click.stop="clearSelection"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
<button
type="button"
:class="toggleButtonClasses"
@@ -77,6 +86,7 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
@@ -111,6 +121,10 @@ const props = defineProps({
type: [String, Function],
default: null
},
clearable: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
@@ -155,7 +169,8 @@ const displayedOptions = 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 === 'sm') base.push('input-sm')
if (props.size === 'lg') base.push('input-lg')
@@ -269,9 +284,17 @@ function handleInput () {
emit('search', searchTerm.value)
}
function clearSelection () {
emit('update:modelValue', '')
searchTerm.value = ''
openDropdown.value = false
}
function closeDropdown () {
openDropdown.value = false
if (selectedOption.value) {
if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}

View File

@@ -24,9 +24,10 @@
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
: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)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
@@ -46,7 +47,10 @@
@keydown.enter.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
class="h-4 w-4 transition-transform"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
@@ -100,9 +104,10 @@
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
: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)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
@@ -119,13 +124,14 @@
>
<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)"
:aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
>
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
@@ -233,7 +239,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
@@ -243,6 +249,13 @@ import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
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'
defineEmits<{
@@ -253,25 +266,37 @@ defineEmits<{
interface NavLink {
to: string
label: string
icon?: Component
}
interface NavGroup {
id: string
label: string
icon?: Component
activePaths: string[]
children: NavLink[]
}
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble' },
{ to: '/machines', label: 'Parc Machines' },
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
]
const navGroups: NavGroup[] = [
{
id: 'component',
label: 'Composants',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{
id: 'pieces',
label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'],
children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
@@ -281,24 +306,17 @@ const navGroups: NavGroup[] = [
{
id: 'products',
label: 'Produits',
icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'],
children: [
{ to: '/product-catalog', label: 'Catalogue des produits' },
{ to: '/product-category', label: 'Catégorie de produit' },
],
},
{
id: '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',
label: 'Ressources liées',
icon: IconLucideLink,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
children: [
{ to: '/sites', label: 'Sites' },

View 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"
>
&times;
</button>
<h3 class="font-bold text-lg mb-6">
{{ title }}
</h3>
<!-- Step 1: Choose category -->
<div class="form-control mb-5" style="position: relative; z-index: 20">
<label class="label pb-1">
<span class="label-text font-medium">Catégorie</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="types"
:loading="loadingTypes"
:max-visible="8"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="(t: any) => t.name"
:option-description="(t: any) => t.code"
/>
</div>
<!-- Step 2: Choose entity (visible only after category selected) -->
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
<label class="label pb-1">
<span class="label-text font-medium">{{ entityLabel }}</span>
</label>
<SearchSelect
v-model="selectedEntityId"
:options="entities"
:loading="loadingEntities"
:max-visible="8"
:placeholder="`Rechercher ${entityLabelLower}...`"
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
:option-label="entityOptionLabel"
:option-description="entityOptionDescription"
/>
</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>

View File

@@ -3,32 +3,57 @@
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
</div>
<ComponentHierarchy
:components="components"
:is-edit-mode="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-component', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
Aucun composant associé à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="component in components" :key="component.id" class="relative">
<button
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>
</template>
@@ -49,5 +74,7 @@ defineEmits<{
'update-component': [component: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-component': []
'remove-component': [linkId: string]
}>()
</script>

View File

@@ -4,25 +4,6 @@
<h1 class="text-3xl font-bold">
{{ title }}
</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 class="flex items-center gap-2 print:hidden" data-print-hide>
<button
@@ -43,7 +24,7 @@
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<button
v-if="isDetailsView && !isEditMode"
v-if="!isEditMode"
@click="$emit('open-print')"
type="button"
class="btn btn-outline btn-secondary"
@@ -60,18 +41,12 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer'
const { canEdit } = usePermissions()
defineProps<{
title: string
isDetailsView: boolean
isSkeletonView: boolean
isEditMode: boolean
hasSkeletonRequirements: boolean
}>()
defineEmits<{
'change-view': [view: 'details' | 'skeleton']
'toggle-edit': []
'open-print': []
}>()

View File

@@ -32,8 +32,8 @@
:class="documentThumbnailClass(doc)"
>
<img
v-if="isImageDocument(doc) && doc.path"
:src="doc.path"
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
:src="doc.fileUrl || doc.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${doc.name}`"
>

View File

@@ -3,32 +3,75 @@
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<PieceItem
v-for="piece in pieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
Aucune pièce associée à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="piece in pieces" :key="piece.id" class="relative">
<button
v-if="isEditMode"
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>
</template>
<script setup lang="ts">
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{
pieces: any[]
isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>()
defineEmits<{
'update-piece': [piece: any]
'toggle-collapse': []
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-piece': []
'remove-piece': [linkId: string]
}>()
</script>

View File

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

View File

@@ -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 }} &bull; 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>

View File

@@ -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">&bull;</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">&bull;</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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,35 +9,95 @@
</p>
</header>
<ModelTypesToolbar
:category="selectedCategory"
:search="searchInput"
:sort="sort"
:dir="dir"
:loading="loading"
:show-category-tabs="allowCategorySwitch"
:can-edit="canEdit"
@update:category="onCategoryChange"
@update:search="onSearchInput"
@update:sort="onSortChange"
@update:dir="onDirChange"
@create="openCreatePage"
/>
<nav
v-if="allowCategorySwitch"
class="tabs tabs-boxed inline-flex"
role="tablist"
aria-label="Catégories"
>
<button
v-for="option in categories"
:key="option.value"
type="button"
class="tab"
:class="{ 'tab-active': option.value === selectedCategory }"
role="tab"
:aria-selected="option.value === selectedCategory"
@click="onCategoryChange(option.value)"
>
{{ option.label }}
</button>
</nav>
<ModelTypesTable
:items="items"
<DataTable
:columns="columns"
:rows="items"
:loading="loading"
:total="total"
:limit="limit"
:offset="offset"
:category="selectedCategory"
:can-edit="canEdit"
@related="openRelatedModal"
@edit="openEditPage"
@delete="confirmDelete"
@convert="openConversionModal"
@update:offset="onOffsetChange"
/>
:sort="currentSort"
:pagination="paginationState"
:show-per-page="true"
row-key="id"
empty-message="Aucune catégorie trouvée."
no-results-message="Aucune catégorie ne correspond à votre recherche."
@sort="handleSort"
@update:current-page="handlePageChange"
@update:per-page="handlePerPageChange"
>
<template #toolbar>
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
<input
v-model="searchInput"
type="search"
class="grow min-w-0"
placeholder="Rechercher par nom…"
autocomplete="off"
/>
</label>
<button
v-if="canEdit"
type="button"
class="btn btn-primary btn-sm"
:disabled="loading"
@click="openCreatePage"
>
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
Créer
</button>
</template>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.name }}</span>
</template>
<template #cell-notes="{ row }">
<span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
<span v-else class="text-base-content/50"></span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
Liés
</button>
<button
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-xs text-warning"
@click="openConversionModal(row)"
>
Convertir
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
Éditer
</button>
<button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
Supprimer
</button>
</div>
</template>
</DataTable>
<ModelTypesConversionModal
:open="conversionModalOpen"
@@ -57,7 +117,7 @@
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des éléments liés
</div>
@@ -103,43 +163,46 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
import { useHead, useRouter } from "#imports";
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
import ModelTypesTable from "~/components/model-types/Table.vue";
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
import { useApi } from "~/composables/useApi";
import { useUrlState } from "~/composables/useUrlState";
import { extractCollection } from "~/shared/utils/apiHelpers";
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { useHead, useRouter } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
import { useApi } from '~/composables/useApi'
import { useUrlState } from '~/composables/useUrlState'
import { extractCollection } from '~/shared/utils/apiHelpers'
import type { DataTableSort } from '~/shared/types/dataTable'
import {
deleteModelType,
listModelTypes,
type ModelCategory,
type ModelType,
type ModelTypeListResponse,
} from "~/services/modelTypes";
import { useToast } from "~/composables/useToast";
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
} from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import IconLucideSearch from '~icons/lucide/search'
import IconLucidePlus from '~icons/lucide/plus'
const DEFAULT_DESCRIPTION =
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
const DEFAULT_DESCRIPTION
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
const props = withDefaults(
defineProps<{
category: ModelCategory;
heading: string;
description?: string;
allowCategorySwitch?: boolean;
category: ModelCategory
heading: string
description?: string
allowCategorySwitch?: boolean
}>(),
{
allowCategorySwitch: false,
}
);
},
)
const selectedCategory = ref<ModelCategory>(props.category);
const searchInput = ref("");
const selectedCategory = ref<ModelCategory>(props.category)
const searchInput = ref('')
// State synced with URL query params (preserved on back/forward navigation)
// State synced with URL query params
const urlState = useUrlState({
q: { default: '' },
sort: { default: 'name' },
@@ -148,81 +211,126 @@ const urlState = useUrlState({
offset: { default: 0, type: 'number' },
}, {
onRestore: () => {
searchInput.value = urlState.q.value;
refresh();
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;
})
const searchTerm = urlState.q
const sort = urlState.sort as Ref<'name' | 'createdAt'>
const dir = urlState.dir as Ref<'asc' | 'desc'>
const limit = urlState.limit
const offset = urlState.offset
// Initialize searchInput from URL (for direct navigation with ?q=...)
searchInput.value = searchTerm.value;
// Initialize searchInput from URL
searchInput.value = searchTerm.value
const items = ref<ModelType[]>([]);
const total = ref(0);
const loading = ref(false);
const items = ref<ModelType[]>([])
const total = ref(0)
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let activeController: AbortController | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let activeController: AbortController | null = null
const router = useRouter();
const { showError, showSuccess } = useToast();
const { get } = useApi();
const { canEdit } = usePermissions();
const router = useRouter()
const { showError, showSuccess } = useToast()
const { get } = useApi()
const { canEdit } = usePermissions()
const headingText = computed(() => props.heading);
const descriptionText = computed(
() => props.description ?? DEFAULT_DESCRIPTION
);
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
const headingText = computed(() => props.heading)
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
useHead(() => ({
title: headingText.value,
}));
useHead(() => ({ title: headingText.value }))
const extractErrorMessage = (error: unknown) => {
if (error && typeof error === "object") {
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'notes', label: 'Notes' },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
]
const showConvertButton = computed(() =>
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
)
const categories: Array<{ label: string, value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' },
{ label: 'Pièces', value: 'PIECE' },
{ label: 'Produits', value: 'PRODUCT' },
]
// Sort state for DataTable
const currentSort = computed<DataTableSort>(() => ({
field: sort.value,
direction: dir.value,
}))
const handleSort = (newSort: DataTableSort) => {
sort.value = newSort.field as 'name' | 'createdAt'
dir.value = newSort.direction as 'asc' | 'desc'
offset.value = 0
doRefresh()
}
// Pagination: convert offset/limit to page-based for DataTable
const currentPage = computed(() => {
if (limit.value <= 0) return 1
return Math.floor(offset.value / limit.value) + 1
})
const totalPages = computed(() => {
if (limit.value <= 0) return 1
return Math.max(1, Math.ceil(total.value / limit.value))
})
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: total.value,
pageItems: items.value.length,
perPageOptions: [20, 50, 100],
perPage: limit.value,
}))
const handlePageChange = (page: number) => {
offset.value = (page - 1) * limit.value
doRefresh()
}
const handlePerPageChange = (perPage: number) => {
limit.value = perPage
offset.value = 0
doRefresh()
}
const extractErrorMessage = (error: unknown): string => {
let raw: string | null = null
if (error && typeof error === 'object') {
const maybeFetchError = error as {
data?: Record<string, unknown>;
statusMessage?: string;
message?: string;
};
data?: Record<string, unknown>
statusMessage?: string
message?: string
}
if (maybeFetchError.data) {
const data = maybeFetchError.data;
if (typeof data.message === "string") {
return data.message;
}
if (Array.isArray(data.message) && data.message.length > 0) {
return data.message[0];
}
}
if (typeof maybeFetchError.statusMessage === "string") {
return maybeFetchError.statusMessage;
}
if (typeof maybeFetchError.message === "string") {
return maybeFetchError.message;
const data = maybeFetchError.data
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
else if (typeof data.detail === 'string') raw = data.detail
else if (typeof data.message === 'string') raw = data.message
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
else if (typeof data.error === 'string') raw = data.error
}
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
}
return "Une erreur est survenue lors de la communication avec le serveur.";
};
return humanizeError(raw)
}
const refresh = async ({
resetOffset = false,
}: { resetOffset?: boolean } = {}) => {
if (resetOffset) {
offset.value = 0;
}
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
if (resetOffset) offset.value = 0
if (activeController) {
activeController.abort();
}
const controller = new AbortController();
activeController = controller;
loading.value = true;
if (activeController) activeController.abort()
const controller = new AbortController()
activeController = controller
loading.value = true
try {
const response: ModelTypeListResponse = await listModelTypes(
@@ -234,312 +342,236 @@ const refresh = async ({
limit: limit.value,
offset: offset.value,
},
{ signal: controller.signal }
);
items.value = response.items;
total.value = response.total;
offset.value = response.offset;
limit.value = response.limit;
} catch (error: unknown) {
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
return;
}
showError(extractErrorMessage(error));
} finally {
{ signal: controller.signal },
)
items.value = response.items
total.value = response.total
offset.value = response.offset
limit.value = response.limit
}
catch (error: unknown) {
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
showError(extractErrorMessage(error))
}
finally {
if (activeController === controller) {
loading.value = false;
activeController = null;
loading.value = false
activeController = null
}
}
};
}
watch(
() => props.category,
(value) => {
if (value !== selectedCategory.value) {
selectedCategory.value = value;
refresh({ resetOffset: true });
selectedCategory.value = value
doRefresh({ resetOffset: true })
}
}
);
const onSearchInput = (value: string) => {
searchInput.value = value;
};
},
)
const onCategoryChange = (value: ModelCategory) => {
if (!allowCategorySwitch.value) {
return;
}
if (!props.allowCategorySwitch) return
if (selectedCategory.value !== value) {
selectedCategory.value = value;
refresh({ resetOffset: true });
selectedCategory.value = value
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) => {
if (category === "COMPONENT") {
return "/component-category";
}
if (category === "PIECE") {
return "/piece-category";
}
return "/product-category";
};
if (category === 'COMPONENT') return '/component-category'
if (category === 'PIECE') return '/piece-category'
return '/product-category'
}
const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value);
const basePath = resolveCategoryBasePath(selectedCategory.value)
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 category = item.category ?? selectedCategory.value;
const basePath = resolveCategoryBasePath(category);
const category = item.category ?? selectedCategory.value
const basePath = resolveCategoryBasePath(category)
router.push(`${basePath}/${item.id}/edit`).catch(() => {
showError("Navigation impossible vers la page d'édition.");
});
};
showError("Navigation impossible vers la page d'édition.")
})
}
const { confirm } = useConfirm()
const confirmDelete = async (item: ModelType) => {
const confirmed = await confirm({
message: 'Supprimer ce type ? Cette action est irréversible.',
});
if (!confirmed) {
return;
}
})
if (!confirmed) return
try {
await deleteModelType(item.id);
invalidateEntityTypeCache(item.category);
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
await deleteModelType(item.id)
invalidateEntityTypeCache(item.category)
showSuccess(`Type « ${item.name} » supprimé avec succès.`)
if (items.value.length === 1 && offset.value >= limit.value) {
offset.value = Math.max(0, offset.value - limit.value);
offset.value = Math.max(0, offset.value - limit.value)
}
await refresh();
} catch (error) {
showError(extractErrorMessage(error));
await doRefresh()
}
};
catch (error) {
showError(extractErrorMessage(error))
}
}
type RelatedEntry = {
id: string;
name: string;
reference?: string | null;
};
id: string
name: string
reference?: string | null
}
const relatedModalOpen = ref(false);
const relatedLoading = ref(false);
const relatedError = ref<string | null>(null);
const relatedItems = ref<RelatedEntry[]>([]);
const relatedType = ref<ModelType | null>(null);
const relatedModalOpen = ref(false)
const relatedLoading = ref(false)
const relatedError = ref<string | null>(null)
const relatedItems = ref<RelatedEntry[]>([])
const relatedType = ref<ModelType | null>(null)
const relatedCategoryLabels: Record<
ModelCategory,
{ plural: string; singular: string }
> = {
COMPONENT: { plural: "composants", singular: "composant" },
PIECE: { plural: "pièces", singular: "pièce" },
PRODUCT: { plural: "produits", singular: "produit" },
};
const relatedCategoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
COMPONENT: { plural: 'composants', singular: 'composant' },
PIECE: { plural: 'pièces', singular: 'pièce' },
PRODUCT: { plural: 'produits', singular: 'produit' },
}
const relatedModalTitle = computed(() => {
const current = relatedType.value;
if (!current) {
return "Éléments liés";
}
return `Éléments liés à « ${current.name} »`;
});
const current = relatedType.value
if (!current) return 'Éléments liés'
return `Éléments liés à « ${current.name} »`
})
const relatedModalSubtitle = computed(() => {
const current = relatedType.value;
if (!current) {
return "";
}
const labels =
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
const count = relatedItems.value.length;
if (relatedLoading.value) {
return `Chargement des ${labels.plural}`;
}
if (count === 0) {
return `Aucun ${labels.singular} lié.`;
}
if (count === 1) {
return `1 ${labels.singular} lié.`;
}
return `${count} ${labels.plural} liés.`;
});
const current = relatedType.value
if (!current) return ''
const labels = relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT
const count = relatedItems.value.length
if (relatedLoading.value) return `Chargement des ${labels.plural}`
if (count === 0) return `Aucun ${labels.singular} lié.`
if (count === 1) return `1 ${labels.singular} lié.`
return `${count} ${labels.plural} liés.`
})
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === "COMPONENT") {
return { endpoint: "/composants", filterKey: "typeComposant" };
}
if (category === "PIECE") {
return { endpoint: "/pieces", filterKey: "typePiece" };
}
return { endpoint: "/products", filterKey: "typeProduct" };
};
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
return { endpoint: '/products', filterKey: 'typeProduct' }
}
const resolveRelatedEditBasePath = (category: ModelCategory) => {
if (category === "COMPONENT") {
return "/component";
}
if (category === "PIECE") {
return "/pieces";
}
return "/product";
};
if (category === 'COMPONENT') return '/component'
if (category === 'PIECE') return '/pieces'
return '/product'
}
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== "object") {
return null;
}
const record = item as Record<string, unknown>;
if (typeof record.id !== "string") {
return null;
}
const name =
typeof record.name === "string" && record.name.trim()
? record.name
: "Sans nom";
const reference =
typeof record.reference === "string" && record.reference.trim()
if (!item || typeof item !== 'object') return null
const record = item as Record<string, unknown>
if (typeof record.id !== 'string') return null
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
const reference
= typeof record.reference === 'string' && record.reference.trim()
? record.reference
: typeof record.code === "string" && record.code.trim()
: typeof record.code === 'string' && record.code.trim()
? record.code
: null;
return {
id: record.id,
name,
reference,
};
};
: null
return { id: record.id, name, reference }
}
const loadRelatedItems = async (item: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
const params = new URLSearchParams();
params.set("itemsPerPage", "200");
params.set(filterKey, buildModelTypeIri(item.id));
params.set("order[name]", "asc");
const { endpoint, filterKey } = resolveRelatedConfig(item.category)
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
params.set(filterKey, buildModelTypeIri(item.id))
params.set('order[name]', 'asc')
relatedLoading.value = true;
relatedError.value = null;
relatedItems.value = [];
relatedLoading.value = true
relatedError.value = null
relatedItems.value = []
try {
const result = await get(`${endpoint}?${params.toString()}`);
const result = await get(`${endpoint}?${params.toString()}`)
if (!result.success) {
relatedError.value =
result.error ?? "Impossible de charger les éléments liés.";
return;
relatedError.value = result.error ?? 'Impossible de charger les éléments liés.'
return
}
const collection = extractCollection(result.data);
const collection = extractCollection(result.data)
relatedItems.value = collection
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry));
} catch (error) {
relatedError.value = extractErrorMessage(error);
} finally {
relatedLoading.value = false;
.filter((entry): entry is RelatedEntry => Boolean(entry))
}
};
catch (error) {
relatedError.value = extractErrorMessage(error)
}
finally {
relatedLoading.value = false
}
}
const openRelatedModal = (item: ModelType) => {
relatedType.value = item;
relatedModalOpen.value = true;
void loadRelatedItems(item);
};
relatedType.value = item
relatedModalOpen.value = true
void loadRelatedItems(item)
}
const openRelatedEdit = (entry: RelatedEntry) => {
const current = relatedType.value;
if (!current) {
return;
}
const basePath = resolveRelatedEditBasePath(current.category);
relatedModalOpen.value = false;
const current = relatedType.value
if (!current) return
const basePath = resolveRelatedEditBasePath(current.category)
relatedModalOpen.value = false
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
showError("Navigation impossible vers la fiche d'édition.");
});
};
showError("Navigation impossible vers la fiche d'édition.")
})
}
const closeRelatedModal = () => {
relatedModalOpen.value = false;
};
relatedModalOpen.value = false
}
const conversionModalOpen = ref(false);
const conversionTarget = ref<ModelType | null>(null);
const conversionModalOpen = ref(false)
const conversionTarget = ref<ModelType | null>(null)
const openConversionModal = (item: ModelType) => {
conversionTarget.value = item;
conversionModalOpen.value = true;
};
conversionTarget.value = item
conversionModalOpen.value = true
}
const closeConversionModal = () => {
conversionModalOpen.value = false;
};
conversionModalOpen.value = false
}
const onConverted = () => {
conversionModalOpen.value = false;
invalidateEntityTypeCache("PIECE");
invalidateEntityTypeCache("COMPONENT");
showSuccess("Catégorie convertie avec succès.");
refresh();
};
conversionModalOpen.value = false
invalidateEntityTypeCache('PIECE')
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie convertie avec succès.')
doRefresh()
}
watch(
() => searchInput.value,
(value) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
searchTerm.value = value.trim();
refresh({ resetOffset: true });
}, 300);
}
);
searchTerm.value = value.trim()
doRefresh({ resetOffset: true })
}, 300)
},
)
onMounted(() => {
refresh();
});
doRefresh()
})
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (activeController) {
activeController.abort();
}
});
if (debounceTimer) clearTimeout(debounceTimer)
if (activeController) activeController.abort()
})
</script>

View File

@@ -57,8 +57,8 @@
<div class="flex items-center gap-3 text-sm">
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -116,7 +116,7 @@
</template>
<script setup>
import { computed, toRefs } from 'vue'
import { computed } from 'vue'
import { isImageDocument } from '~/utils/documentPreview'
import DocumentUpload from '~/components/DocumentUpload.vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
@@ -173,8 +173,6 @@ const emit = defineEmits([
'update:selectedFiles'
])
const form = toRefs(props.form)
const selectedFilesModel = computed({
get: () => props.selectedFiles,
set: value => emit('update:selectedFiles', value)

View File

@@ -1,4 +1,5 @@
import { useToast } from './useToast'
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
export interface ApiResponse<T = any> {
success: boolean
@@ -20,11 +21,10 @@ export function useApi() {
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
const url = `${API_BASE_URL}${endpoint}`
const isFormData = options.body instanceof FormData
const defaultOptions: ApiCallOptions = {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
}
// Ajouter un timeout à la requête
@@ -60,23 +60,26 @@ export function useApi() {
} else {
const contentType = response.headers.get('content-type') || ''
let errorData: Record<string, unknown> = {}
if (contentType.includes('application/json')) {
if (contentType.includes('json')) {
errorData = await response.json().catch(() => ({}))
} else {
const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {}
}
const errorMessage = response.status === 403
const rawMessage = response.status === 403
? 'Permissions insuffisantes pour cette action.'
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
const errorMessage = humanizeError(rawMessage)
showError(errorMessage)
return { success: false, error: errorMessage, status: response.status }
}
} catch (error) {
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
showError(`Erreur réseau: ${errorMessage}`)
const errorMessage = err.name === 'AbortError'
? 'La requête a pris trop de temps. Veuillez réessayer.'
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
showError(errorMessage)
return { success: false, error: errorMessage }
}
}
@@ -115,6 +118,13 @@ export function useApi() {
})
}
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'POST',
body: formData,
})
}
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, { method: 'DELETE' })
}
@@ -123,6 +133,7 @@ export function useApi() {
apiCall,
get,
post,
postFormData,
patch,
put,
delete: del,

View File

@@ -68,15 +68,21 @@ export function useComments() {
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)
params.set('order[createdAt]', 'desc')
if (options.entityName) params.set('entityName', options.entityName)
const sortField = options.orderBy || 'createdAt'
const sortDir = options.orderDir || 'desc'
params.set(`order[${sortField}]`, sortDir)
params.set('itemsPerPage', String(options.itemsPerPage || 30))
params.set('page', String(options.page || 1))

View File

@@ -10,6 +10,7 @@ export interface Composant {
id: string
name: string
reference?: string | null
description?: string | null
typeComposantId?: string | null
typeComposant?: { id: string; name?: string } | null
productId?: string | null
@@ -40,6 +41,7 @@ interface LoadComposantsOptions {
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
typeName?: string
force?: boolean
}
@@ -106,10 +108,11 @@ export function useComposants() {
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
typeName,
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
if (!force && loaded.value && !search && !typeName && page === 1) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
@@ -134,6 +137,10 @@ export function useComposants() {
params.set('name', search.trim())
}
if (typeName && typeName.trim()) {
params.set('typeComposant.name', typeName.trim())
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/composants?${params.toString()}`)

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

View File

@@ -1,7 +1,6 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Document {
@@ -10,12 +9,21 @@ export interface Document {
filename: string
mimeType: string
size: number
path: string
fileUrl: string
downloadUrl: string
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
path?: string
createdAt?: string
siteId?: string
machineId?: string
composantId?: string
productId?: string
pieceId?: string
site?: { id: string; name?: string } | null
machine?: { id: string; name?: string } | null
composant?: { id: string; name?: string } | null
piece?: { id: string; name?: string } | null
product?: { id: string; name?: string } | null
}
export interface UploadContext {
@@ -32,19 +40,30 @@ export interface DocumentResult {
error?: string
}
const documents = ref<Document[]>([])
const loading = ref(false)
interface LoadDocumentsOptions {
search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
attachmentFilter?: string
force?: boolean
}
const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
reader.readAsDataURL(file)
})
const documents = ref<Document[]>([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function useDocuments() {
const { get, post, delete: del } = useApi()
const { get, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast()
const loadFromEndpoint = async (
@@ -76,10 +95,61 @@ export function useDocuments() {
}
}
const loadDocuments = async (
options: { updateStore?: boolean; itemsPerPage?: number } = {},
): Promise<DocumentResult> => {
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
const {
search = '',
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 (
@@ -145,18 +215,17 @@ export function useDocuments() {
try {
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({
name: file.name,
filename: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
path: dataUrl,
...context,
})
if (context.siteId) formData.append('siteId', context.siteId)
if (context.machineId) formData.append('machineId', context.machineId)
if (context.composantId) formData.append('composantId', context.composantId)
if (context.productId) formData.append('productId', context.productId)
if (context.pieceId) formData.append('pieceId', context.pieceId)
const result = await post('/documents', payload)
const result = await postFormData('/documents', formData)
if (result.success) {
created.push(result.data as Document)
showSuccess(`Document "${file.name}" ajouté`)
@@ -213,7 +282,9 @@ export function useDocuments() {
return {
documents,
total,
loading,
loaded,
loadDocuments,
loadDocumentsBySite,
loadDocumentsByMachine,

View File

@@ -7,6 +7,7 @@
import { ref, type Ref } from 'vue'
import { useToast } from './useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import {
listModelTypes,
createModelType,
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true, data: state.types.value }
} catch (error) {
const err = error as Error & { message?: string }
const message = err?.message || 'Erreur inconnue'
showError(`Impossible de charger les types de ${label}: ${message}`)
const message = humanizeError(err?.message)
showError(`Impossible de charger les types de ${label}.`)
return { success: false, error: message }
} finally {
state.loading.value = false
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la création du type de ${label}: ${message}`)
const raw = err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de créer le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
const raw = err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
const raw = err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de supprimer le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false

View File

@@ -1,46 +1,23 @@
/**
* Machine creation page orchestration composable.
*
* Consolidates entity lookup maps, option filters, label helpers,
* template wrappers, and the finalization logic that were previously
* inlined in pages/machines/new.vue.
* Simplified: no more TypeMachine / skeleton system.
* Supports direct creation or cloning from an existing machine.
*/
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
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 { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
import {
useMachineCreatePreview,
validateRequirementSelections as _validateRequirementSelections,
resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel,
resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel,
} from '~/composables/useMachineCreatePreview'
import {
getComponentMachineAssignments,
getPieceMachineAssignments,
getPieceComponentAssignments,
formatAssignmentList,
} from '~/shared/utils/assignmentUtils'
import { humanizeError } from '~/shared/utils/errorMessages'
export function useMachineCreatePage() {
// ---------------------------------------------------------------------------
// Composable calls
// ---------------------------------------------------------------------------
const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
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()
// ---------------------------------------------------------------------------
@@ -48,355 +25,84 @@ export function useMachineCreatePage() {
// ---------------------------------------------------------------------------
const submitting = ref(false)
const loading = ref(true)
const newMachine = reactive({
name: '',
siteId: '',
typeMachineId: '',
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
// ---------------------------------------------------------------------------
const finalizeMachineCreation = async () => {
if (submitting.value) return
const type = selectedMachineType.value
if (!type) {
toast.showError('Merci de sélectionner un type de machine')
return
}
if (!canCreateMachine.value) {
toast.showError('Compléter les informations obligatoires avant de créer la machine')
if (!newMachine.name?.trim()) {
toast.showError('Merci de renseigner un nom pour la machine')
return
}
submitting.value = true
try {
const baseMachineData = {
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference,
typeMachineId: type.id,
let result: any
if (newMachine.cloneFromMachineId) {
result = await cloneMachine(newMachine.cloneFromMachineId, {
name: newMachine.name,
siteId: newMachine.siteId,
...(newMachine.reference ? { reference: newMachine.reference } : {}),
})
} else {
result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId || undefined,
reference: newMachine.reference || undefined,
} as any)
}
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) {
const machineId = result.data?.id
if (hasRequirements && machineId) {
const skeletonResult: any = await reconfigureSkeleton(machineId, {
componentLinks,
pieceLinks,
productLinks,
} as any)
if (!skeletonResult.success) {
// Rollback: delete the orphaned machine
await deleteMachine(machineId).catch(() => {})
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants. La machine n\'a pas été créée.')
return
}
}
// Initialize custom fields for the machine type
if (machineId) {
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
}
|| (result.data?.machine as any)?.id
|| null
newMachine.name = ''
newMachine.siteId = ''
newMachine.typeMachineId = ''
newMachine.reference = ''
clearRequirementSelections()
await navigateTo('/machines')
newMachine.cloneFromMachineId = ''
if (machineId) {
await navigateTo(`/machine/${machineId}`)
} else {
await navigateTo('/machines')
}
} 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) {
toast.showError(`Erreur lors de la création: ${error.message}`)
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
} finally {
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 () => {
await Promise.all([
loadSites(),
loadMachineTypes(),
loadComposants({ itemsPerPage: 200, force: true }),
loadPieces({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200, force: true }),
])
loading.value = true
try {
await Promise.all([
loadSites(),
loadMachines(),
])
} finally {
loading.value = false
}
})
// ---------------------------------------------------------------------------
@@ -405,59 +111,11 @@ export function useMachineCreatePage() {
return {
// State
submitting,
newMachine,
sites,
machineTypes,
machineTypesLoading,
composantsLoading,
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,
machines,
submitting,
loading,
// Actions
finalizeMachineCreation,

View File

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

View File

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

View File

@@ -39,6 +39,8 @@ import {
resolveIdentifier,
resolveProductReference as _resolveProductReference,
getProductDisplay as _getProductDisplay,
getProductSuppliersLabel,
getProductPriceLabel,
extractParentLinkIdentifiers,
} from '~/shared/utils/productDisplayUtils'
import {
@@ -64,7 +66,7 @@ export function useMachineDetailData(machineId: string) {
const {
updateMachine: updateMachineApi,
reconfigureSkeleton: reconfigureMachineSkeleton,
updateStructure: updateMachineStructure,
} = useMachines()
const { updateComposant: updateComposantApi } = useComposants()
const { updatePiece: updatePieceApi } = usePieces()
@@ -75,11 +77,12 @@ export function useMachineDetailData(machineId: string) {
upsertCustomFieldValue,
updateCustomFieldValue: updateCustomFieldValueApi,
} = useCustomFields()
const { get } = useApi()
const { get, post: apiPost, delete: apiDel } = useApi()
const {
uploadDocuments,
deleteDocument,
loadDocumentsByMachine,
loadDocumentsByProduct,
} = useDocuments()
const toast = useToast()
const { constructeurs, loadConstructeurs } = useConstructeurs()
@@ -105,6 +108,7 @@ export function useMachineDetailData(machineId: string) {
const machineComponentLinks = ref<AnyRecord[]>([])
const machinePieceLinks = ref<AnyRecord[]>([])
const machineProductLinks = ref<AnyRecord[]>([])
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
const printAreaRef = ref<HTMLElement | null>(null)
// ---------------------------------------------------------------------------
@@ -169,39 +173,21 @@ export function useMachineDetailData(machineId: string) {
const componentsCollapsed = ref(true)
const collapseToggleToken = ref(0)
const piecesCollapsed = ref(true)
const pieceCollapseToggleToken = ref(0)
// ---------------------------------------------------------------------------
// Product helpers
// ---------------------------------------------------------------------------
const machineType = computed(() => (machine.value as AnyRecord)?.typeMachine || null)
const componentTypeOptions = computed(() => componentTypes.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 map = new Map<string, string>()
componentTypeOptions.value.forEach((type) => {
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
})
@@ -210,10 +196,6 @@ export function useMachineDetailData(machineId: string) {
pieceTypeOptions.value.forEach((type) => {
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
})
@@ -310,8 +292,7 @@ export function useMachineDetailData(machineId: string) {
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
return (piecesData || []).map((piece) => {
const requirement = (piece.typeMachinePieceRequirement as AnyRecord) || {}
const typePiece = (requirement.typePiece as AnyRecord) || (piece.typePiece as AnyRecord) || {}
const typePiece = (piece.typePiece as AnyRecord) || {}
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
@@ -320,10 +301,6 @@ export function useMachineDetailData(machineId: string) {
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
normalizeStructureDefs((piece.typePiece as AnyRecord)?.structure),
normalizeStructureDefs(typePiece.structure),
normalizeStructureDefs(typePiece.pieceSkeleton),
normalizeStructureDefs((piece.typePiece as AnyRecord)?.pieceSkeleton),
normalizeStructureDefs(requirement.structure),
normalizeStructureDefs(requirement.pieceSkeleton),
]
const valueEntries = [
@@ -347,17 +324,16 @@ export function useMachineDetailData(machineId: string) {
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
normalizeExistingCustomFieldDefinitions((requirement.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(requirement.customFields),
normalizeExistingCustomFieldDefinitions((requirement.definition as AnyRecord)?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
const constructeurIds = uniqueConstructeurIds(
piece.constructeurs,
piece.constructeurIds,
piece.constructeurId,
piece.constructeur,
(piece.originalPiece as AnyRecord)?.constructeurs,
(piece.originalPiece as AnyRecord)?.constructeurIds,
(piece.originalPiece as AnyRecord)?.constructeurId,
(piece.originalPiece as AnyRecord)?.constructeur,
@@ -396,7 +372,6 @@ export function useMachineDetailData(machineId: string) {
constructeurId: constructeurIds[0] || null,
typePieceId:
piece.typePieceId ||
(piece.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
(piece.typePiece as AnyRecord)?.id ||
null,
__productDisplay: productDisplay,
@@ -409,16 +384,12 @@ export function useMachineDetailData(machineId: string) {
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
return (componentsData || []).map((component) => {
const requirement = (component.typeMachineComponentRequirement as AnyRecord) || {}
const type = (requirement.typeComposant as AnyRecord) || (component.typeComposant as AnyRecord) || {}
const type = (component.typeComposant as AnyRecord) || {}
const normalizedStructureDefs = [
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
normalizeStructureDefs((component.typeComposant as AnyRecord)?.structure),
normalizeStructureDefs(type.structure),
normalizeStructureDefs(type.componentSkeleton),
normalizeStructureDefs(requirement.structure),
normalizeStructureDefs(requirement.componentSkeleton),
]
const actualComponent = (component.originalComposant as AnyRecord) || component
@@ -445,9 +416,6 @@ export function useMachineDetailData(machineId: string) {
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(type.customFields),
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
normalizeExistingCustomFieldDefinitions((requirement.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(requirement.customFields),
normalizeExistingCustomFieldDefinitions((requirement.definition as AnyRecord)?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
@@ -464,9 +432,11 @@ export function useMachineDetailData(machineId: string) {
: []
const constructeurIds = uniqueConstructeurIds(
component.constructeurs,
component.constructeurIds,
component.constructeurId,
component.constructeur,
actualComponent?.constructeurs,
actualComponent?.constructeurIds,
actualComponent?.constructeurId,
actualComponent?.constructeur,
@@ -505,7 +475,6 @@ export function useMachineDetailData(machineId: string) {
constructeurId: constructeurIds[0] || null,
typeComposantId:
component.typeComposantId ||
(component.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
(component.typeComposant as AnyRecord)?.id ||
null,
__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(
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
)
@@ -583,166 +633,6 @@ export function useMachineDetailData(machineId: string) {
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
// ---------------------------------------------------------------------------
@@ -784,9 +674,6 @@ export function useMachineDetailData(machineId: string) {
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
normalizeExistingCustomFieldDefinitions(
(machine.value.typeMachine as AnyRecord)?.customFields,
),
),
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
machineCustomFields.value = merged
@@ -977,6 +864,7 @@ export function useMachineDetailData(machineId: string) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
}
loadProductDocuments().catch(() => {})
}
}
} catch (error) {
@@ -1126,6 +1014,11 @@ export function useMachineDetailData(machineId: string) {
collapseToggleToken.value += 1
}
const toggleAllPieces = () => {
piecesCollapsed.value = !piecesCollapsed.value
pieceCollapseToggleToken.value += 1
}
// ---------------------------------------------------------------------------
// 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 aggregated: AnyRecord[] = []
machinePieces.value.forEach((piece) => aggregated.push(piece))
flattenedComponents.value.forEach((component) => {
;((component.pieces as AnyRecord[]) || []).forEach((piece) => aggregated.push(piece))
const reloadMachineStructure = async () => {
const result: any = await get(`/machines/${machineId}/structure`)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'
? result.data.machine
: result.data
if (machinePayload && typeof machinePayload === 'object') {
machine.value = {
...machine.value,
...machinePayload,
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
}
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
syncMachineCustomFields()
}
}
}
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 () => {
loading.value = true
try {
const machineResult: any = await get(`/machines/${machineId}/skeleton`)
const machineResult: any = await get(`/machines/${machineId}/structure`)
if (!machineResult.success) {
console.error('Machine non trouvée:', machineId, machineResult.error)
@@ -1233,6 +1228,9 @@ export function useMachineDetailData(machineId: string) {
collapseAllComponents()
// Load product documents in background
loadProductDocuments().catch(() => {})
// Wait for documents if still loading
await documentPromise
} 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)?.customFields, () => syncMachineCustomFields(), { deep: true })
watch(() => ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
watch(
() => [components.value.length, machinePieces.value.length],
() => ensurePrintSelectionEntries(),
@@ -1298,13 +1295,10 @@ export function useMachineDetailData(machineId: string) {
debug,
componentsCollapsed,
collapseToggleToken,
piecesCollapsed,
pieceCollapseToggleToken,
// Computed
machineType,
componentRequirements,
pieceRequirements,
productRequirements,
machineHasSkeletonRequirements,
componentTypeOptions,
pieceTypeOptions,
componentTypeLabelMap,
@@ -1313,12 +1307,9 @@ export function useMachineDetailData(machineId: string) {
productById,
flattenedComponents,
machinePieces,
machineDirectProducts,
machineDocumentsList,
visibleMachineCustomFields,
componentRequirementGroups,
pieceRequirementGroups,
productRequirementGroups,
machineDirectProducts,
// Product helpers
findProductById,
@@ -1331,7 +1322,6 @@ export function useMachineDetailData(machineId: string) {
findComponentById,
findPieceById,
collectConstructeurs,
collectPiecesForSkeleton,
// Transform
transformCustomFields,
@@ -1370,6 +1360,7 @@ export function useMachineDetailData(machineId: string) {
toggleEditMode,
toggleAllComponents,
collapseAllComponents,
toggleAllPieces,
// Print
printModalOpen,
@@ -1384,10 +1375,19 @@ export function useMachineDetailData(machineId: string) {
loadMachineData,
loadInitialData,
// External (needed by skeleton editor)
// Structure link management
addComponentLink,
removeComponentLink,
addPieceLink,
removePieceLink,
addProductLink,
removeProductLink,
reloadMachineStructure,
// External
constructeurs,
loadProducts,
reconfigureMachineSkeleton,
updateMachineStructure,
toast,
// Re-exports for template

View File

@@ -153,8 +153,6 @@ export const buildMachineHierarchyFromLinks = (
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 requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
const machinePieceLinkId = normalizePieceLinkId(link)
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
@@ -170,11 +168,8 @@ export const buildMachineHierarchyFromLinks = (
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || 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 : [],
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
typeMachinePieceRequirement: requirement,
typeMachinePieceRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
typePiece: appliedPiece.typePiece || null,
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || null,
overrides,
originalPiece,
machinePieceLink: link,
@@ -186,11 +181,8 @@ export const buildMachineHierarchyFromLinks = (
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedPiece.definition || originalPiece?.definition || {},
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)
@@ -215,8 +207,6 @@ export const buildMachineHierarchyFromLinks = (
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 requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
const machineComponentLinkId = normalizeComponentLinkId(link)
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
@@ -245,11 +235,8 @@ export const buildMachineHierarchyFromLinks = (
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || 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 : [],
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
typeMachineComponentRequirement: requirement,
typeMachineComponentRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
typeComposant: appliedComponent.typeComposant || null,
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || null,
overrides: compOverrides,
machineComponentLinkOverrides: compOverrides,
definitionOverrides: compOverrides,
@@ -259,16 +246,12 @@ export const buildMachineHierarchyFromLinks = (
componentLinkId: machineComponentLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
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 || {},
customFields: appliedComponent.customFields || [],
pieces,
subComponents,
subcomponents: 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)

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ export interface Machine {
id: string
name?: string
siteId?: string | null
typeMachineId?: string | null
componentLinks?: unknown[]
pieceLinks?: 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']) ??
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> => {
loading.value = true
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) {
return { success: false, error: 'Identifiant de machine manquant' }
}
loading.value = true
try {
const result = await patch(`/machines/${machineId}/skeleton`, payload)
const result = await patch(`/machines/${machineId}/structure`, payload)
if (result.success) {
const index = machines.value.findIndex((machine) => machine.id === machineId)
if (index !== -1) {
@@ -180,7 +163,29 @@ export function useMachines() {
}
return result
} 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 }
} finally {
loading.value = false
@@ -241,10 +246,6 @@ export function useMachines() {
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 isLoading = (): boolean => loading.value
@@ -253,13 +254,12 @@ export function useMachines() {
loading,
loadMachines,
createMachine,
createMachineFromType,
updateMachine: updateMachineData,
reconfigureSkeleton,
updateStructure,
cloneMachine,
deleteMachine,
getMachineById,
getMachinesBySite,
getMachinesByType,
getMachines,
isLoading,
addMissingCustomFields,

View File

@@ -10,6 +10,7 @@ export interface Piece {
id: string
name: string
reference?: string | null
description?: string | null
typePieceId?: string | null
typePiece?: { id: string; name?: string } | null
productId?: string | null
@@ -41,6 +42,7 @@ interface LoadPiecesOptions {
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
typeName?: string
force?: boolean
}
@@ -116,10 +118,11 @@ export function usePieces() {
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
typeName,
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
if (!force && loaded.value && !search && !typeName && page === 1) {
return {
success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage },
@@ -144,6 +147,10 @@ export function usePieces() {
params.set('name', search.trim())
}
if (typeName && typeName.trim()) {
params.set('typePiece.name', typeName.trim())
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/pieces?${params.toString()}`)

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { humanizeError } from '~/shared/utils/errorMessages'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
@@ -39,6 +40,7 @@ interface LoadProductsOptions {
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
typeName?: string
force?: boolean
}
@@ -115,10 +117,11 @@ export function useProducts() {
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
typeName,
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
if (!force && loaded.value && !search && !typeName && page === 1) {
return {
success: true,
data: { items: products.value, total: total.value, page, itemsPerPage },
@@ -143,6 +146,10 @@ export function useProducts() {
params.set('name', search.trim())
}
if (typeName && typeName.trim()) {
params.set('typeProduct.name', typeName.trim())
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/products?${params.toString()}`)
@@ -168,9 +175,9 @@ export function useProducts() {
return result as ProductListResult
} catch (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
showError(`Impossible de charger les produits: ${message}`)
showError(`Impossible de charger les produits.`)
return { success: false, error: message }
} finally {
loading.value = false
@@ -197,9 +204,9 @@ export function useProducts() {
return { success: false, error: result.error }
} catch (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
showError(message)
showError('Impossible de créer le produit.')
return { success: false, error: message }
} finally {
loading.value = false
@@ -223,9 +230,9 @@ export function useProducts() {
return { success: false, error: result.error }
} catch (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
showError(message)
showError('Impossible de mettre à jour le produit.')
return { success: false, error: message }
} finally {
loading.value = false
@@ -248,9 +255,9 @@ export function useProducts() {
return { success: false, error: result.error }
} catch (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
showError(message)
showError('Impossible de supprimer le produit.')
return { success: false, error: message }
} finally {
loading.value = false

View File

@@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { navigateTo, useRoute } from '#imports'
import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConfirm } from '~/composables/useConfirm'
import { getFileIcon } from '~/utils/fileIcons'
@@ -23,6 +24,8 @@ type SiteDocument = {
mimeType?: string
size?: number
path?: string
fileUrl?: string
downloadUrl?: string
}
type SiteWithDocuments = {
@@ -209,17 +212,23 @@ export function useSiteManagement() {
}
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')
link.href = doc.path
link.href = url
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(doc.path, '_blank')
window.open(url, '_blank')
}
const openPreview = (doc: SiteDocument) => {
@@ -266,10 +275,10 @@ export function useSiteManagement() {
if (result.success) {
showSuccess(`Site "${site.name}" supprimé avec succès`)
} else {
showError(`Erreur lors de la suppression: ${result.error}`)
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
}
} catch (error: any) {
showError(`Erreur lors de la suppression: ${error.message}`)
showError(`Impossible de supprimer le site : ${humanizeError(error.message)}`)
}
}

View File

@@ -13,8 +13,19 @@ const toasts = ref<Toast[]>([])
const MAX_TOASTS = 3
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() {
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 toast: Toast = {
id,

View File

@@ -9,8 +9,24 @@
<section class="card border border-base-200 bg-base-100 shadow-sm">
<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">
<DataTable
: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">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
@@ -22,7 +38,7 @@
id="activity-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@change="table.handleFilterChange"
>
<option value="">Tous</option>
<option value="piece">Pièce</option>
@@ -42,7 +58,7 @@
id="activity-action"
v-model="actionFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@change="table.handleFilterChange"
>
<option value="">Toutes</option>
<option value="create">Création</option>
@@ -50,157 +66,111 @@
<option value="delete">Suppression</option>
</select>
</div>
</template>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-per-page"
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
</template>
<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
</label>
<select
id="activity-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<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>
</div>
<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>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<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 type { ActivityLogEntry } from '~/composables/useActivityLog'
import { useDataTable } from '~/composables/useDataTable'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
import Pagination from '~/components/common/Pagination.vue'
const { entries, total, loading, loadActivityLog } = useActivityLog()
const currentPage = ref(1)
const itemsPerPage = ref(50)
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
const table = useDataTable(
{ fetchData: fetchLog },
{
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 50,
persistToUrl: true,
extraParams: {
entityType: { default: '' },
action: { default: '' },
},
},
)
const entityTypeFilter = ref('')
const actionFilter = ref('')
const entityTypeFilter = table.filters.entityType as Ref<string>
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>())
@@ -209,28 +179,18 @@ const toggleExpanded = (id: string) => {
else expandedIds.add(id)
}
const hasDiff = (entry: ActivityLogEntry) =>
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
const canExpandRow = (row: any) =>
row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
const fetchLog = () => {
function fetchLog() {
loadActivityLog({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
entityType: entityTypeFilter.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> = {
piece: 'Pièce',
product: 'Produit',

View File

@@ -9,82 +9,66 @@
</button>
</div>
<div v-if="loading" class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg" />
</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>
<div v-else-if="profiles.length" class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Nom</th>
<th>Email</th>
<th>Role</th>
<th>Mot de passe</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in profiles" :key="profile.id">
<td class="font-medium">
{{ profile.firstName }} {{ profile.lastName }}
</td>
<td class="text-sm text-base-content/70">
{{ profile.email || '-' }}
</td>
<td>
<select
class="select select-bordered select-xs"
:value="primaryRole(profile)"
@change="handleRoleChange(profile.id, $event.target.value)"
>
<option value="ROLE_ADMIN">
Admin
</option>
<option value="ROLE_GESTIONNAIRE">
Gestionnaire
</option>
<option value="ROLE_VIEWER">
Viewer
</option>
</select>
</td>
<td>
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
<span v-else class="badge badge-ghost badge-sm">Non</span>
<button
class="btn btn-ghost btn-xs ml-1"
@click="openPasswordDialog(profile.id)"
>
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
</button>
</td>
<td>
<span
class="badge badge-sm"
:class="profile.isActive ? 'badge-success' : 'badge-error'"
>
{{ profile.isActive ? 'Actif' : 'Inactif' }}
</span>
</td>
<td>
<button
v-if="profile.isActive"
class="btn btn-ghost btn-xs text-error"
@click="handleDeactivate(profile.id)"
>
Desactiver
</button>
</td>
</tr>
</tbody>
</table>
</div>
<template #cell-email="{ row }">
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
</template>
<div v-else class="text-center py-12 text-base-content/60">
Aucun profil.
</div>
<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">
@@ -112,15 +96,9 @@
<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>
<option value="ROLE_ADMIN">Admin</option>
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
<option value="ROLE_VIEWER">Viewer</option>
</select>
</div>
<div class="modal-action">
@@ -173,11 +151,55 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
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)
@@ -209,7 +231,8 @@ const handleCreate = async () => {
await createProfile(data)
showCreateDialog.value = false
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
} finally {
}
finally {
creating.value = false
}
}
@@ -230,7 +253,8 @@ const handleSetPassword = async () => {
try {
await setPassword(passwordProfileId.value, newPassword.value)
showPasswordDialog.value = false
} finally {
}
finally {
settingPassword.value = false
}
}
@@ -239,7 +263,8 @@ const handleDeactivate = async (profileId) => {
await deactivateProfile(profileId)
}
onMounted(() => {
fetchAll()
onMounted(async () => {
await fetchAll()
loaded.value = true
})
</script>

View File

@@ -69,6 +69,37 @@ const badgeClass = (type: ChangeType) => {
}
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',
@@ -171,7 +202,7 @@ const releases: Release[] = [
{ 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 JWT' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
{ type: 'feat', text: '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' },

View File

@@ -11,9 +11,22 @@
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<!-- Filtres -->
<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">
<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"
@@ -25,17 +38,11 @@
id="comment-status"
v-model="statusFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@change="table.handleFilterChange"
>
<option value="open">
Ouverts
</option>
<option value="resolved">
Résolus
</option>
<option value="">
Tous
</option>
<option value="open">Ouverts</option>
<option value="resolved">Résolus</option>
<option value="">Tous</option>
</select>
</div>
@@ -50,186 +57,84 @@
id="comment-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@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>
<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>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="comment-per-page"
>
Par page
</label>
<select
id="comment-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">
20
</option>
<option :value="50">
50
</option>
<option :value="100">
100
</option>
</select>
</div>
</div>
<template #cell-content="{ row }">
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
</template>
<p class="text-xs text-base-content/50 lg:text-right">
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<!-- Loading -->
<div v-if="loadingList" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<!-- Empty states -->
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
Aucun commentaire trouvé.
</p>
<!-- Table -->
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Contenu</th>
<th>Type</th>
<th>Item</th>
<th>Auteur</th>
<th>Date</th>
<th>Statut</th>
<th v-if="canEdit">
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="comment in comments"
:key="comment.id"
class="hover"
>
<td class="max-w-xs">
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
</td>
<td>
<span class="badge badge-outline badge-sm">
{{ entityTypeLabel(comment.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="getEntityRoute(comment)"
:to="getEntityRoute(comment)!"
class="link link-primary text-sm font-medium"
>
{{ comment.entityName || comment.entityId }}
</NuxtLink>
<span v-else class="text-sm">
{{ comment.entityName || comment.entityId }}
</span>
</td>
<td class="text-sm">
{{ comment.authorName }}
</td>
<td class="text-sm whitespace-nowrap">
{{ formatCommentDate(comment.createdAt) }}
</td>
<td>
<span
class="badge badge-sm"
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
>
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
</span>
</td>
<td v-if="canEdit" @click.stop>
<button
v-if="comment.status === 'open'"
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(comment.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<span v-else class="text-xs text-base-content/50">
{{ comment.resolvedByName }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
<button
class="btn btn-sm"
:disabled="page <= 1"
@click="goToPage(page - 1)"
>
Précédent
</button>
<span class="flex items-center text-sm text-base-content/70">
Page {{ page }} / {{ totalPages }}
<template #cell-entityType="{ row }">
<span class="badge badge-outline badge-sm">
{{ entityTypeLabel(row.entityType) }}
</span>
<button
class="btn btn-sm"
:disabled="page >= totalPages"
@click="goToPage(page + 1)"
</template>
<template #cell-entity="{ row }">
<NuxtLink
v-if="getEntityRoute(row)"
:to="getEntityRoute(row)!"
class="link link-primary text-sm font-medium"
>
Suivant
{{ row.entityName || row.entityId }}
</NuxtLink>
<span v-else class="text-sm">
{{ row.entityName || row.entityId }}
</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatCommentDate(row.createdAt) }}</span>
</template>
<template #cell-status="{ row }">
<span
class="badge badge-sm"
:class="row.status === 'open' ? 'badge-warning' : 'badge-success'"
>
{{ row.status === 'open' ? 'Ouvert' : 'Résolu' }}
</span>
</template>
<template v-if="canEdit" #cell-actions="{ row }">
<button
v-if="row.status === 'open'"
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(row.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
</div>
</template>
<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 } from 'vue'
import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComments, type Comment } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check'
const { canEdit } = usePermissions()
@@ -241,16 +146,43 @@ const {
const comments = ref<Comment[]>([])
const total = ref(0)
const page = ref(1)
const itemsPerPage = ref(20)
const statusFilter = ref('open')
const entityTypeFilter = ref('')
const loadingList = ref(false)
const loadingList = ref(true)
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
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',
@@ -277,13 +209,16 @@ const formatCommentDate = (dateStr: string): string => {
}).format(date)
}
const loadComments = async () => {
async function loadComments() {
loadingList.value = true
const result = await fetchAllComments({
status: statusFilter.value || undefined,
entityType: entityTypeFilter.value || undefined,
page: page.value,
itemsPerPage: itemsPerPage.value,
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 ?? []
@@ -292,16 +227,6 @@ const loadComments = async () => {
loadingList.value = false
}
const handleFilterChange = () => {
page.value = 1
loadComments()
}
const goToPage = (p: number) => {
page.value = p
loadComments()
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {

View File

@@ -26,164 +26,107 @@
</p>
</header>
<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">
<DataTable
: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">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="debouncedSearch"
@input="table.debouncedSearch"
/>
</label>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="component-catalog-sort"
>
Trier par
</label>
<select
id="component-catalog-sort"
v-model="sortField"
class="select select-bordered select-sm"
@change="handleSortChange"
>
<option value="name">Nom</option>
<option value="createdAt">Date de création</option>
</select>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.component)"
:alt="resolvePreviewAlt(row.component)"
/>
</template>
<template #cell-name="{ row }">
{{ row.component.name || 'Composant sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.component.reference || '—' }}
</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>
<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">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="component-catalog-dir"
<NuxtLink
:to="`/component/${row.component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Ordre
</label>
<select
id="component-catalog-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
@change="handleSortChange"
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
Supprimer
</button>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="component-catalog-per-page"
>
Par page
</label>
<select
id="component-catalog-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handlePerPageChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loadingComposants" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!composantsTotal" class="text-sm text-base-content/70">
Aucun composant n'a encore été créé.
</p>
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
Aucun composant ne correspond à votre recherche.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-24">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Type de composant</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="component in composantsList" :key="component.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(component)"
:alt="resolvePreviewAlt(component)"
/>
</td>
<td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td>
<td>
<NuxtLink
v-if="component.typeComposant?.id"
:to="`/component-category/${component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(component) }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/component/${component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingComposants"
@click="handleDeleteComponent(component)"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { useUrlState } from '~/composables/useUrlState'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
@@ -192,175 +135,110 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value)
// State synced with URL query params (preserved on back/forward navigation)
const {
page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchComposants(),
})
const table = useDataTable(
{ fetchData: fetchComposants },
{ 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)
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' },
]
// Search debounce for API calls
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const composantsOnPage = computed(() => componentRows.value.length)
const paginationState = table.pagination(total, composantsOnPage)
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchComposants()
}, 300)
}
// Enrichir les composants avec les types de composants complets
// Enrich composants with full type data
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
return {
...composant,
typeComposant: typeComposant || composant.typeComposant || null
}
return { ...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({
search: searchTerm.value,
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value as 'asc' | 'desc',
force: true
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.typeComposant || undefined,
force: true,
})
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchComposants()
}
const handleSortChange = () => {
currentPage.value = 1
fetchComposants()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchComposants()
}
const resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : []
if (!documents.length) {
return null
}
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path)
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
const image = withPath.find((doc) => isImageDocument(doc))
if (image) {
return image
}
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (component: Record<string, any>) => {
const parts = [component?.name, component?.reference].filter(Boolean)
if (parts.length) {
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolveComponentType = (component: Record<string, any>) => {
const type = component?.typeComposant
if (type?.name) {
return type.name
}
if (component?.typeComposantLabel) {
return component.typeComposantLabel
}
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const resolveDeleteGuard = (component: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks)
? component.machineLinks.length
: component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents)
? component.documents.length
: component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues)
? component.customFieldValues.length
: component?.customFieldValuesCount ?? 0
if (machineLinks > 0) {
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
}
if (documents > 0) {
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
}
return {
blockingReasons,
hasCustomFields: customFields > 0,
}
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
if (blockingReasons.length) {
showError(
`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(
', ',
)}. Supprimez ou détachez ces éléments avant de réessayer.`
)
showError(`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const componentName = component?.name || 'ce composant'
const confirmLines = [
`Voulez-vous vraiment supprimer ${componentName} ?`,
]
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`]
if (hasCustomFields) {
confirmLines.push(
'Les valeurs de champs personnalisés associées seront également supprimées.'
)
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) {
return
}
if (!confirmed) return
await deleteComposant(component.id)
// Reload current page after deletion
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 () => {
await Promise.all([
fetchComposants(),
loadComponentTypes()
])
await Promise.all([fetchComposants(), loadComponentTypes()])
})
</script>

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<main class="container mx-auto px-6 py-10">
@@ -79,6 +80,19 @@
</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="form-control">
<label class="label">
@@ -373,8 +387,8 @@
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -625,6 +639,7 @@ const historyDiffEntries = (entry: ComponentHistoryEntry) =>
const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
@@ -805,6 +820,7 @@ watch(
selectedTypeId.value = resolvedTypeId
editionForm.name = currentComponent.name || ''
editionForm.description = currentComponent.description || ''
editionForm.reference = currentComponent.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent,
@@ -845,6 +861,7 @@ const submitEdition = async () => {
const payload: Record<string, any> = {
name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
}
const reference = editionForm.reference.trim()
@@ -870,7 +887,6 @@ const submitEdition = async () => {
updatedComponent.id,
[
updatedComponent?.typeComposant?.customFields,
updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -52,6 +52,19 @@
</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="form-control">
<label class="label">
@@ -357,6 +370,7 @@ import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
@@ -408,6 +422,7 @@ const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
@@ -889,6 +904,7 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.prix = ''
@@ -906,6 +922,11 @@ const submitCreation = async () => {
typeComposantId: selectedType.value.id,
}
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
@@ -978,7 +999,7 @@ const submitCreation = async () => {
toast.showError(result.error)
}
} 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 {
submitting.value = false
uploadingDocuments.value = false
@@ -1198,7 +1219,6 @@ const saveCustomFieldValues = async (createdComponent: any) => {
}
registerDefinitions(createdComponent?.typeComposant?.customFields)
registerDefinitions(createdComponent?.typeMachineComponentRequirement?.typeComposant?.customFields)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) {

View File

@@ -17,73 +17,48 @@
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div class="flex-1">
<label class="label"><span class="label-text">Recherche</span></label>
<input
v-model="searchTerm"
type="search"
class="input input-bordered w-full"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
>
</div>
<div class="md:w-1/3">
<label class="label"><span class="label-text">Tri</span></label>
<select v-model="sortKey" class="select select-bordered w-full">
<option value="name">
Nom
</option>
<option value="email">
Email
</option>
<option value="phone">
Téléphone
</option>
</select>
</div>
</div>
<DataTable
:columns="columns"
:rows="filteredConstructeurs"
:loading="loading"
:sort="currentSort"
:show-counter="false"
empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé."
@sort="handleSort"
>
<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="searchTerm"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
/>
</label>
</template>
<div v-if="loading" class="py-16 text-center text-sm text-gray-500">
<span class="loading loading-spinner loading-lg mb-2" />
Chargement des fournisseurs...
</div>
<template #cell-phone="{ row }">
{{ formatPhoneDisplay(row.phone) }}
</template>
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500">
Aucun fournisseur trouvé.
</div>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</template>
<div v-else class="overflow-x-auto">
<table class="table">
<thead>
<tr class="text-xs uppercase">
<th>Nom</th>
<th>Email</th>
<th>Téléphone</th>
<th class="text-right">
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
<td>{{ constructeur.name }}</td>
<td>{{ constructeur.email || '—' }}</td>
<td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)">
Supprimer
</button>
</div>
</template>
</DataTable>
</div>
</div>
@@ -118,6 +93,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
@@ -130,22 +106,46 @@ const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
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 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 saving = ref(false)
const editingConstructeur = ref(null)
const form = ref({ name: '', email: '', phone: '' })
const filteredConstructeurs = computed(() => {
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
const sorted = [...constructeurs.value].sort((a, b) => {
const key = sortKey.value
return (a[key] || '').localeCompare(b[key] || '')
if (key === 'createdAt') {
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 }
const term = searchTerm.value.toLowerCase()
return sorted.filter(item =>
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term))
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
)
})
@@ -153,6 +153,17 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 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 formatted = formatPhone(value)
if (formatted) {
@@ -161,7 +172,7 @@ const formatPhoneDisplay = (value) => {
return value || '—'
}
function debounce (fn, delay) {
function debounce(fn, delay) {
let timeout
return (...args) => {
clearTimeout(timeout)
@@ -184,7 +195,7 @@ const openEditModal = (constructeur) => {
form.value = {
name: constructeur.name,
email: constructeur.email || '',
phone: constructeur.phone || ''
phone: constructeur.phone || '',
}
modalOpen.value = true
}
@@ -197,7 +208,7 @@ const closeModal = () => {
const saveConstructeur = async () => {
const trimmedName = form.value.name.trim()
const duplicate = constructeurs.value.find(
(c) => c.name.toLowerCase() === trimmedName.toLowerCase()
c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id,
)
if (duplicate) {
@@ -212,7 +223,8 @@ const saveConstructeur = async () => {
let result
if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload)
} else {
}
else {
result = await createConstructeur(payload)
}
saving.value = false
@@ -234,6 +246,3 @@ const confirmDelete = async (constructeur) => {
onMounted(() => loadConstructeurs())
</script>
<style scoped>
</style>

View File

@@ -3,230 +3,193 @@
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="documentRows"
@close="closePreview"
/>
<section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-6">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="w-full md:w-2/3">
<label class="label">
<span class="label-text">Recherche</span>
<DataTable
:columns="columns"
:rows="documentRows"
: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>
<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">
<label class="label">
<span class="label-text">Filtrer par rattachement</span>
</label>
<select v-model="attachmentFilter" class="select select-bordered w-full">
<option value="all">
Tous
</option>
<option value="site">
Sites
</option>
<option value="machine">
Machines
</option>
<option value="composant">
Composants
</option>
<option value="piece">
Pièces
</option>
</select>
</div>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-filter"
>
Rattachement
</label>
<select
id="doc-filter"
v-model="attachmentFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="all">Tous</option>
<option value="site">Sites</option>
<option value="machine">Machines</option>
<option value="composant">Composants</option>
<option value="piece">Pi&egrave;ces</option>
<option value="product">Produits</option>
</select>
</div>
</template>
<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">
<span class="loading loading-spinner loading-lg mb-3" />
Chargement des documents...
</div>
<template #cell-mimeType="{ row }">
{{ row.mimeType || 'Inconnu' }}
</template>
<div v-else-if="filteredDocuments.length === 0" class="text-center py-16 text-sm text-gray-500">
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
Aucun document ne correspond à votre recherche pour l'instant.
</div>
<template #cell-size="{ row }">
{{ formatSize(row.size) }}
</template>
<div v-else class="overflow-x-auto">
<table class="table">
<thead>
<tr class="text-xs uppercase">
<th>Nom</th>
<th>Type</th>
<th>Taille</th>
<th>Rattaché à</th>
<th>Date</th>
<th class="text-right">
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="document in filteredDocuments" :key="document.id" class="text-sm">
<td>
<div class="flex items-center gap-3">
<span class="text-xl" :class="documentIcon(document).colorClass">
<component
:is="documentIcon(document).component"
class="h-6 w-6"
aria-hidden="true"
/>
</span>
<div>
<div class="font-semibold">
{{ document.name }}
</div>
<div class="text-xs text-gray-500">
{{ document.filename }}
</div>
</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>
<template #cell-attachment="{ row }">
<div class="flex flex-col text-xs">
<span v-if="row.site">Site &middot; {{ row.site.name }}</span>
<span v-else-if="row.machine">Machine &middot; {{ row.machine.name }}</span>
<span v-else-if="row.composant">Composant &middot; {{ row.composant.name }}</span>
<span v-else-if="row.piece">Pi&egrave;ce &middot; {{ row.piece.name }}</span>
<span v-else-if="row.product">Produit &middot; {{ row.product.name }}</span>
<span v-else class="text-gray-400">Non d&eacute;fini</span>
</div>
</template>
<template #cell-createdAt="{ row }">
{{ formatFrenchDate(row.createdAt) }}
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<button
class="btn btn-ghost btn-xs"
type="button"
:disabled="!canPreviewDocument(row)"
:title="canPreviewDocument(row) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
@click="openPreview(row)"
>
Consulter
</button>
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(row)">
T&eacute;l&eacute;charger
</button>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
<script setup lang="ts">
import { computed, onMounted, ref, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useDocuments } from '~/composables/useDocuments'
import { useApi } from '~/composables/useApi'
import { useUrlState } from '~/composables/useUrlState'
import { useDataTable } from '~/composables/useDataTable'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatFrenchDate } from '~/utils/date'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideFileSearch from '~icons/lucide/file-search'
const { documents, loading, loadDocuments } = useDocuments()
const { get } = useApi()
const { documents, total, loading, loadDocuments } = useDocuments()
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
q: { default: '', debounce: 300 },
filter: { default: 'all' },
})
const previewDocument = ref(null)
const table = useDataTable(
{ fetchData: fetchDocuments },
{
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)
onMounted(() => {
loadDocuments({ itemsPerPage: 200 })
})
const documentRows = computed(() => documents.value)
const documentsOnPage = computed(() => documents.value.length)
const paginationState = table.pagination(total, documentsOnPage)
const filteredDocuments = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const filter = attachmentFilter.value
const columns = [
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
{ 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) => {
const matchesFilter =
filter === 'all' ||
(filter === 'site' && document.site) ||
(filter === 'machine' && document.machine) ||
(filter === 'composant' && document.composant) ||
(filter === 'piece' && document.piece)
if (!matchesFilter) { return false }
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))
async function fetchDocuments() {
await loadDocuments({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
attachmentFilter: attachmentFilter.value,
force: true,
})
})
}
const formatSize = (size) => {
if (size === undefined || size === null) { return '—' }
if (size === 0) { return '0 B' }
const formatSize = (size: number | undefined | null) => {
if (size === undefined || size === null) return '\u2014'
if (size === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
/** Fetch the full document (with path) from the API on demand. */
const fetchDocumentPath = async (doc) => {
if (doc?.path) { return doc.path }
if (!doc?.id) { return null }
const result = await get(`/documents/${doc.id}`)
if (result.success && result.data?.path) {
doc.path = result.data.path
return result.data.path
}
return null
const downloadDocument = (doc: any) => {
if (doc?.downloadUrl) window.open(doc.downloadUrl, '_blank')
}
const downloadDocument = async (doc) => {
const path = await fetchDocumentPath(doc)
if (!path) { return }
if (path.startsWith('data:')) {
const link = document.createElement('a')
link.href = path
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(path, '_blank')
}
const openPreview = async (doc) => {
if (!canPreviewDocument(doc)) { return }
await fetchDocumentPath(doc)
const openPreview = (doc: any) => {
if (!canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
@@ -235,4 +198,8 @@ const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
onMounted(() => {
fetchDocuments()
})
</script>

View File

@@ -49,35 +49,18 @@
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<span class="label-text">Site</span>
</label>
<select v-model="selectedType" class="select select-bordered">
<select v-model="selectedSiteFilter" class="select select-bordered">
<option value="">
Tous les types
Tous les sites
</option>
<option
v-for="type in machineTypes"
:key="type.id"
:value="type.id"
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ 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 }}
{{ site.name }}
</option>
</select>
</div>
@@ -208,27 +191,9 @@
<h4 class="font-semibold text-sm">
{{ machine.name }}
</h4>
<div
class="badge badge-sm"
:class="
getCategoryBadgeClass(machine.typeMachine?.category)
"
>
{{ machine.typeMachine?.category || "N/A" }}
</div>
</div>
<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
v-if="machine.reference"
class="flex items-center gap-1"
@@ -373,78 +338,17 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
</label>
<select
v-model="newMachine.typeMachineId"
class="select select-bordered"
:disabled="!canEdit"
required
>
<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"
:disabled="!canEdit"
>
</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 class="form-control mb-4">
<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"
:disabled="!canEdit"
>
</div>
<div class="modal-action">
@@ -469,31 +373,28 @@
import { ref, reactive, onMounted, computed } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideUser from '~icons/lucide/user'
import IconLucidePhone from '~icons/lucide/phone'
import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false)
const searchTerm = ref('')
const selectedType = ref('')
const selectedCategory = ref('')
const selectedSiteFilter = ref('')
const collapsedSites = ref([])
const newSite = reactive({
@@ -508,48 +409,14 @@ const newSite = reactive({
const newMachine = reactive({
name: '',
siteId: '',
typeMachineId: '',
reference: ''
})
// 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 map = new Map()
machinesWithType.value.forEach((machine) => {
machines.value.forEach((machine) => {
const siteId = machine.siteId || extractRelationId(machine.site)
if (!siteId) { return }
@@ -587,6 +454,11 @@ const formatPhoneDisplay = (value) => {
const filteredSites = computed(() => {
let filtered = sitesWithMachines.value
// Filtrer par site
if (selectedSiteFilter.value) {
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
}
// Filtrer par terme de recherche
if (searchTerm.value) {
filtered = filtered.filter((site) => {
@@ -615,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
})
@@ -669,27 +514,15 @@ const handleCreateSite = async () => {
}
const handleCreateMachine = async () => {
if (!selectedMachineType.value) {
console.error('Aucun type de machine sélectionné')
return
}
const machineData = {
const result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference
}
const result = await createMachineFromType(
machineData,
selectedMachineType.value
)
})
if (result.success) {
// Reset form
newMachine.name = ''
newMachine.siteId = ''
newMachine.typeMachineId = ''
newMachine.reference = ''
showAddMachineModal.value = false
await loadMachines()
@@ -731,10 +564,10 @@ const confirmDeleteMachine = async (machine) => {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
await loadMachines()
} else {
showError(`Erreur lors de la suppression: ${result.error}`)
showError(`Impossible de supprimer la machine : ${result.error}`)
}
} catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`)
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
}
}
}
@@ -744,19 +577,8 @@ const addMachineToSite = (site) => {
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
onMounted(async () => {
await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
await Promise.all([loadSites(), loadMachines()])
})
</script>

View File

@@ -1,163 +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
v-if="canEdit"
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 { canEdit } = usePermissions();
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>

View File

@@ -1,265 +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>
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
<TypeEditForm
:key="formKey"
v-model="draftType"
:saving="!canEdit || creating"
:resettable="false"
submit-label="Créer le type"
submit-loading-label="Création..."
@submit="handleSubmit"
/>
</div>
</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 { canEdit } = usePermissions()
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>

View File

@@ -10,123 +10,127 @@
<DocumentPreviewModal
:document="d.previewDocument.value"
:visible="d.previewVisible.value"
:documents="d.machineDocumentsList.value"
@close="d.closePreview"
/>
<!-- Header with actions -->
<MachineDetailHeader
:title="machineViewTitle"
:is-details-view="s.isDetailsView.value"
:is-skeleton-view="s.isSkeletonView.value"
:is-edit-mode="d.isEditMode.value"
:has-skeleton-requirements="d.machineHasSkeletonRequirements.value"
@change-view="s.changeMachineView"
@toggle-edit="d.toggleEditMode"
@open-print="d.openPrintModal"
/>
<template v-if="s.isDetailsView.value">
<!-- Debug info -->
<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>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Debug info -->
<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>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description || d.machine.value.typeMachine?.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div v-if="d.machine.value.typeMachine?.category" class="badge badge-outline">
{{ d.machine.value.typeMachine?.category }}
</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>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<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>
</PageHero>
<!-- Machine Info Card -->
<MachineInfoCard
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"
@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" />
<!-- 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"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</template>
</PageHero>
<template v-else>
<MachineSkeletonSummary
:component-requirement-groups="d.componentRequirementGroups.value"
:piece-requirement-groups="d.pieceRequirementGroups.value"
:product-requirement-groups="d.productRequirementGroups.value"
<!-- Machine Info Card -->
<MachineInfoCard
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"
@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
/>
</template>
</div>
</div>
<!-- Error State -->
@@ -155,10 +159,9 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useMachineSkeletonEditor } from '~/composables/useMachineSkeletonEditor'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
@@ -168,7 +171,7 @@ import MachineDocumentsCard from '~/components/machine/MachineDocumentsCard.vue'
import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.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'
const route = useRoute()
@@ -181,41 +184,25 @@ if (!machineId) {
const d = useMachineDetailData(machineId)
const s = useMachineSkeletonEditor({
machine: d.machine,
components: d.components,
pieces: d.pieces,
machineComponentLinks: d.machineComponentLinks,
machinePieceLinks: d.machinePieceLinks,
machineProductLinks: d.machineProductLinks,
machineType: d.machineType,
machineHasSkeletonRequirements: d.machineHasSkeletonRequirements,
componentRequirements: d.componentRequirements,
pieceRequirements: d.pieceRequirements,
productRequirements: d.productRequirements,
componentTypeLabelMap: d.componentTypeLabelMap,
pieceTypeLabelMap: d.pieceTypeLabelMap,
productInventory: d.productInventory,
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 addModalOpen = ref(false)
const addModalKind = ref('component')
const openAddModal = (kind) => {
addModalKind.value = kind
addModalOpen.value = true
}
const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
} else {
await d.addProductLink(entityId)
}
}
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'
})

View File

@@ -13,7 +13,7 @@
<div class="card bg-base-100 shadow-lg mb-6">
<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">
<label class="label">
<span class="label-text">Site</span>
@@ -29,29 +29,14 @@
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<span class="label-text">Recherche</span>
</label>
<select v-model="selectedType" class="select select-bordered">
<option value="">
Tous les types
</option>
<option v-for="type in machineTypes" :key="type.id" :value="type.id">
{{ 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>
<input
v-model="searchQuery"
type="text"
placeholder="Rechercher par nom ou référence..."
class="input input-bordered"
>
</div>
</div>
</div>
@@ -88,26 +73,14 @@
<h3 class="card-title text-lg">
{{ machine.name }}
</h3>
<div class="badge badge-primary badge-sm">
{{ machine.typeMachine?.category || 'N/A' }}
</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="flex items-center gap-2">
<IconLucideMapPin class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.site?.name || 'Site inconnu' }}</span>
</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">
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.reference }}</span>
@@ -136,43 +109,28 @@
import { ref, computed, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
const { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const toast = useToast()
const selectedSite = ref('')
const selectedType = ref('')
const selectedCategory = ref('')
const searchQuery = ref('')
const categories = computed(() => {
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
// Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => {
return machines.value.map((machine) => {
const site = sites.value.find(s => s.id === machine.siteId)
const typeMachine = machineTypes.value.find(t => t.id === machine.typeMachineId)
return {
...machine,
site: site || null,
typeMachine: typeMachine || null
}
})
})
@@ -184,12 +142,12 @@ const filteredMachines = computed(() => {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
}
if (selectedType.value) {
filtered = filtered.filter(machine => machine.typeMachineId === selectedType.value)
}
if (selectedCategory.value) {
filtered = filtered.filter(machine => machine.typeMachine?.category === selectedCategory.value)
if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine =>
machine.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term),
)
}
return filtered
@@ -214,10 +172,10 @@ const confirmDeleteMachine = async (machine) => {
if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
} else {
showError(`Erreur lors de la suppression: ${result.error}`)
showError(`Impossible de supprimer la machine : ${result.error}`)
}
} catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`)
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
}
}
}
@@ -226,7 +184,6 @@ onMounted(async () => {
await Promise.all([
loadMachines(),
loadSites(),
loadMachineTypes()
])
})
</script>

View File

@@ -1,13 +1,13 @@
<template>
<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>
<h1 class="text-2xl font-bold">
Nouvelle machine
</h1>
<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>
</div>
<NuxtLink to="/machines" class="btn btn-ghost">
@@ -15,21 +15,25 @@
</NuxtLink>
</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-body space-y-6">
<!-- Basic fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<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>
<input
id="machine-field-name"
v-model="c.newMachine.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
required
>
@@ -37,9 +41,15 @@
<div class="form-control">
<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>
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" :disabled="!canEdit" 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="">
Sélectionner un site
</option>
@@ -52,129 +62,43 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<label class="label" for="machine-field-reference">
<span class="label-text">Référence</span>
</label>
<SearchSelect
v-model="c.newMachine.typeMachineId"
:options="c.machineTypes"
:loading="c.machineTypesLoading"
<input
id="machine-field-reference"
v-model="c.newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
placeholder="Rechercher un type…"
empty-text="Aucun type trouvé"
:option-label="c.machineTypeLabel"
:option-description="c.machineTypeDescription"
/>
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
<span class="label-text">Cloner depuis une machine</span>
</label>
<input
v-model="c.newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
<SearchSelect
v-model="c.newMachine.cloneFromMachineId"
:options="c.machines"
:disabled="!canEdit"
>
:clearable="true"
placeholder="Rechercher une machine..."
empty-text="Aucune machine trouvée"
/>
</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 -->
<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
</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="!canEdit || !c.canCreateMachine || c.submitting"
class="btn btn-sm md:btn-md btn-primary"
:disabled="!canEdit || !c.newMachine.name?.trim() || c.submitting"
:class="{ loading: c.submitting }"
>
Créer la machine
@@ -191,10 +115,6 @@
import { proxyRefs } from 'vue'
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
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 { canEdit } = usePermissions()

View File

@@ -16,6 +16,7 @@
</NuxtLink>
</div>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-2">
@@ -25,187 +26,130 @@
</p>
</header>
<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">
<DataTable
: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">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="debouncedSearch"
@input="table.debouncedSearch"
/>
</label>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="piece-catalog-sort"
>
Trier par
</label>
<select
id="piece-catalog-sort"
v-model="sortField"
class="select select-bordered select-sm"
@change="handleSortChange"
>
<option value="name">Nom</option>
<option value="createdAt">Date de création</option>
</select>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</template>
<template #cell-name="{ row }">
{{ row.piece.name || 'Pièce sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.piece.reference || '—' }}
</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 class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="piece-catalog-dir"
<span v-else>—</span>
</template>
<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
</label>
<select
id="piece-catalog-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
@change="handleSortChange"
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
+{{ row.suppliers.overflow }}
</span>
</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">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="piece-catalog-per-page"
<NuxtLink
:to="`/pieces/${row.piece.id}/edit`"
class="btn btn-ghost btn-xs"
>
Par page
</label>
<select
id="piece-catalog-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handlePerPageChange"
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
Supprimer
</button>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loadingPieces" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!piecesTotal" class="text-sm text-base-content/70">
Aucune pièce n'a encore été créée.
</p>
<p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
Aucune pièce ne correspond à votre recherche.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-24">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Fournisseurs</th>
<th>Type de pièce</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="row in pieceRows" :key="row.piece.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</td>
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
<td>{{ row.piece.reference || '—' }}</td>
<td>
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else></span>
</td>
<td>
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/pieces/${row.piece.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import { useUrlState } from '~/composables/useUrlState'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
@@ -214,114 +158,73 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value)
// State synced with URL query params (preserved on back/forward navigation)
const {
page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchPieces(),
})
const table = useDataTable(
{ fetchData: fetchPieces },
{ 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)
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' },
]
// Search debounce for API calls
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const piecesOnPage = computed(() => pieceRows.value.length)
const paginationState = table.pagination(total, piecesOnPage)
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchPieces()
}, 300)
}
// Enrichir les pièces avec les types de pièces complets
// Enrich pieces with full type data
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return {
...piece,
typePiece: typePiece || piece.typePiece || null
}
return { ...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({
search: searchTerm.value,
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value as 'asc' | 'desc',
force: true
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.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 documents = Array.isArray(piece?.documents) ? piece.documents : []
if (!documents.length) {
return null
}
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path)
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
const image = withPath.find((doc) => isImageDocument(doc))
if (image) {
return image
}
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (piece: Record<string, any>) => {
const parts = [piece?.name, piece?.reference].filter(Boolean)
if (parts.length) {
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolvePieceType = (piece: Record<string, any>) => {
const type = piece?.typePiece
if (type?.name) {
return type.name
}
if (piece?.typePieceLabel) {
return piece.typePieceLabel
}
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
@@ -332,61 +235,36 @@ const resolvePieceSuppliers = (piece: Record<string, any>) => {
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') {
return
}
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) {
return
}
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) {
return
}
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) {
return
}
if (Array.isArray(value)) {
value.forEach(collectConstructeurs)
return
}
if (typeof value === 'string') {
pushName(value)
return
}
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) {
collectConstructeurs(record.constructeur)
}
if (Array.isArray(record?.constructeurs)) {
collectConstructeurs(record.constructeurs)
}
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') {
return
}
value
.split(/[,;\\/•·|]+/)
.map((part) => part.trim())
.filter(Boolean)
.forEach(pushName)
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(piece?.constructeurs)
collectConstructeurs(piece?.constructeur)
collectConstructeurs(piece?.product?.constructeurs)
collectConstructeurs(piece?.product?.constructeur)
collectFromLabel(piece?.constructeursLabel)
collectFromLabel(piece?.supplierLabel)
collectFromLabel(piece?.product?.constructeursLabel)
@@ -399,83 +277,45 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
const suppliers = resolvePieceSuppliers(piece)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return {
suppliers,
visible,
overflow,
tooltip: suppliers.length ? suppliers.join(', ') : '',
}
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}
const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks)
? piece.machineLinks.length
: piece?.machineLinksCount ?? 0
const documents = Array.isArray(piece?.documents)
? piece.documents.length
: piece?.documentsCount ?? 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 machineLinks = Array.isArray(piece?.machineLinks) ? piece.machineLinks.length : piece?.machineLinksCount ?? 0
const documents = Array.isArray(piece?.documents) ? piece.documents.length : piece?.documentsCount ?? 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 { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
if (blockingReasons.length) {
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.`
)
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.`)
return
}
const pieceName = piece?.name || 'cette pièce'
const confirmLines = [
`Voulez-vous vraiment supprimer ${pieceName} ?`,
]
const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`]
if (hasCustomFields) {
confirmLines.push(
'Les valeurs de champs personnalisés associées seront également supprimées.'
)
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) {
return
}
if (!confirmed) return
await deletePiece(piece.id)
// Reload current page after deletion
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 () => {
await Promise.all([
fetchPieces(),
loadPieceTypes()
])
await Promise.all([fetchPieces(), loadPieceTypes()])
})
</script>

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<main class="container mx-auto px-6 py-10">
@@ -79,6 +80,19 @@
</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="form-control">
<label class="label">
@@ -320,8 +334,8 @@
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -568,6 +582,7 @@ const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
@@ -823,6 +838,7 @@ watch(
selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece,
@@ -895,6 +911,7 @@ const submitEdition = async () => {
const payload: Record<string, any> = {
name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
constructeurIds,
}
@@ -928,7 +945,6 @@ const submitEdition = async () => {
updatedPiece.id,
[
updatedPiece?.typePiece?.pieceCustomFields,
updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -52,6 +52,19 @@
</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="form-control">
<label class="label">
@@ -302,6 +315,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -336,6 +350,7 @@ const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
@@ -490,6 +505,7 @@ const canSubmit = computed(() =>
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.prix = ''
@@ -513,6 +529,11 @@ const submitCreation = async () => {
typePieceId: selectedType.value.id,
}
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
@@ -552,7 +573,6 @@ const submitCreation = async () => {
createdPiece.id,
[
createdPiece?.typePiece?.pieceCustomFields,
createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
@@ -579,7 +599,7 @@ const submitCreation = async () => {
toast.showError(result.error)
}
} 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 {
submitting.value = false
uploadingDocuments.value = false

View File

@@ -19,51 +19,8 @@
<section class="card border border-base-200 bg-base-100 shadow-sm">
<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
v-else-if="errorMessage"
v-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
@@ -75,96 +32,107 @@
</button>
</div>
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
Chargement du catalogue…
</p>
<DataTable
v-else
: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">
Aucun produit n'a encore été enregistré.
</p>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
Aucun produit ne correspond à votre recherche.
</p>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.product.name }}</span>
</template>
<div v-else class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-16">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Type de produit</th>
<th>Fournisseurs</th>
<th class="text-right">Prix indicatif</th>
<th class="w-32 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="row in productRows" :key="row.product.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product)"
:alt="resolvePreviewAlt(row.product)"
/>
</td>
<td class="font-medium">{{ row.product.name }}</td>
<td>{{ row.product.reference || '—' }}</td>
<td>
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '' }}</span>
</td>
<td>
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
: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 class="text-sm text-base-content/50"></span>
</td>
<td class="text-right">
{{ formatPrice(row.product.supplierPrice) }}
</td>
<td class="text-right space-x-2">
<NuxtLink
:to="`/product/${row.product.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
<template #cell-reference="{ row }">
{{ row.product.reference || '—' }}
</template>
<template #cell-typeProduct="{ row }">
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
: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 class="text-sm text-base-content/50">—</span>
</template>
<template #cell-price="{ row }">
{{ formatPrice(row.product.supplierPrice) }}
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<NuxtLink
:to="`/product/${row.product.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
</div>
</template>
</DataTable>
</div>
</section>
</main>
@@ -173,24 +141,22 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useHead } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useUrlState } from '~/composables/useUrlState'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Catalogue des produits',
}))
useHead(() => ({ title: 'Catalogue des produits' }))
const {
products,
total,
loading,
loaded,
error,
loadProducts,
deleteProduct,
@@ -198,65 +164,54 @@ const {
const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
})
const table = useDataTable(
{ fetchData: fetchProducts },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
)
// 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(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return {
...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
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
})
})
const filteredCount = computed(() => filteredProducts.value.length)
const totalCount = computed(() => {
const reported = Number(total.value)
if (!Number.isFinite(reported) || reported < 0) {
return normalizedProducts.value.length
}
return reported
})
const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildSuppliersDisplay(product),
})),
)
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', {
style: 'currency',
@@ -265,14 +220,9 @@ const priceFormatter = new Intl.NumberFormat('fr-FR', {
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') {
return '—'
}
if (value === null || value === undefined || value === '') return '—'
const number = Number(value)
if (Number.isNaN(number)) {
return '—'
}
return priceFormatter.format(number)
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const MAX_VISIBLE_SUPPLIERS = 3
@@ -282,59 +232,34 @@ const resolveProductSuppliers = (product: Record<string, any>) => {
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') {
return
}
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) {
return
}
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) {
return
}
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) {
return
}
if (Array.isArray(value)) {
value.forEach(collectConstructeurs)
return
}
if (typeof value === 'string') {
pushName(value)
return
}
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) {
collectConstructeurs(record.constructeur)
}
if (Array.isArray(record?.constructeurs)) {
collectConstructeurs(record.constructeurs)
}
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') {
return
}
value
.split(/[,;\\/•·|]+/)
.map((part) => part.trim())
.filter(Boolean)
.forEach(pushName)
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(product?.constructeurs)
collectConstructeurs(product?.constructeur)
collectFromLabel(product?.constructeursLabel)
collectFromLabel(product?.supplierLabel)
collectFromLabel(product?.suppliers)
@@ -346,53 +271,28 @@ const buildSuppliersDisplay = (product: Record<string, any>) => {
const suppliers = resolveProductSuppliers(product)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return {
suppliers,
visible,
overflow,
tooltip: suppliers.length ? suppliers.join(', ') : '',
}
return { 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 documents = Array.isArray(product?.documents) ? product.documents : []
if (!documents.length) {
return null
}
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path)
if (!withPath.length) {
return normalized[0] ?? null
}
const images = withPath.filter((doc) => isImageDocument(doc))
if (images.length) {
return images[0]
}
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
if (!withPath.length) return normalized[0] ?? null
const images = withPath.filter((doc: any) => isImageDocument(doc))
if (images.length) return images[0]
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
return withPath[0]
}
const resolvePreviewAlt = (product: Record<string, any>) => {
const parts = [product?.name, product?.reference].filter(Boolean)
if (parts.length) {
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const reload = async () => {
await loadProducts({ itemsPerPage: 200, force: true })
}
const reload = () => fetchProducts()
const { confirm } = useConfirm()
@@ -400,10 +300,7 @@ const confirmDelete = async (product: Record<string, any>) => {
const confirmed = await confirm({
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
})
if (!confirmed) {
return
}
if (!confirmed) return
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${product.name}" supprimé`)
@@ -411,9 +308,6 @@ const confirmDelete = async (product: Record<string, any>) => {
}
onMounted(async () => {
await Promise.all([
loadProducts({ itemsPerPage: 200, force: true }),
loadProductTypes()
])
await Promise.all([fetchProducts(), loadProductTypes()])
})
</script>

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="productDocuments"
@close="closePreview"
/>
<main class="container mx-auto px-6 py-10">
@@ -244,8 +245,8 @@
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
@@ -406,6 +407,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
@@ -699,7 +701,7 @@ const submitEdition = async () => {
await router.push('/product-catalog')
}
} 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 {
saving.value = false
}

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="siteDocuments"
@close="closePreview"
/>

View File

@@ -1,215 +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>
<!-- Commentaires -->
<CommentSection
entity-type="machine_skeleton"
:entity-id="type.id"
:entity-name="type.name"
/>
</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 { canEdit } = usePermissions()
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>

View File

@@ -1,240 +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>
<!-- Edit Form -->
<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">
Modifier : {{ type.name }}
</h2>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
<TypeEditForm
v-model="editedType"
:saving="saving"
@submit="saveChanges"
/>
</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, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
const { showSuccess, showError } = useToast()
const type = ref(null)
const loading = ref(true)
const saving = ref(false)
// Données éditées du type
const editedType = ref({
name: '',
description: '',
category: '',
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
pieceRequirements: [],
productRequirements: []
})
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 saveChanges = async () => {
try {
saving.value = true
const currentEditedType = editedType.value
// Préparer les données pour l'API
const updatedType = {
...currentEditedType,
customFields: normalizeCustomFields(currentEditedType.customFields),
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
}
const result = await updateMachineType(type.value.id, updatedType)
if (result.success) {
showSuccess('Type mis à jour avec succès !')
router.push('/machine-skeleton')
} else {
showError('Erreur lors de la mise à jour du type')
}
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
showError('Erreur lors de la sauvegarde')
} finally {
saving.value = false
}
}
// Charger le type au montage
onMounted(async () => {
if (!canEdit.value) {
router.replace(`/type/${route.params.id}`)
return
}
try {
const typeId = route.params.id
const result = await getMachineTypeById(typeId, true)
if (result.success) {
type.value = result.data
// Initialiser les données éditées
editedType.value = {
name: type.value.name || '',
description: type.value.description || '',
category: type.value.category || '',
maintenanceFrequency: type.value.maintenanceFrequency || '',
customFields: type.value.customFields || [],
componentRequirements: type.value.componentRequirements || [],
pieceRequirements: type.value.pieceRequirements || [],
productRequirements: type.value.productRequirements || [],
}
} else {
console.error('Failed to load type:', result.error)
showError('Type non trouvé')
}
} catch (error) {
console.error('Erreur lors du chargement:', error)
showError('Erreur lors du chargement')
} finally {
loading.value = false
}
})
</script>

View File

@@ -4,7 +4,6 @@ export const RELATION_ID_MAP: Record<string, { key: string; path: string }> = {
composantId: { key: 'composant', path: 'composants' },
pieceId: { key: 'piece', path: 'pieces' },
productId: { key: 'product', path: 'products' },
typeMachineId: { key: 'typeMachine', path: 'type_machines' },
typeComposantId: { key: 'typeComposant', path: 'model_types' },
typePieceId: { key: 'typePiece', path: 'model_types' },
typeProductId: { key: 'typeProduct', path: 'model_types' },

View File

@@ -60,9 +60,8 @@ export const uniqueConstructeurIds = (...sources: unknown[]): string[] => {
if (value.constructeur) {
explore(value.constructeur);
}
// Only extract ID if this looks like a constructeur object (has @type or recognizable fields)
// Don't extract ID from component/piece/product objects that happen to be passed in
if (typeof value.id === 'string' && !value.name && !value.typeComposant && !value.typePiece && !value.typeProduct) {
// Extract ID from constructeur-like objects, but skip component/piece/product entities
if (typeof value.id === 'string' && !value.typeComposant && !value.typePiece && !value.typeProduct) {
pushId(value.id);
}
return;

View File

@@ -0,0 +1,46 @@
export type SortDirection = 'asc' | 'desc'
export interface DataTableColumn {
/** Unique key — used as slot name prefix (`#cell-{key}`) and default data accessor (`row[key]`) */
key: string
/** Display label for the column header */
label: string
/** Whether clicking this column header triggers sorting. Default: false */
sortable?: boolean
/** Sort key sent to the API (may differ from `key`). Falls back to `key` if omitted. */
sortKey?: string
/** CSS class applied to both <th> and <td> */
class?: string
/** CSS class applied only to <th> */
headerClass?: string
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
width?: string
/** Text alignment: 'left' (default), 'center', 'right' */
align?: 'left' | 'center' | 'right'
/** Hide on mobile (adds 'hidden sm:table-cell') */
hiddenMobile?: boolean
/** Whether this column has a filter input under the header */
filterable?: boolean
/** Placeholder for the filter input */
filterPlaceholder?: string
}
export type DataTableColumnFilters = Record<string, string>
export interface DataTableSort {
field: string
direction: SortDirection
}
export interface DataTablePagination {
currentPage: number
totalPages: number
/** Total items matching current filters */
totalItems: number
/** Items displayed on current page */
pageItems: number
/** Available per-page options. If omitted, per-page selector is hidden. */
perPageOptions?: number[]
/** Currently selected items per page */
perPage?: number
}

View File

@@ -19,26 +19,32 @@ export const formatSize = (size: number | null | undefined): string => {
return `${formatted.toFixed(1)} ${units[index]}`
}
const resolveUrl = (doc: any): string => doc?.fileUrl || doc?.path || ''
export const shouldInlinePdf = (doc: any): boolean => {
if (!doc || !isPdfDocument(doc) || !doc.path) return false
if (!doc || !isPdfDocument(doc)) return false
const url = resolveUrl(doc)
if (!url) return false
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) return false
return true
}
export const appendPdfViewerParams = (src: string): string => {
if (!src || src.startsWith('data:')) return src || ''
if (!src) return ''
if (src.startsWith('data:')) return src
if (src.includes('#')) return `${src}&toolbar=0&navpanes=0`
return `${src}#toolbar=0&navpanes=0`
}
export const documentPreviewSrc = (doc: any): string => {
if (!doc?.path) return ''
if (isPdfDocument(doc)) return appendPdfViewerParams(doc.path)
return doc.path
const url = resolveUrl(doc)
if (!url) return ''
if (isPdfDocument(doc)) return appendPdfViewerParams(url)
return url
}
export const documentThumbnailClass = (doc: any): string => {
if (shouldInlinePdf(doc) || (isImageDocument(doc) && doc?.path)) return 'h-24 w-20'
if (shouldInlinePdf(doc) || (isImageDocument(doc) && resolveUrl(doc))) return 'h-24 w-20'
return 'h-16 w-16'
}
@@ -52,8 +58,14 @@ export const documentIcon = (doc: any): FileIconResult =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
export const downloadDocument = (doc: any): void => {
if (!doc?.path) return
const target = String(doc.path)
// Prefer dedicated download endpoint
if (doc?.downloadUrl) {
window.open(doc.downloadUrl, '_blank')
return
}
// Fallback for legacy data: URIs during migration
const target = resolveUrl(doc)
if (!target) return
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target

View File

@@ -123,7 +123,8 @@ export function extractStructureCustomFields(structure: any): any[] {
export function deduplicateFieldDefinitions(definitions: any[]): any[] {
const result: any[] = []
const seen = new Set<string>()
const seenIds = new Set<string>()
const seenNames = new Set<string>()
const orderedDefinitions = (Array.isArray(definitions) ? definitions.slice() : [])
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
@@ -132,9 +133,12 @@ export function deduplicateFieldDefinitions(definitions: any[]): any[] {
if (!field || typeof field !== 'object') return
const id = field.id ?? field.customFieldId ?? field.customField?.id ?? null
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
const key = id || nameKey
if (key && seen.has(key)) return
if (key) seen.add(key)
// Deduplicate by name+type (primary) AND by id — a field with the same
// name+type is the same field even when stored with different IDs.
if (nameKey && seenNames.has(nameKey)) return
if (id && seenIds.has(id)) return
if (id) seenIds.add(id)
if (nameKey) seenNames.add(nameKey)
field.orderIndex = resolveOrderIndex(field)
result.push(field)
})
@@ -219,7 +223,8 @@ export function dedupeMergedFields(fields: any[]): any[] {
: []
}
const seen = new Map<string, any>()
const seenById = new Map<string, any>()
const seenByName = new Map<string, any>()
const result: any[] = []
const orderedFields = fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
@@ -236,18 +241,14 @@ export function dedupeMergedFields(fields: any[]): any[] {
const fieldId = resolveCustomFieldId(field)
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
const key = fieldId || nameKey
if (!key) {
field.orderIndex = resolveOrderIndex(field)
result.push(field)
return
}
// Check duplicates by name+type first (same field can have different IDs)
const existing = (nameKey ? seenByName.get(nameKey) : undefined) ?? (fieldId ? seenById.get(fieldId) : undefined)
const existing = seen.get(key)
if (!existing) {
field.orderIndex = resolveOrderIndex(field)
seen.set(key, field)
if (fieldId) seenById.set(fieldId, field)
if (nameKey) seenByName.set(nameKey, field)
result.push(field)
return
}
@@ -258,7 +259,8 @@ export function dedupeMergedFields(fields: any[]): any[] {
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field)
existing.orderIndex = Math.min(resolveOrderIndex(existing), resolveOrderIndex(field))
seen.set(key, existing)
if (fieldId) seenById.set(fieldId, existing)
if (nameKey) seenByName.set(nameKey, existing)
}
})
@@ -276,45 +278,29 @@ export function buildDefinitionSources(entity: any, entityType: 'composant' | 'p
}
if (entityType === 'composant') {
const requirement = entity.typeMachineComponentRequirement || {}
const type = requirement.typeComposant || entity.typeComposant || {}
const type = entity.typeComposant || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(type.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
entity.definition?.structure,
type.structure,
type.componentSkeleton,
requirement.structure,
requirement.componentSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)
})
} else {
const requirement = entity.typeMachinePieceRequirement || {}
const type = requirement.typePiece || entity.typePiece || {}
const type = entity.typePiece || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(entity.typePiece?.customFields)
pushFields(type.customFields)
pushFields(requirement.typePiece?.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
entity.definition?.structure,
entity.typePiece?.structure,
type.structure,
type.pieceSkeleton,
entity.typePiece?.pieceSkeleton,
requirement.structure,
requirement.pieceSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)

View File

@@ -0,0 +1,152 @@
/**
* Translates raw backend error messages (Symfony, Doctrine, API Platform)
* into user-friendly French messages for display in toasts/alerts.
*/
const EXACT_MATCHES: Record<string, string> = {
// UniqueConstraintSubscriber (HTTP 409)
'nom duplique': 'Un élément avec ce nom existe déjà.',
// English backend messages → French
'Machine not found.': 'Machine introuvable.',
'Composant not found.': 'Composant introuvable.',
'Piece not found.': 'Pièce introuvable.',
'Product not found.': 'Produit introuvable.',
'Site not found.': 'Site introuvable.',
'Custom field not found.': 'Champ personnalisé introuvable.',
'Custom field value not found.': 'Valeur du champ personnalisé introuvable.',
'Document not found.': 'Document introuvable.',
'File not found on disk.': 'Le fichier n\'a pas été trouvé sur le serveur.',
'Invalid document data.': 'Les données du document sont invalides.',
'Invalid JSON payload.': 'Les données envoyées sont invalides.',
'Unsupported entity type.': 'Type d\'entité non supporté.',
'Entity target is missing.': 'La cible de l\'entité est manquante.',
'customFieldId or customFieldName is required.': 'L\'identifiant du champ personnalisé est requis.',
// Symfony validator messages
'This value should not be blank.': 'Ce champ ne peut pas être vide.',
'This value is not a valid email address.': 'L\'adresse email n\'est pas valide.',
'This value is already used.': 'Cette valeur est déjà utilisée.',
'This field is missing.': 'Un champ obligatoire est manquant.',
// HTTP status texts (used in "Erreur XXX: StatusText" fallback)
'Internal Server Error': 'Erreur interne du serveur. Veuillez réessayer.',
'Bad Request': 'Requête invalide.',
'Not Found': 'Ressource introuvable.',
'Conflict': 'Un élément similaire existe déjà.',
'Unprocessable Entity': 'Données invalides.',
'Unprocessable Content': 'Données invalides.',
'Service Unavailable': 'Service temporairement indisponible. Veuillez réessayer.',
'Gateway Timeout': 'Le serveur met trop de temps à répondre. Veuillez réessayer.',
}
const TECHNICAL_PATTERNS: Array<[RegExp, string]> = [
// Database / Doctrine errors
[/SQLSTATE\[/i, 'Une erreur est survenue. Veuillez réessayer.'],
[/An exception occurred/i, 'Une erreur est survenue. Veuillez réessayer.'],
[/Duplicate entry/i, 'Un élément avec ces données existe déjà.'],
[/unique.*constraint.*violation/i, 'Un élément avec ces données existe déjà.'],
[/foreign key constraint/i, 'Impossible de supprimer cet élément car il est utilisé ailleurs.'],
[/violates not-null constraint/i, 'Un champ obligatoire n\'a pas été renseigné.'],
[/violates check constraint/i, 'Une valeur saisie est invalide.'],
// Symfony / API Platform internal messages
[/Expected argument of type/i, 'Les données envoyées sont invalides.'],
[/Could not denormalize/i, 'Les données envoyées sont invalides.'],
[/The JSON value could not be decoded/i, 'Les données envoyées sont invalides.'],
[/Syntax error.*JSON/i, 'Les données envoyées sont invalides.'],
[/No route found/i, 'Ressource introuvable.'],
[/Access Denied/i, 'Permissions insuffisantes pour cette action.'],
]
/**
* Detects if a message contains technical jargon that should not be shown to users.
*/
function containsTechnicalJargon(message: string): boolean {
const patterns = [
/stack trace/i,
/exception/i,
/\bat\s+[\w\\]+::/,
/vendor\//,
/\.php/,
/doctrine/i,
/symfony/i,
/SQLSTATE/i,
/PDOException/i,
/DBALException/i,
/RuntimeException/i,
/TypeError/i,
/LogicException/i,
/InvalidArgumentException/i,
/UnexpectedValueException/i,
/constraint.*violation/i,
/entity.*manager/i,
/Hydra error/i,
]
return patterns.some((p) => p.test(message))
}
/**
* Translates a raw backend error message into a user-friendly French message.
*
* Usage:
* import { humanizeError } from '~/shared/utils/errorMessages'
* showError(humanizeError(rawMessage))
*/
export function humanizeError(rawMessage: string | undefined | null): string {
if (!rawMessage) return 'Une erreur est survenue.'
const trimmed = rawMessage.trim()
if (!trimmed) return 'Une erreur est survenue.'
// 1. Exact match
if (EXACT_MATCHES[trimmed]) return EXACT_MATCHES[trimmed]
// 2. "Erreur XXX: StatusText" pattern — translate the status text
const httpMatch = trimmed.match(/^Erreur (\d{3})\s*:\s*(.+)$/)
if (httpMatch) {
const statusText = httpMatch[2]!.trim()
if (EXACT_MATCHES[statusText]) return EXACT_MATCHES[statusText]
return `Erreur serveur (${httpMatch[1]}). Veuillez réessayer.`
}
// 3. Regex patterns for technical errors
for (const [pattern, replacement] of TECHNICAL_PATTERNS) {
if (pattern.test(trimmed)) return replacement
}
// 4. If it contains technical jargon, replace with generic message
if (containsTechnicalJargon(trimmed)) {
return 'Une erreur est survenue. Veuillez réessayer.'
}
// 5. Already user-friendly — return as-is
return trimmed
}
/**
* Extracts the best error message from various backend response formats.
* Handles: { message }, { error }, { detail }, { "hydra:description" }
*/
export function extractApiErrorMessage(errorData: Record<string, unknown>): string | null {
if (!errorData || typeof errorData !== 'object') return null
// Symfony validator violations — priorité max (message propre sans préfixe champ)
if (Array.isArray(errorData.violations) && errorData.violations.length > 0) {
const first = errorData.violations[0] as Record<string, unknown>
if (typeof first?.message === 'string') return first.message
}
// UniqueConstraintSubscriber format ({ success: false, error: "nom duplique" })
if (typeof errorData.error === 'string') return errorData.error
// Custom controllers format
if (typeof errorData.message === 'string') return errorData.message
if (Array.isArray(errorData.message) && typeof errorData.message[0] === 'string') return errorData.message[0]
// API Platform hydra format (fallback — peut contenir "propertyPath: message")
if (typeof errorData['hydra:description'] === 'string') return errorData['hydra:description']
if (typeof errorData.detail === 'string') return errorData.detail
return null
}

View File

@@ -208,11 +208,6 @@ export const getProductDisplay = (
((source.requirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeMachineComponentRequirement as AnyRecord)?.products,
(source.typeMachineComponentRequirement as AnyRecord)?.productSkeleton,
((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.typeMachineComponentRequirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.productSkeleton,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products,

View File

@@ -3,19 +3,19 @@ import { getFileIcon } from './fileIcons'
export const getPreviewType = (document) => {
if (!document) { return null }
const mime = (document.mimeType || '').toLowerCase()
const path = document.path || ''
const check = prefix => mime.startsWith(prefix) || path.startsWith(`data:${prefix}`)
if (check('image/')) { return 'image' }
if (mime === 'application/pdf' || path.startsWith('data:application/pdf')) { return 'pdf' }
if (check('audio/')) { return 'audio' }
if (check('video/')) { return 'video' }
if (check('text/') || mime.includes('json') || mime.includes('xml') || path.startsWith('data:application/json')) { return 'text' }
if (mime.startsWith('image/')) { return 'image' }
if (mime === 'application/pdf') { return 'pdf' }
if (mime.startsWith('audio/')) { return 'audio' }
if (mime.startsWith('video/')) { return 'video' }
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) { return 'text' }
return null
}
export const canPreviewDocument = (document = {}) => !!getPreviewType(document)
export const canPreviewDocument = (document = {}) => {
if (!getPreviewType(document)) return false
return !!(document.fileUrl || document.path)
}
export const isImageDocument = (document = {}) => getPreviewType(document) === 'image'

View File

@@ -412,9 +412,6 @@ export const buildMachinePrintContext = ({
}
const machineBadges = []
if (machine?.typeMachine?.category) {
machineBadges.push(machine.typeMachine.category)
}
if (machine?.site?.name) {
machineBadges.push(`Site: ${machine.site.name}`)
}
@@ -480,10 +477,8 @@ export const buildMachinePrintContext = ({
id: machine?.id || null,
name: machineName,
description: machine?.description || '',
typeDescription: machine?.typeMachine?.description || '',
reference: machineReference,
site: machine?.site?.name || '',
category: machine?.typeMachine?.category || '',
badges: machineBadges,
constructeurs: machineConstructeurs,
constructeur: machineConstructeurs[0] || null,

View File

@@ -1,35 +0,0 @@
# Rapport de déduplication
## DUP-001 · Score 92 · Formulaire de contact site
- **Motif** : duplication à lidentique du bloc de champs de contact (nom, téléphone, adresse…) entre les modales de création et dédition de site.
- **Occurrences détectées** :
- `app/components/sites/SiteCreateModal.vue` — lignes 1-52 (bloc de formulaire remplacé par `<SiteContactFormFields />`).
- `app/components/sites/SiteEditModal.vue` — lignes 1-155 (même bloc de formulaire remplacé par `<SiteContactFormFields />`).
- **Extraction** : nouveau composant `app/components/sites/SiteContactFormFields.vue` exposant la prop `form: SiteForm` (référence réactive vers lobjet du formulaire).
- **Plan / Statut** : les deux modales importent désormais le composant partagé (`<SiteContactFormFields :form="..." />`), supprimant lancienne duplication. Aucun changement dAPI publique côté modale.
## DUP-002 · Score 95 · Éditeur de contraintes (composants/pièces)
- **Motif** : logique et template identiques pour la gestion des groupes requis dans `TypeEditComponentRequirementsSection` et `TypeEditPieceRequirementsSection` (ajout/suppression, formulaires, cases à cocher).
- **Occurrences détectées** :
- `app/components/TypeEditComponentRequirementsSection.vue` — lignes 1-94 (ancien template remplacé par `<RequirementListEditor />`).
- `app/components/TypeEditPieceRequirementsSection.vue` — lignes 1-94 (même duplication remplacée).
- **Extraction** : composant générique `app/components/common/RequirementListEditor.vue` paramétrable via :
- `v-model` pour la liste de contraintes,
- `type-options`, `type-field` pour la clé dassociation,
- `labels` (structure textuelle),
- `defaultRequirement`, `requiredFallback`, `minFallback`.
- **Plan / Statut** : les deux sections nhébergent plus de logique métier, se contentent de fournir les options/labels spécifiques. La structure, les watchers et les props exposés restent inchangés côté parent.
## DUP-003 · Score 88 · Formatage de dates UI
- **Motif** : fonctions utilitaires de formatage (`toLocaleDateString`/`Intl.DateTimeFormat`) recopiées dans plusieurs pages (catalogues modèles et documents).
- **Occurrences détectées** :
- `app/pages/component-catalog.vue` — lignes 70-311 (affichage de la colonne « Modifié »).
- `app/pages/pieces-catalog.vue` — lignes 70-310.
- `app/pages/documents.vue` — lignes 90-188.
- **Extraction** : utilitaire commun `app/utils/date.ts` exposant `formatFrenchDate(value: Date | string | number | null | undefined): string` avec gestion des valeurs nulles/invalides.
- **Plan / Statut** : toutes les pages importent `formatFrenchDate` et lutilisent directement en template. Plus de fonction locale dupliquée.
## Couverture & suites
- Les trois duplications les plus impactantes repérées ont été factorisées (>= 80 % du volume ciblé).
- Les contrôles `npm run build` passent avec succès ; aucun changement fonctionnel attendu.
- Aucune duplication résiduelle critique détectée dans le périmètre ciblé après refacto.

View File

@@ -1,100 +0,0 @@
# Micro duplication report
## MDUP-001 · Score 92 · Type form-field
- **Pattern**: Champ téléphone complet (label, input `tel`, placeholder "Ex: 06 00 00 00 00", règles de validation implicites).
- **Occurrences**:
- `app/components/ConstructeurSelect.vue` L70-L86 — modal de création de constructeur. 【F:app/components/ConstructeurSelect.vue†L66-L88】
- `app/pages/constructeurs.vue` L82-L92 — formulaire de création/édition. 【F:app/pages/constructeurs.vue†L80-L92】
- `app/components/sites/SiteContactFormFields.vue` L1-L57 — bloc de formulaire de contact. 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
- `app/pages/index.vue` L200-L224 — création rapide dun site. 【F:app/pages/index.vue†L200-L224】
- **Extraction**: ✅ `app/components/form/FieldPhone.vue` (props : `modelValue`, `label`, `required`, `error`, `help`, `placeholder`, `disabled`, `normalizeOnBlur`, `validateOnBlur`). 【F:app/components/form/FieldPhone.vue†L1-L113】
- **Plan de remplacement**: Remplacer chaque bloc par `<FieldPhone v-model="..." />`. Call-sites déjà migrés ci-dessus.
## MDUP-002 · Score 90 · Type form-field
- **Pattern**: Champ email (label « Email », input `type="email"`, placeholder dexemple, aucune validation mutualisée).
- **Occurrences**:
- `app/components/ConstructeurSelect.vue` L66-L84. 【F:app/components/ConstructeurSelect.vue†L66-L86】
- `app/pages/constructeurs.vue` L82-L90. 【F:app/pages/constructeurs.vue†L80-L92】
- **Extraction**: ✅ `app/components/form/FieldEmail.vue` avec normalisation et validation partagée. 【F:app/components/form/FieldEmail.vue†L1-L112】
- **Plan de remplacement**: Blocs remplacés par `<FieldEmail />` sur les deux formulaires.
## MDUP-003 · Score 88 · Type form-field
- **Pattern**: Groupe « informations de contact site » (Nom du contact, Téléphone, Adresse, Code postal, Ville) répliqué.
- **Occurrences**:
- `app/components/sites/SiteContactFormFields.vue` (composant existant). 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
- `app/pages/index.vue` L200-L223 — doublait le bloc dans le modal rapide. 【F:app/pages/index.vue†L200-L223】
- **Extraction**: ✅ Réutilisation directe du composant `SiteContactFormFields` sur la page index. Aucun changement dAPI.
- **Plan de remplacement**: Remplacer le bloc du modal par `<SiteContactFormFields :form="newSite" />` (effectué).
## MDUP-004 · Score 86 · Type tiny-logic
- **Pattern**: Liaisons `computed({ get, set })` pour faire transiter `v-model` entre props et emits.
- **Occurrences**:
- `app/components/TypeEditComponentRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditComponentRequirementsSection.vue†L45-L59】
- `app/components/TypeEditPieceRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditPieceRequirementsSection.vue†L45-L59】
- `app/components/common/RequirementListEditor.vue` L198-L203. 【F:app/components/common/RequirementListEditor.vue†L198-L204】
- `app/components/TypeEditCustomFieldsSection.vue` L163-L168. 【F:app/components/TypeEditCustomFieldsSection.vue†L163-L168】
- `app/components/TypeEditBaseInfoSection.vue` L82-L102. 【F:app/components/TypeEditBaseInfoSection.vue†L82-L102】
- `app/components/sites/SiteEditModal.vue` L140-L154. 【F:app/components/sites/SiteEditModal.vue†L140-L154】
- **Extraction proposée**: `app/composables/useControlledModel.ts` retournant `{ model }` via `useVModel` maison (prop name configurable, options pour defaultValue et transform).
- **Plan**: 1) Introduire le composable, 2) remapper les computed existantes, 3) supprimer le code duplicatif.
## MDUP-005 · Score 82 · Type tiny-logic
- **Pattern**: Fonctions `createDefaultRequirement` quasi identiques (seuls champs `minCount`, `required` et `type*Id` changent).
- **Occurrences**:
- `app/components/TypeEditComponentRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditComponentRequirementsSection.vue†L61-L69】
- `app/components/TypeEditPieceRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditPieceRequirementsSection.vue†L61-L69】
- **Extraction proposée**: `app/shared/requirements/defaults.ts` exportant `createRequirementDefaults({ min, required, typeKey })`.
- **Plan**: Mutualiser la fonction, la paramétrer par options, adapter les deux sections.
## MDUP-006 · Score 80 · Type tiny-logic
- **Pattern**: Effet `onMounted` identique qui teste la liste et déclenche `loadX` si vide.
- **Occurrences**:
- `app/components/TypeEditComponentRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditComponentRequirementsSection.vue†L89-L93】
- `app/components/TypeEditPieceRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditPieceRequirementsSection.vue†L89-L93】
- **Extraction proposée**: `useEnsureOptionsLoaded(optionsRef, loader)` dans `app/composables/` pour encapsuler le check + chargement (support async/await, options pour refetch forcé).
- **Plan**: Appeler le composable dans les deux sections et supprimer le code inline.
## MDUP-007 · Score 78 · Type ui-fragment
- **Pattern**: Pieds de modale avec boutons « Annuler » + primaire + spinner optionnel.
- **Occurrences**:
- `app/components/ConstructeurSelect.vue` L80-L86. 【F:app/components/ConstructeurSelect.vue†L80-L86】
- `app/pages/constructeurs.vue` L86-L91. 【F:app/pages/constructeurs.vue†L86-L91】
- `app/components/sites/SiteCreateModal.vue` L21-L27. 【F:app/components/sites/SiteCreateModal.vue†L21-L27】
- `app/components/sites/SiteEditModal.vue` L82-L89. 【F:app/components/sites/SiteEditModal.vue†L82-L89】
- `app/pages/index.vue` L217-L223 & L306-L312. 【F:app/pages/index.vue†L215-L313】
- **Extraction proposée**: `app/components/common/ModalActions.vue` avec props `primaryLabel`, `primaryLoading`, `onCancel`, slots secondaires.
- **Plan**: Introduire le composant, refactorer chaque modal pour lutiliser, garantir les mêmes classes Tailwind.
## MDUP-008 · Score 76 · Type ui-fragment
- **Pattern**: Gabarit de modale (div `.modal` + `.modal-box`, titre `<h3>`, formulaire, actions).
- **Occurrences**:
- `app/components/ConstructeurSelect.vue` L58-L89. 【F:app/components/ConstructeurSelect.vue†L58-L89】
- `app/components/sites/SiteCreateModal.vue` L1-L31. 【F:app/components/sites/SiteCreateModal.vue†L1-L31】
- `app/components/sites/SiteEditModal.vue` L1-L94. 【F:app/components/sites/SiteEditModal.vue†L1-L94】
- `app/pages/index.vue` L192-L315 (modales site/machine). 【F:app/pages/index.vue†L192-L315】
- **Extraction proposée**: `app/components/common/ModalShell.vue` gérant louverture, le titre, le footer via slots (`header`, `default`, `footer`).
- **Plan**: Remplacer chaque squelette par le nouveau composant tout en conservant la structure DOM requise par DaisyUI.
## MDUP-009 · Score 74 · Type form-field
- **Pattern**: Champ texte simple (label, input type="text", `required`) pour les « Nom » & co.
- **Occurrences**:
- `app/components/ConstructeurSelect.vue` L62-L65. 【F:app/components/ConstructeurSelect.vue†L62-L66】
- `app/pages/constructeurs.vue` L78-L81. 【F:app/pages/constructeurs.vue†L78-L81】
- `app/components/sites/SiteCreateModal.vue` L6-L17. 【F:app/components/sites/SiteCreateModal.vue†L5-L17】
- `app/components/sites/SiteEditModal.vue` L8-L20. 【F:app/components/sites/SiteEditModal.vue†L8-L20】
- `app/components/TypeEditBaseInfoSection.vue` L8-L48. 【F:app/components/TypeEditBaseInfoSection.vue†L8-L48】
- **Extraction proposée**: `app/components/form/FieldText.vue` avec props `type`, `label`, `required`, `maxlength`, `placeholder`, support `modelModifiers`.
- **Plan**: Introduire le composant, migrer progressivement les champs texte, ajouter un paramètre pour afficher létoile obligatoire.
## MDUP-010 · Score 72 · Type ui-fragment
- **Pattern**: Bouton primaire avec indicateur de chargement inline (`<span class="loading loading-spinner loading-xs mr-2">`).
- **Occurrences**:
- `app/components/ConstructeurSelect.vue` L82-L84. 【F:app/components/ConstructeurSelect.vue†L82-L84】
- `app/pages/constructeurs.vue` L88-L89. 【F:app/pages/constructeurs.vue†L88-L90】
- `app/components/sites/SiteEditModal.vue` L86-L88. 【F:app/components/sites/SiteEditModal.vue†L86-L88】
- **Extraction proposée**: `app/components/common/LoadingButton.vue` gérant les variantes (`primary`, `outline`), le spinner et le label via slots.
- **Plan**: Remplacer les boutons concernés par le composant, propager `loading` & `disabled` automatiquement.
## Annexes
- **Validations centralisées**: `app/shared/validation/phone.ts` & `app/shared/validation/email.ts` fournissent désormais des schémas communs. 【F:app/shared/validation/phone.ts†L1-L36】【F:app/shared/validation/email.ts†L1-L34】
- **Formatters communs**: `app/utils/formatters/phone.ts` et `app/utils/formatters/email.ts` proposent les helpers associés. 【F:app/utils/formatters/phone.ts†L1-L67】【F:app/utils/formatters/email.ts†L1-L37】

View File

@@ -1,229 +0,0 @@
# Plan de migration — Réduction de code frontend
> Objectif : réduire ~5 700 LOC sans modifier le fonctionnel.
> Branche : à partir de `refacto/F1-decoupage-mega-composants`
> Statut global : **EN ATTENTE**
---
## Phase 1 — Pages catalogue (3 pages, ~1 200 LOC → ~350 LOC)
### M1.1 · Composant générique `CatalogPage.vue`
- **Motif** : `component-catalog.vue` (348 LOC), `pieces-catalog.vue` (463 LOC) et `product-catalog.vue` (408 LOC) partagent 95 % de structure (recherche, tri, pagination, tableau, suppression, états vides/loading).
- **Différences isolées** : colonnes du tableau, garde de suppression, extraction fournisseur.
- **Plan** :
1. Créer `app/components/common/CatalogPage.vue` acceptant :
- `columns: ColumnDef[]` (nom, clé, slot optionnel)
- `fetchFn: (params) => Promise<PaginatedResult>`
- `deleteFn: (id) => Promise<Result>`
- `deleteGuard?: (item) => string | null` (message bloquant ou null)
- `entityLabel: string`, `createRoute: string`
- Slots nommés pour colonnes custom (`#col-supplier`, etc.)
2. Extraire `supplierDisplayUtils.ts` (pattern `MAX_VISIBLE_SUPPLIERS` dupliqué dans pieces-catalog et product-catalog).
3. Réduire chaque page catalogue à ~80 LOC (config + slots custom).
- **Gain estimé** : ~850 LOC
- **Statut** : `[ ]`
---
## Phase 2 — Composables CRUD génériques (~1 170 LOC → ~400 LOC)
### M2.1 · Factory `useEntityCRUD<T>(config)`
- **Motif** : `usePieces.ts` (240), `useProducts.ts` (305), `useComposants.ts` (231), `useSites.ts` (124) suivent le même pattern CRUD : refs `loading/loaded/error`, `loadItems()` paginé, `create/update/delete` avec mise à jour cache + toast.
- **Différences isolées** : endpoint, normaliseur, enrichissement constructeurs, champs de tri.
- **Plan** :
1. Créer `app/composables/useEntityCRUD.ts` :
```ts
interface EntityCRUDConfig {
endpoint: string
label: string
normalizer?: (item: any) => any
enricher?: (item: any) => Promise<any>
defaultSort?: { field: string; dir: 'asc' | 'desc' }
}
export function useEntityCRUD(config: EntityCRUDConfig)
```
2. Extraire `extractTotal()` dans `apiHelpers.ts` (dupliqué 3×, ~10 LOC chacun).
3. Extraire `buildPaginatedQuery(options)` dans `apiHelpers.ts` (dupliqué 3×, ~15 LOC chacun).
4. Extraire pattern `withResolvedConstructeurs()` dans `useEntityEnricher.ts` (dupliqué 3× dans pieces/products/composants, ~50 LOC chacun).
5. Réduire chaque composable à un appel de factory + méthodes spécifiques.
6. Garder `useMachines.ts` séparé (méthodes spéciales : `reconfigureSkeleton`, `createMachineFromType`).
- **Gain estimé** : ~770 LOC
- **Statut** : `[ ]`
### M2.2 · Helper `withLoadingState()`
- **Motif** : pattern `loading.value = true; try { ... } finally { loading.value = false }` répété 10+ fois dans les composables CRUD.
- **Plan** : créer `app/composables/useLoadingHelper.ts` exportant :
```ts
async function withLoadingState<T>(loading: Ref<boolean>, fn: () => Promise<T>): Promise<T>
```
- **Gain estimé** : ~100 LOC
- **Statut** : `[ ]`
### M2.3 · Fusion `usePersistedValue` + `usePersistedSort`
- **Motif** : même pattern `useCookie()` + `watch()` + JSON parse/stringify.
- **Plan** : fusionner en `usePersistedState<T>(key, fallback, prefix?)`.
- **Gain estimé** : ~30 LOC
- **Statut** : `[ ]`
---
## Phase 3 — Pages edit entités (~2 750 LOC → ~1 200 LOC)
### M3.1 · Composant `HistorySection.vue`
- **Motif** : bloc historique identique (loading/error/empty + itération entries) dans `component/[id]/edit.vue` (L437-503), `pieces/[id]/edit.vue` (L384-450), `product/[id]/edit.vue` (L304-370) — ~67 LOC × 3.
- **Plan** : créer `app/components/common/HistorySection.vue` avec props `entries`, `loading`, `error`.
- **Gain estimé** : ~130 LOC
- **Statut** : `[ ]`
### M3.2 · Composant `DocumentsSection.vue`
- **Motif** : bloc document (upload, liste, preview, download, delete) dupliqué dans les 3 pages edit + `MachineDocumentsCard.vue` + `SiteEditModal.vue` — ~70-180 LOC × 5.
- **Plan** : créer `app/components/common/DocumentsSection.vue` avec props `documents`, `entityId`, `entityType` et events `upload`, `delete`, `preview`.
- **Gain estimé** : ~400 LOC
- **Statut** : `[ ]`
### M3.3 · Composable `useEntityEditForm(config)`
- **Motif** : les 3 pages edit partagent : chargement entité + types + constructeurs, gestion champs custom, normalisation payload, sauvegarde, gestion erreur.
- **Différences** : component a structure display, piece a product selection, product est plus simple.
- **Plan** :
1. Créer `app/composables/useEntityEditForm.ts` gérant le cycle de vie commun (load, save, custom fields sync).
2. Chaque page edit ne garde que ses spécificités.
- **Gain estimé** : ~500 LOC
- **Statut** : `[ ]`
### M3.4 · Réutilisation `customFieldFormUtils.ts` dans `component/create.vue`
- **Motif** : `component/create.vue` (1 266 LOC) réimplémente `resolveFieldName`, `resolveFieldType`, `resolveDefaultValue` déjà dans `customFieldFormUtils.ts`. Aussi 3 fonctions `resolveXxxLabel` quasi-identiques (~18 LOC × 3).
- **Plan** :
1. Remplacer les fonctions locales par les imports de `customFieldFormUtils.ts`.
2. Créer `resolveTypeLabel(entity, typeField, labelField, fallback)` générique.
- **Gain estimé** : ~120 LOC
- **Statut** : `[ ]`
---
## Phase 4 — Décomposition `useMachineDetailData.ts` (1 410 LOC → ~500 LOC)
### M4.1 · Extraire `useMachineDocuments.ts`
- **Motif** : gestion documents (upload, delete, preview, refresh) = ~200 LOC dans le composable monolithique.
- **Gain estimé** : ~150 LOC (après factorisation avec DocumentsSection)
- **Statut** : `[ ]`
### M4.2 · Extraire `useMachineConstructeurs.ts`
- **Motif** : résolution constructeurs avec chaînes de fallback 4 niveaux, `uniqueConstructeurIds`, `resolveConstructeurs` = ~80 LOC.
- **Gain estimé** : ~60 LOC
- **Statut** : `[ ]`
### M4.3 · Fusionner `transformCustomFields` et `transformComponentCustomFields`
- **Motif** : L303-405 et L407-514 — logique quasi-identique de transformation des champs custom, seule la source (machine vs composant) diffère.
- **Plan** : créer `transformEntityCustomFields(entity, fieldSource, config)` paramétrable.
- **Gain estimé** : ~100 LOC
- **Statut** : `[ ]`
### M4.4 · Extraire groupement de requirements
- **Motif** : `componentRequirementGroups`, `pieceRequirementGroups` = computed complexes avec construction de maps et filtres répétitifs.
- **Gain estimé** : ~80 LOC
- **Statut** : `[ ]`
---
## Phase 5 — `StructureNodeEditor.vue` (1 167 LOC → ~600 LOC)
### M5.1 · Composable `useDragDrop.ts`
- **Motif** : 4 handlers drag-drop quasi-identiques (custom fields, pièces, produits, sous-composants) avec chacun `draggingIndex`, `dropTargetIndex`, `reorderClass()`, `handleDragStart/Over/End`.
- **Plan** : créer `useDragDrop<T>(items: Ref<T[]>)` retournant `{ dragging, target, reorderClass, onDragStart, onDragOver, onDragEnd, onDrop }`.
- **Gain estimé** : ~350 LOC
- **Statut** : `[ ]`
### M5.2 · Extraire validation noeud
- **Motif** : `isAssignmentNodeComplete` + logique de validation dispersée.
- **Plan** : déplacer vers `app/shared/utils/structureValidation.ts`.
- **Gain estimé** : ~40 LOC
- **Statut** : `[ ]`
---
## Phase 6 — Micro-duplications restantes (du `micro-dup-report.md`)
### M6.1 · `useControlledModel.ts` (MDUP-004)
- **Motif** : `computed({ get, set })` pour transiter `v-model` entre props et emits — dupliqué dans 6 composants.
- **Gain estimé** : ~60 LOC
- **Statut** : `[ ]`
### M6.2 · `ModalShell.vue` (MDUP-008) + `ModalActions.vue` (MDUP-007)
- **Motif** : squelette de modale DaisyUI (`.modal` + `.modal-box` + titre + footer) dupliqué dans 4+ composants. Pieds de modale « Annuler + Primaire + spinner » dupliqués 5×.
- **Gain estimé** : ~120 LOC
- **Statut** : `[ ]`
### M6.3 · `LoadingButton.vue` (MDUP-010) + `FieldText.vue` (MDUP-009)
- **Motif** : bouton primaire avec spinner (3 occurrences), champ texte simple label+input (5 occurrences).
- **Gain estimé** : ~80 LOC
- **Statut** : `[ ]`
### M6.4 · `createRequirementDefaults` + `useEnsureOptionsLoaded` (MDUP-005, MDUP-006)
- **Motif** : factory de requirement par défaut + `onMounted` identiques dans les sections composant/pièce.
- **Gain estimé** : ~30 LOC
- **Statut** : `[ ]`
---
## Phase 7 — Consolidation custom fields (~1 150 LOC → ~800 LOC)
### M7.1 · Fusionner logique de résolution dans `customFieldUtils.ts`
- **Motif** : `customFieldUtils.ts` (440), `entityCustomFieldLogic.ts` (349), `customFieldFormUtils.ts` (367) contiennent des fonctions de résolution de champs qui se chevauchent (`resolveFieldId`, `resolveFieldName`, génération de clé, déduplication).
- **Plan** : consolider les fonctions dupliquées en gardant la séparation thématique (utils / form / entity) mais en partageant les primitives.
- **Gain estimé** : ~150 LOC
- **Statut** : `[ ]`
---
## Récapitulatif
| Phase | Cible | LOC avant | Gain estimé | Priorité |
|-------|-------|-----------|-------------|----------|
| **P1** | Pages catalogue | ~1 220 | ~850 | Haute |
| **P2** | Composables CRUD | ~1 170 | ~900 | Haute |
| **P3** | Pages edit entités | ~2 750 | ~1 150 | Haute |
| **P4** | useMachineDetailData | ~1 410 | ~390 | Moyenne |
| **P5** | StructureNodeEditor | ~1 167 | ~390 | Moyenne |
| **P6** | Micro-duplications | ~400 | ~290 | Basse |
| **P7** | Custom fields utils | ~1 150 | ~150 | Basse |
| | **Total** | | **~4 120 LOC** | |
### Ordre recommandé
1. **P2** (CRUD generics) — fondation pour P1 et P3
2. **P1** (catalogues) — dépend de P2 pour les fetch functions
3. **P3** (pages edit) — plus gros gain absolu, dépend partiellement de P2
4. **P5** (drag-drop) — indépendant, quick win
5. **P4** (machine detail) — complexe mais fort impact
6. **P6** (micro-dup) — petits gains, faible risque
7. **P7** (custom fields) — délicat, à faire en dernier
### Vérification après chaque phase
```bash
cd Inventory_frontend
npx nuxi typecheck # 0 erreurs
npm run lint:fix # 0 erreurs
npm run build # succès
npx vitest run # 54+ tests pass
```