20 Commits

Author SHA1 Message Date
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
Matthieu
a98ab8c275 feat(comments): add comment/ticket system across all entity pages
Add CommentSection component for inline comments on entity detail pages
(machines, pieces, composants, products, categories, skeleton types).
Add dedicated /comments page with filters, pagination and clickable links.
Add unresolved count badge on avatar and in profile dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:06 +01:00
Matthieu
e22463874c fix(constructeurs): improve search filtering and duplicate prevention
Switch ConstructeurSelect to client-side filtering instead of debounced
API calls. Add duplicate name check before creating a new constructeur
in both ConstructeurSelect and the constructeurs page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:05:54 +01:00
Matthieu
256039264e chore: update package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:02:13 +01:00
Matthieu
e459da7c20 fix(ui) : replace checkbox with toggle switch for boolean custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:51 +01:00
Matthieu
e84b5cf674 feat(ui) : display role badge in profile dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:42:05 +01:00
Matthieu
cc70fe2b29 feat(permissions) : add role-based UI guards and readonly mode for viewers
- Add usePermissions composable (isAdmin, canEdit, canView)
- Password-protected profile login with modal on profiles page
- Disable all form fields for ROLE_VIEWER across edit/create pages
- Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers
- Add readonly prop to ModelTypeForm for category pages
- Disable modal fields (sites, constructeurs) for viewers
- Guard /admin routes in middleware

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:36:42 +01:00
Matthieu
6bed715b7f fix(machines): fix skeleton creation — load all items + atomic creation
- Load composants/pieces/products with itemsPerPage: 200 instead of 30
  (root cause: only first 30 items were available in creation dropdowns)
- Rollback machine if skeleton PATCH fails (delete orphaned machine)
- Initialize custom fields after successful machine creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:39:45 +01:00
Matthieu
dbf8c8856b test(e2e) : add Playwright setup with product and category CRUD specs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:07:23 +01:00
Matthieu
62127a33f5 chore(release) : update changelog for v1.6.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:13 +01:00
84 changed files with 4815 additions and 2489 deletions

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
# Playwright
e2e/.auth/
playwright-report/
test-results/

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 ```bash
# npm
npm install npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
``` ```
## Development Server ## Développement
Start the development server on `http://localhost:3000`:
```bash ```bash
# npm
npm run dev npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
``` ```
## Production L'application est accessible sur **http://localhost:3001**.
Build the application for production: ## Commandes disponibles
```bash | Commande | Description |
# npm |----------|-------------|
npm run build | `npm run dev` | Serveur de développement avec HMR |
| `npm run build` | Build de production |
| `npm run lint:fix` | Correction automatique ESLint |
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
| `npm run test` | Tests unitaires Vitest |
| `npm run test:watch` | Tests unitaires en mode watch |
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
# pnpm ## Fonctionnalités
pnpm build
# yarn ### Gestion du parc
yarn build
# bun - **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
bun run build - **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
- **Sites** : gestion multi-sites avec coordonnées de contact
### Catalogues
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
### Documents et traçabilité
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
### Administration
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
- **Profils** : gestion des utilisateurs et attribution des rôles
- **Notifications** : badge compteur de commentaires ouverts avec polling
## Architecture
```
app/
├── pages/ # 36 pages (file-based routing)
├── components/ # 57 composants Vue (auto-imported par Nuxt)
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
│ ├── form/ # Champs de formulaire (email, téléphone)
│ ├── layout/ # Navbar principale
│ ├── machine/ # Vue détail et création de machines
│ │ └── create/ # Wizard de création machine
│ ├── model-types/ # Gestion des types/catégories
│ └── sites/ # Modales site (création, édition)
├── composables/ # 45 composables (logique métier)
├── shared/ # Types, utilitaires, validation
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
│ ├── validation/ # Validation email, téléphone
│ └── model/ # Définitions de structures
├── services/ # Service layer (wrappers API spécialisés)
├── middleware/ # Middleware d'auth global (session cookie)
└── utils/ # Formatage dates, montants, événements
``` ```
Locally preview production build: ## Conventions de code
```bash ### Composables
# npm
npm run preview
# pnpm Pattern avec injection de dépendances explicite :
pnpm preview
# yarn ```typescript
yarn preview interface Deps {
machineId: Ref<string>
onSave: () => void
}
# bun export function useMachineDetail(deps: Deps) {
bun run preview // ...
}
``` ```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. ### Communication entre composants
**Props + Events uniquement** — pas de `provide/inject` dans le projet.
### Appels API
Le composable `useApi.ts` centralise tous les appels HTTP :
- Cookies de session inclus automatiquement (`credentials: 'include'`)
- `application/ld+json` pour POST/PUT
- `application/merge-patch+json` pour PATCH
- Gestion d'erreurs centralisée avec traduction des messages backend en français
### Styles
Classes DaisyUI standard :
- Input : `input input-bordered input-sm md:input-md`
- Select : `select select-bordered select-sm md:select-md`
- Button : `btn btn-sm md:btn-md btn-primary`
## Authentification
L'application utilise une **authentification par session (cookies)**, pas de JWT.
Le middleware global `profile.global.ts` vérifie la session à chaque navigation :
- Utilisateur non connecté → redirection vers `/profiles`
- Route `/admin/*` → accès restreint à `ROLE_ADMIN`
## Tests
- **13 tests unitaires** (Vitest + happy-dom) couvrant composables, utils et composants
- **3 specs E2E** (Playwright + Chrome) avec setup d'authentification
## Submodule Git
Ce repo est un **submodule** du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory).
Workflow de commit :
1. Commiter dans ce repo (frontend) en premier
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
3. Pousser les deux repos

View File

@@ -0,0 +1,212 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold flex items-center gap-2">
<IconLucideMessageSquare class="w-5 h-5" />
Commentaires
<span v-if="openComments.length" class="badge badge-warning badge-sm">
{{ openComments.length }}
</span>
</h3>
<button
v-if="showResolved && resolvedComments.length"
type="button"
class="btn btn-ghost btn-xs"
@click="showResolvedList = !showResolvedList"
>
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
</button>
</div>
<!-- Formulaire d'ajout -->
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<button
type="button"
class="btn btn-primary btn-sm self-end"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
</div>
<!-- Liste des commentaires ouverts -->
<div v-if="loadingComments" class="flex justify-center py-4">
<span class="loading loading-spinner loading-sm" />
</div>
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
Aucun commentaire ouvert.
</div>
<div v-else class="space-y-3">
<div
v-for="comment in openComments"
:key="comment.id"
class="bg-base-200 rounded-lg p-3 space-y-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
</div>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span>
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
</span>
<div v-if="canEdit" class="flex gap-1">
<button
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(comment.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loading"
@click="handleDelete(comment.id)"
>
<IconLucideTrash2 class="w-3 h-3" />
</button>
</div>
</div>
</div>
</div>
<!-- Commentaires résolus -->
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
<div class="divider text-xs text-base-content/40">
Résolus
</div>
<div
v-for="comment in resolvedComments"
:key="comment.id"
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
>
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<div class="flex items-center justify-between text-xs text-base-content/50">
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
<span v-if="comment.resolvedByName">
Résolu par {{ comment.resolvedByName }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useComments, type Comment } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import IconLucideMessageSquare from '~icons/lucide/message-square'
import IconLucideSend from '~icons/lucide/send'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideTrash2 from '~icons/lucide/trash-2'
const props = defineProps<{
entityType: string
entityId: string
entityName?: string
showResolved?: boolean
}>()
const { canEdit } = usePermissions()
const {
loading,
fetchComments,
createComment,
resolveComment,
deleteComment,
} = useComments()
const comments = ref<Comment[]>([])
const newContent = ref('')
const submitting = ref(false)
const loadingComments = ref(false)
const showResolvedList = ref(false)
const openComments = computed(() =>
comments.value.filter(c => c.status === 'open'),
)
const resolvedComments = computed(() =>
comments.value.filter(c => c.status === 'resolved'),
)
const formatCommentDate = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
const loadComments = async () => {
loadingComments.value = true
const [openResult, resolvedResult] = await Promise.all([
fetchComments(props.entityType, props.entityId, 'open'),
props.showResolved
? fetchComments(props.entityType, props.entityId, 'resolved')
: Promise.resolve({ success: true, data: [] as Comment[] }),
])
const open = openResult.success ? (openResult.data ?? []) : []
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
comments.value = [...open, ...resolved]
loadingComments.value = false
}
const handleSubmit = async () => {
const content = newContent.value.trim()
if (!content) return
submitting.value = true
const result = await createComment(
props.entityType,
props.entityId,
content,
props.entityName,
)
submitting.value = false
if (result.success) {
newContent.value = ''
await loadComments()
}
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {
await loadComments()
}
}
const handleDelete = async (commentId: string) => {
const result = await deleteComment(commentId)
if (result.success) {
comments.value = comments.value.filter(c => c.id !== commentId)
}
}
onMounted(() => {
if (props.entityId) {
loadComments()
}
})
</script>

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
@@ -174,8 +175,8 @@
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10" class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -332,8 +333,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >

View File

@@ -20,16 +20,16 @@
</button> </button>
<div <div
v-if="openDropdown" v-if="openDropdown"
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col" class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
> >
<div <div
v-if="options.length === 0" v-if="filteredOptions.length === 0"
class="px-3 py-2 text-xs text-gray-500" class="px-3 py-2 text-xs text-gray-500"
> >
Aucun fournisseur trouvé Aucun fournisseur trouvé
</div> </div>
<button <button
v-for="option in options" v-for="option in filteredOptions"
:key="option.id" :key="option.id"
type="button" type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none" class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@@ -164,8 +164,7 @@ const openCreateModal = ref(false)
const creating = ref(false) const creating = ref(false)
const options = ref<ConstructeurSummary[]>([]) const options = ref<ConstructeurSummary[]>([])
const selectedIds = ref<string[]>([]) const selectedIds = ref<string[]>([])
let searchTimeout: ReturnType<typeof setTimeout> | null = null
let lastSearchTerm = ''
const uniqueOptions = (items: ConstructeurSummary[] = []) => { const uniqueOptions = (items: ConstructeurSummary[] = []) => {
const seen = new Map<string, ConstructeurSummary>() const seen = new Map<string, ConstructeurSummary>()
@@ -182,32 +181,22 @@ const normalizedInitialOptions = computed(() =>
) )
const applyOptions = (items: ConstructeurSummary[] = []) => { const applyOptions = (items: ConstructeurSummary[] = []) => {
const normalized = uniqueOptions([ options.value = uniqueOptions([
...normalizedInitialOptions.value, ...normalizedInitialOptions.value,
...items, ...items,
]) ])
const limited = normalized.slice(0, 10)
selectedIds.value.forEach((id) => {
if (!limited.some((item) => item.id === id)) {
const match =
normalized.find((item) => item.id === id) ||
constructeurs.value.find((item) => item.id === id)
if (match) {
if (limited.length >= 10) {
limited.pop()
}
limited.unshift(match)
}
}
})
options.value = uniqueOptions([
...normalizedInitialOptions.value,
...limited,
])
} }
const filteredOptions = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
if (!term) return options.value
return options.value.filter((option) =>
(option.name ?? '').toLowerCase().includes(term)
|| (option.email && option.email.toLowerCase().includes(term))
|| (option.phone && option.phone.toLowerCase().includes(term))
)
})
const createForm = ref({ const createForm = ref({
name: '', name: '',
email: '', email: '',
@@ -257,46 +246,20 @@ const extractDataArray = (data: unknown): ConstructeurSummary[] => {
} }
const ensureOptionsLoaded = async (force = false) => { const ensureOptionsLoaded = async (force = false) => {
if (!force && !searchTerm.value && constructeurs.value.length) { if (!force && constructeurs.value.length) {
applyOptions(constructeurs.value as ConstructeurSummary[]) applyOptions(constructeurs.value as ConstructeurSummary[])
return return
} }
if (!force && searchTerm.value === lastSearchTerm && options.value.length) { const result = await searchConstructeurs('')
return
}
if (options.value.length && !force) {
return
}
const result = await searchConstructeurs(searchTerm.value)
if (result.success) { if (result.success) {
applyOptions(extractDataArray(result.data)) applyOptions(extractDataArray(result.data))
lastSearchTerm = searchTerm.value
} }
} }
const onSearch = () => { const onSearch = () => {
openDropdown.value = true openDropdown.value = true
if (searchTimeout) { ensureOptionsLoaded()
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(async () => {
if (!searchTerm.value && constructeurs.value.length) {
applyOptions(constructeurs.value as ConstructeurSummary[])
lastSearchTerm = ''
return
}
if (searchTerm.value === lastSearchTerm) {
return
}
const result = await searchConstructeurs(searchTerm.value)
if (result.success) {
applyOptions(extractDataArray(result.data))
lastSearchTerm = searchTerm.value
}
}, 250)
} }
const toggleOption = (option: ConstructeurSummary) => { const toggleOption = (option: ConstructeurSummary) => {
@@ -319,9 +282,19 @@ const closeCreateModal = () => {
} }
const handleCreate = async () => { const handleCreate = async () => {
const trimmedName = createForm.value.name.trim()
const duplicate = options.value.find(
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
)
if (duplicate) {
emitSelection([...selectedIds.value, duplicate.id])
closeCreateModal()
return
}
creating.value = true creating.value = true
const payload: { name: string; email?: string; phone?: string } = { const payload: { name: string; email?: string; phone?: string } = {
name: createForm.value.name, name: trimmedName,
} }
if (createForm.value.email) { if (createForm.value.email) {
payload.email = createForm.value.email payload.email = createForm.value.email
@@ -383,9 +356,6 @@ watch(
constructeurs, constructeurs,
(list) => { (list) => {
applyOptions((list as ConstructeurSummary[]) || []) applyOptions((list as ConstructeurSummary[]) || [])
if (!searchTerm.value) {
lastSearchTerm = ''
}
}, },
{ immediate: true }, { immediate: true },
) )
@@ -405,9 +375,6 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', clickHandler) window.removeEventListener('click', clickHandler)
if (searchTimeout) {
clearTimeout(searchTimeout)
}
}) })
watch( watch(

View File

@@ -55,16 +55,16 @@
</select> </select>
<!-- Champ de type BOOLEAN --> <!-- Champ de type BOOLEAN -->
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="fieldValues[field.id]" v-model="fieldValues[field.id]"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm"
:checked="fieldValues[field.id] === 'true'" :checked="fieldValues[field.id] === 'true'"
@change="updateCustomFieldValue(field.id)" @change="updateCustomFieldValue(field.id)"
> >
<span class="text-sm">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<!-- Champ de type DATE --> <!-- Champ de type DATE -->
<input <input

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
@@ -184,8 +185,8 @@
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8" class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -413,8 +414,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >

View File

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

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

@@ -24,9 +24,10 @@
<li v-for="link in simpleLinks" :key="link.to"> <li v-for="link in simpleLinks" :key="link.to">
<NuxtLink <NuxtLink
:to="link.to" :to="link.to"
class="rounded-md px-2 py-1 transition-colors" class="rounded-md px-2 py-1 transition-colors flex items-center gap-2"
:class="linkClass(link)" :class="linkClass(link)"
> >
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }} {{ link.label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -46,7 +47,10 @@
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')" @keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')" @keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
> >
<span>{{ group.label }}</span> <span class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
</span>
<IconLucideChevronRight <IconLucideChevronRight
class="h-4 w-4 transition-transform" class="h-4 w-4 transition-transform"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''" :class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
@@ -65,6 +69,9 @@
:class="childLinkClass(child)" :class="childLinkClass(child)"
> >
{{ child.label }} {{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
@@ -97,9 +104,10 @@
<li v-for="link in simpleLinks" :key="link.to"> <li v-for="link in simpleLinks" :key="link.to">
<NuxtLink <NuxtLink
:to="link.to" :to="link.to"
class="transition-colors px-3 py-2 rounded-md" class="transition-colors px-3 py-2 rounded-md flex items-center gap-1.5"
:class="linkClass(link)" :class="linkClass(link)"
> >
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }} {{ link.label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -116,13 +124,14 @@
> >
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors" class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 transition-colors"
:class="groupClass(group)" :class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'" :aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')" @click="toggleDropdown(group.id + '-desktop')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')" @keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')" @keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
> >
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }} {{ group.label }}
<IconLucideChevronRight <IconLucideChevronRight
class="h-4 w-4 transition-transform" class="h-4 w-4 transition-transform"
@@ -142,6 +151,9 @@
:class="childLinkClass(child)" :class="childLinkClass(child)"
> >
{{ child.label }} {{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
@@ -166,8 +178,14 @@
<div <div
tabindex="0" tabindex="0"
role="button" role="button"
class="btn btn-ghost btn-circle avatar placeholder" class="btn btn-ghost btn-circle avatar placeholder indicator"
> >
<span
v-if="unresolvedCount > 0"
class="indicator-item badge badge-warning badge-xs"
>
{{ unresolvedCount }}
</span>
<div <div
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center" class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
> >
@@ -185,11 +203,21 @@
<li class="px-2 py-1 text-sm text-base-content/70"> <li class="px-2 py-1 text-sm text-base-content/70">
Connecté en tant que<br /> Connecté en tant que<br />
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span> <span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
</li>
<li v-if="isAdmin">
<NuxtLink to="/admin" class="justify-between">
Administration
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
</NuxtLink>
</li> </li>
<li> <li>
<NuxtLink to="/profiles/manage" class="justify-between"> <NuxtLink to="/comments" class="justify-between">
Gestion des profils Commentaires
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" /> <span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
{{ unresolvedCount }}
</span>
<IconLucideChevronRight v-else class="w-4 h-4" aria-hidden="true" />
</NuxtLink> </NuxtLink>
</li> </li>
<li> <li>
@@ -211,14 +239,23 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
import { useRoute } from '#imports' import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown' import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
import { useProfileSession } from '~/composables/useProfileSession' import { useProfileSession } from '~/composables/useProfileSession'
import { useComments } from '~/composables/useComments'
import IconLucideMenu from '~icons/lucide/menu' import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings' import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideLogOut from '~icons/lucide/log-out' import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{ defineEmits<{
@@ -229,25 +266,38 @@ defineEmits<{
interface NavLink { interface NavLink {
to: string to: string
label: string label: string
icon?: Component
} }
interface NavGroup { interface NavGroup {
id: string id: string
label: string label: string
icon?: Component
activePaths: string[] activePaths: string[]
children: NavLink[] children: NavLink[]
} }
const simpleLinks: NavLink[] = [ const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble' }, { to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines' }, { to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
{ to: '/machine-skeleton', label: 'Squelettes de machine' }, { to: '/machine-skeleton', label: 'Squelettes', icon: IconLucideClipboardList },
] ]
const navGroups: NavGroup[] = [ const navGroups: NavGroup[] = [
{
id: 'component',
label: 'Composants',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{ {
id: 'pieces', id: 'pieces',
label: 'Pièces', label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'], activePaths: ['/piece-category', '/pieces-catalog'],
children: [ children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' }, { to: '/pieces-catalog', label: 'Catalogue des pièces' },
@@ -257,29 +307,23 @@ const navGroups: NavGroup[] = [
{ {
id: 'products', id: 'products',
label: 'Produits', label: 'Produits',
icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'], activePaths: ['/product-category', '/product-catalog'],
children: [ children: [
{ to: '/product-catalog', label: 'Catalogue des produits' }, { to: '/product-catalog', label: 'Catalogue des produits' },
{ to: '/product-category', label: 'Catégorie de produit' }, { to: '/product-category', label: 'Catégorie de produit' },
], ],
}, },
{
id: 'component',
label: 'Composant',
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{ {
id: 'resources', id: 'resources',
label: 'Ressources liées', label: 'Ressources liées',
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'], icon: IconLucideLink,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
children: [ children: [
{ to: '/sites', label: 'Sites' }, { to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' }, { to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' }, { to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/comments', label: 'Commentaires' },
{ to: '/activity-log', label: 'Journal d\'activité' }, { to: '/activity-log', label: 'Journal d\'activité' },
], ],
}, },
@@ -288,6 +332,25 @@ const navGroups: NavGroup[] = [
const route = useRoute() const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown() const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
const { activeProfile } = useProfileSession() const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions()
const { fetchUnresolvedCount } = useComments()
const unresolvedCount = ref(0)
let pollInterval: ReturnType<typeof setInterval> | null = null
const refreshUnresolvedCount = async () => {
if (!activeProfile.value) return
unresolvedCount.value = await fetchUnresolvedCount()
}
onMounted(() => {
refreshUnresolvedCount()
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
})
onBeforeUnmount(() => {
if (pollInterval) clearInterval(pollInterval)
})
const isActive = (path: string) => { const isActive = (path: string) => {
if (path === '/') { if (path === '/') {
@@ -318,6 +381,18 @@ const childLinkClass = (child: NavLink) => {
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content hover:bg-primary/10 hover:text-primary'
} }
const roleLabel = computed(() => {
if (isAdmin.value) return 'Admin'
if (canEdit.value) return 'Gestionnaire'
return 'Lecteur'
})
const roleBadgeClass = computed(() => {
if (isAdmin.value) return 'badge-error'
if (canEdit.value) return 'badge-warning'
return 'badge-info'
})
const activeProfileLabel = computed(() => { const activeProfileLabel = computed(() => {
if (!activeProfile.value) { if (!activeProfile.value) {
return 'Profil inconnu' return 'Profil inconnu'

View File

@@ -60,6 +60,8 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye' import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer' import IconLucidePrinter from '~icons/lucide/printer'
const { canEdit } = usePermissions()
defineProps<{ defineProps<{
title: string title: string
isDetailsView: boolean isDetailsView: boolean

View File

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

View File

@@ -120,17 +120,16 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
:value="field.value ?? ''"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm"
:checked="String(field.value).toLowerCase() === 'true'" :checked="String(field.value).toLowerCase() === 'true'"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')" @change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
@blur="$emit('update-custom-field', field)" @blur="$emit('update-custom-field', field)"
/> >
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:value="field.value ?? ''" :value="field.value ?? ''"

View File

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

View File

@@ -29,7 +29,7 @@
class="select select-bordered w-full" class="select select-bordered w-full"
name="category" name="category"
required required
:disabled="lockCategory" :disabled="lockCategory || isReadonly"
> >
<option value="COMPONENT">Composants</option> <option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option> <option value="PIECE">Pièces</option>
@@ -134,7 +134,7 @@
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')"> <button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled"> <button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span> <span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
{{ submitLabel }} {{ submitLabel }}
</button> </button>
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<{
disableSubmitMessage?: string disableSubmitMessage?: string
restrictedMode?: boolean restrictedMode?: boolean
restrictedModeMessage?: string restrictedModeMessage?: string
readonly?: boolean
}>(), { }>(), {
initialData: null, initialData: null,
saving: false, saving: false,
@@ -187,6 +188,7 @@ const props = withDefaults(defineProps<{
disableSubmitMessage: '', disableSubmitMessage: '',
restrictedMode: false, restrictedMode: false,
restrictedModeMessage: '', restrictedModeMessage: '',
readonly: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -209,7 +211,8 @@ const disableSubmitMessage = computed(() =>
? props.disableSubmitMessage ? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.', : 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
) )
const restrictedMode = computed(() => props.restrictedMode === true) const isReadonly = computed(() => props.readonly === true)
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
const restrictedModeMessage = computed(() => const restrictedModeMessage = computed(() =>
(props.restrictedModeMessage && props.restrictedModeMessage.trim()) (props.restrictedModeMessage && props.restrictedModeMessage.trim())
? props.restrictedModeMessage ? props.restrictedModeMessage
@@ -291,7 +294,7 @@ const resetForm = () => {
} }
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer')) const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value) const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
const validate = () => { const validate = () => {
errors.name = undefined errors.name = undefined
@@ -308,6 +311,7 @@ const validate = () => {
} }
const handleSubmit = () => { const handleSubmit = () => {
if (isReadonly.value) return
if (!validate()) { if (!validate()) {
return return
} }

View File

@@ -49,7 +49,7 @@
Liés Liés
</button> </button>
<button <button
v-if="showConvertButton" v-if="canEdit && showConvertButton"
type="button" type="button"
class="btn btn-ghost btn-sm text-warning" class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)" @click="emit('convert', item)"
@@ -60,7 +60,7 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)"> <button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer Supprimer
</button> </button>
</td> </td>
@@ -88,7 +88,7 @@
Liés Liés
</button> </button>
<button <button
v-if="showConvertButton" v-if="canEdit && showConvertButton"
type="button" type="button"
class="btn btn-ghost btn-sm text-warning" class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)" @click="emit('convert', item)"
@@ -99,7 +99,7 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)"> <button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer Supprimer
</button> </button>
</footer> </footer>
@@ -146,6 +146,7 @@ const props = defineProps<{
limit: number; limit: number;
offset: number; offset: number;
category?: ModelCategory; category?: ModelCategory;
canEdit?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -83,13 +83,14 @@ import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt'; type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
category: ModelCategory; category: ModelCategory;
search: string; search: string;
sort: SortField; sort: SortField;
dir: SortDirection; dir: SortDirection;
loading?: boolean; loading?: boolean;
showCategoryTabs?: boolean; showCategoryTabs?: boolean;
canEdit?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -37,9 +37,9 @@
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-outline" @click="emit('edit', site)"> <button class="btn btn-sm btn-outline" @click="emit('edit', site)">
Modifier {{ canEdit ? 'Modifier' : 'Consulter' }}
</button> </button>
<button class="btn btn-sm btn-error" @click="emit('delete', site)"> <button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
Supprimer Supprimer
</button> </button>
</div> </div>
@@ -55,6 +55,8 @@ import IconLucidePhone from '~icons/lucide/phone'
import IconLucideUser from '~icons/lucide/user' import IconLucideUser from '~icons/lucide/user'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
const { canEdit } = usePermissions()
const props = defineProps({ const props = defineProps({
site: { site: {
type: Object, type: Object,

View File

@@ -9,11 +9,12 @@
type="text" type="text"
placeholder="Nom et prénom" placeholder="Nom et prénom"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
<FieldPhone v-model="contactPhone" required /> <FieldPhone v-model="contactPhone" :disabled="disabled" required />
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -24,6 +25,7 @@
type="text" type="text"
placeholder="Adresse complète" placeholder="Adresse complète"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
@@ -38,6 +40,7 @@
type="text" type="text"
placeholder="Code postal" placeholder="Code postal"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
@@ -51,6 +54,7 @@
type="text" type="text"
placeholder="Ville" placeholder="Ville"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
@@ -77,6 +81,10 @@ const props = defineProps({
type: Object as PropType<SiteForm>, type: Object as PropType<SiteForm>,
required: true, required: true,
}, },
disabled: {
type: Boolean,
default: false,
},
}) })
const form = toRef(props, 'form') const form = toRef(props, 'form')

View File

@@ -12,17 +12,18 @@
type="text" type="text"
placeholder="Ex: Usine principale" placeholder="Ex: Usine principale"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
<SiteContactFormFields :form="siteRef" /> <SiteContactFormFields :form="siteRef" :disabled="disabled" />
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" @click="emit('close')"> <button type="button" class="btn" @click="emit('close')">
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" :disabled="disabled">
Créer le site Créer le site
</button> </button>
</div> </div>
@@ -53,6 +54,10 @@ const props = defineProps({
site: { site: {
type: Object as PropType<SiteForm>, type: Object as PropType<SiteForm>,
required: true required: true
},
disabled: {
type: Boolean,
default: false
} }
}) })

View File

@@ -2,7 +2,7 @@
<div v-if="visible" class="modal modal-open"> <div v-if="visible" class="modal modal-open">
<div class="modal-box max-w-md"> <div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4"> <h3 class="font-bold text-lg mb-4">
Modifier le site {{ disabled ? 'Détails du site' : 'Modifier le site' }}
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span> <span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
</h3> </h3>
<form class="space-y-4" @submit.prevent="emit('submit')"> <form class="space-y-4" @submit.prevent="emit('submit')">
@@ -15,11 +15,12 @@
type="text" type="text"
placeholder="Nom du site" placeholder="Nom du site"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
> >
</div> </div>
<SiteContactFormFields :form="props.form" /> <SiteContactFormFields :form="props.form" :disabled="disabled" />
<div class="border-t border-base-200 pt-4 space-y-4"> <div class="border-t border-base-200 pt-4 space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -37,6 +38,7 @@
</div> </div>
<DocumentUpload <DocumentUpload
v-if="!disabled"
v-model="selectedFilesModel" v-model="selectedFilesModel"
title="Déposer vos fichiers" title="Déposer vos fichiers"
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..." subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
@@ -55,8 +57,8 @@
<div class="flex items-center gap-3 text-sm"> <div class="flex items-center gap-3 text-sm">
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"> <div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -90,7 +92,7 @@
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)"> <button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
Télécharger Télécharger
</button> </button>
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)"> <button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
Supprimer Supprimer
</button> </button>
</div> </div>
@@ -103,7 +105,7 @@
<button type="button" class="btn" @click="emit('close')"> <button type="button" class="btn" @click="emit('close')">
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments"> <button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" /> <span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
Enregistrer Enregistrer
</button> </button>
@@ -114,7 +116,7 @@
</template> </template>
<script setup> <script setup>
import { computed, toRefs } from 'vue' import { computed } from 'vue'
import { isImageDocument } from '~/utils/documentPreview' import { isImageDocument } from '~/utils/documentPreview'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue' import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
@@ -155,6 +157,10 @@ const props = defineProps({
formatSize: { formatSize: {
type: Function, type: Function,
required: true required: true
},
disabled: {
type: Boolean,
default: false
} }
}) })
@@ -167,8 +173,6 @@ const emit = defineEmits([
'update:selectedFiles' 'update:selectedFiles'
]) ])
const form = toRefs(props.form)
const selectedFilesModel = computed({ const selectedFilesModel = computed({
get: () => props.selectedFiles, get: () => props.selectedFiles,
set: value => emit('update:selectedFiles', value) set: value => emit('update:selectedFiles', value)

View File

@@ -0,0 +1,80 @@
import { ref } from 'vue'
import { useApi } from './useApi'
export interface AdminProfile {
id: string
firstName: string
lastName: string
email: string | null
isActive: boolean
hasPassword: boolean
roles: string[]
createdAt: string
updatedAt: string
}
export function useAdminProfiles() {
const { get, post, put } = useApi()
const profiles = ref<AdminProfile[]>([])
const loading = ref(false)
const fetchAll = async () => {
loading.value = true
try {
const result = await get<AdminProfile[]>('/admin/profiles')
if (result.success && result.data) {
profiles.value = result.data
}
} finally {
loading.value = false
}
}
const createProfile = async (data: {
firstName: string
lastName: string
email?: string
password?: string
role?: string
}) => {
const result = await post<AdminProfile>('/admin/profiles', data)
if (result.success) {
await fetchAll()
}
return result
}
const updateRole = async (id: string, role: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
if (result.success) {
await fetchAll()
}
return result
}
const setPassword = async (id: string, password: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
if (result.success) {
await fetchAll()
}
return result
}
const deactivateProfile = async (id: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
if (result.success) {
await fetchAll()
}
return result
}
return {
profiles,
loading,
fetchAll,
createProfile,
updateRole,
setPassword,
deactivateProfile,
}
}

View File

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

View File

@@ -0,0 +1,190 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Comment {
id: string
content: string
entityType: string
entityId: string
entityName?: string | null
authorId: string
authorName: string
status: 'open' | 'resolved'
resolvedById?: string | null
resolvedByName?: string | null
resolvedAt?: string | null
createdAt: string
updatedAt: string
}
interface CommentResult {
success: boolean
data?: Comment | Comment[]
error?: string
}
interface CommentListResult {
success: boolean
data?: Comment[]
total?: number
error?: string
}
export function useComments() {
const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loading = ref(false)
const fetchComments = async (
entityType: string,
entityId: string,
status: string = 'open',
): Promise<CommentListResult> => {
loading.value = true
try {
const params = new URLSearchParams({
entityType,
entityId,
status,
'order[createdAt]': 'desc',
itemsPerPage: '200',
})
const result = await get(`/comments?${params.toString()}`)
if (result.success) {
const items = extractCollection<Comment>(result.data)
return { success: true, data: items }
}
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const fetchAllComments = async (options: {
status?: string
entityType?: string
entityName?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: string
} = {}): Promise<CommentListResult> => {
loading.value = true
try {
const params = new URLSearchParams()
if (options.status) params.set('status', options.status)
if (options.entityType) params.set('entityType', options.entityType)
if (options.entityName) params.set('entityName', options.entityName)
const sortField = options.orderBy || 'createdAt'
const sortDir = options.orderDir || 'desc'
params.set(`order[${sortField}]`, sortDir)
params.set('itemsPerPage', String(options.itemsPerPage || 30))
params.set('page', String(options.page || 1))
const result = await get(`/comments?${params.toString()}`)
if (result.success) {
const items = extractCollection<Comment>(result.data)
const raw = result.data as Record<string, unknown> | null
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
return { success: true, data: items, total }
}
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const createComment = async (
entityType: string,
entityId: string,
content: string,
entityName?: string,
): Promise<CommentResult> => {
loading.value = true
try {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
const result = await post('/comments', payload)
if (result.success) {
showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible d\'ajouter le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const resolveComment = async (commentId: string): Promise<CommentResult> => {
loading.value = true
try {
const result = await patch(`/comments/${commentId}/resolve`)
if (result.success) {
showSuccess('Commentaire résolu')
return { success: true, data: result.data as Comment }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible de résoudre le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const deleteComment = async (commentId: string): Promise<CommentResult> => {
loading.value = true
try {
const result = await del(`/comments/${commentId}`)
if (result.success) {
showSuccess('Commentaire supprimé')
return { success: true }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible de supprimer le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const fetchUnresolvedCount = async (): Promise<number> => {
try {
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
if (result.success && result.data) {
return result.data.count
}
return 0
} catch {
return 0
}
}
return {
loading,
fetchComments,
fetchAllComments,
createComment,
resolveComment,
deleteComment,
fetchUnresolvedCount,
}
}

View File

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

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

View File

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

View File

@@ -15,6 +15,7 @@ import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections' import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
import { import {
useMachineCreatePreview, useMachineCreatePreview,
@@ -34,7 +35,7 @@ export function useMachineCreatePage() {
// Composable calls // Composable calls
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines() const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi() const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
const { composants, loadComposants, loading: composantsLoading } = useComposants() const { composants, loadComposants, loading: composantsLoading } = useComposants()
@@ -340,17 +341,24 @@ export function useMachineCreatePage() {
: await createMachineFromType(baseMachineData as any, type) : await createMachineFromType(baseMachineData as any, type)
if (result.success) { if (result.success) {
if (hasRequirements && result.data?.id) { const machineId = result.data?.id
const skeletonResult: any = await reconfigureSkeleton(result.data.id, { if (hasRequirements && machineId) {
const skeletonResult: any = await reconfigureSkeleton(machineId, {
componentLinks, componentLinks,
pieceLinks, pieceLinks,
productLinks, productLinks,
} as any) } as any)
if (!skeletonResult.success) { if (!skeletonResult.success) {
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants') // 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 return
} }
} }
// Initialize custom fields for the machine type
if (machineId) {
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
}
newMachine.name = '' newMachine.name = ''
newMachine.siteId = '' newMachine.siteId = ''
newMachine.typeMachineId = '' newMachine.typeMachineId = ''
@@ -358,10 +366,10 @@ export function useMachineCreatePage() {
clearRequirementSelections() clearRequirementSelections()
await navigateTo('/machines') await navigateTo('/machines')
} else if (result.error) { } else if (result.error) {
toast.showError(`Impossible de créer la machine: ${result.error}`) toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(`Erreur lors de la création: ${error.message}`) toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -386,9 +394,9 @@ export function useMachineCreatePage() {
await Promise.all([ await Promise.all([
loadSites(), loadSites(),
loadMachineTypes(), loadMachineTypes(),
loadComposants(), loadComposants({ itemsPerPage: 200, force: true }),
loadPieces(), loadPieces({ itemsPerPage: 200, force: true }),
loadProducts(), loadProducts({ itemsPerPage: 200, force: true }),
]) ])
}) })

View File

@@ -0,0 +1,41 @@
import { computed } from 'vue'
import { useProfileSession } from './useProfileSession'
const ROLE_HIERARCHY: Record<string, string[]> = {
ROLE_ADMIN: ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
ROLE_GESTIONNAIRE: ['ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
ROLE_VIEWER: ['ROLE_VIEWER', 'ROLE_USER'],
ROLE_USER: ['ROLE_USER'],
}
export function usePermissions() {
const { activeProfile } = useProfileSession()
const effectiveRoles = computed<string[]>(() => {
const roles = (activeProfile.value?.roles as string[] | undefined) ?? ['ROLE_USER']
const all = new Set<string>()
for (const role of roles) {
const inherited = ROLE_HIERARCHY[role] ?? [role]
for (const r of inherited) {
all.add(r)
}
}
return [...all]
})
const isGranted = (role: string): boolean => {
return effectiveRoles.value.includes(role)
}
const isAdmin = computed(() => isGranted('ROLE_ADMIN'))
const canEdit = computed(() => isGranted('ROLE_GESTIONNAIRE'))
const canView = computed(() => isGranted('ROLE_VIEWER'))
return {
isAdmin,
canEdit,
canView,
isGranted,
effectiveRoles,
}
}

View File

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

View File

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

View File

@@ -1,12 +1,9 @@
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports' import { useState, useRuntimeConfig } from '#imports'
import type { Profile } from './useProfiles' import type { Profile } from './useProfiles'
const buildUrl = (path: string): string => { const buildUrl = (path: string): string => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const baseUrl = import.meta.server const base = ((config.public.apiBaseUrl as string) || '').replace(/\/$/, '')
? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
: ((config.public.apiBaseUrl as string) || '')
const base = baseUrl.replace(/\/$/, '')
return `${base}${path}` return `${base}${path}`
} }
@@ -15,19 +12,12 @@ export function useProfileSession() {
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false) const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
const loading = useState<boolean>('profileSession:loading', () => false) const loading = useState<boolean>('profileSession:loading', () => false)
const getSessionHeaders = (): Record<string, string> | undefined => {
if (!import.meta.server) { return undefined }
const headers = useRequestHeaders(['cookie'])
return headers?.cookie ? { cookie: headers.cookie } : undefined
}
const fetchCurrentProfile = async (): Promise<Profile | null> => { const fetchCurrentProfile = async (): Promise<Profile | null> => {
loading.value = true loading.value = true
try { try {
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), { activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
headers: getSessionHeaders(),
}) })
} catch (error) { } catch (error) {
const err = error as { status?: number } const err = error as { status?: number }
@@ -51,12 +41,15 @@ export function useProfileSession() {
return Promise.resolve(activeProfile.value) return Promise.resolve(activeProfile.value)
} }
const activateProfile = async (profileId: string): Promise<void> => { const activateProfile = async (profileId: string, password?: string): Promise<void> => {
const body: Record<string, string> = { profileId }
if (password) {
body.password = password
}
await $fetch(buildUrl('/session/profile'), { await $fetch(buildUrl('/session/profile'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: { profileId }, body,
headers: getSessionHeaders(),
}) })
await fetchCurrentProfile() await fetchCurrentProfile()
} }
@@ -66,7 +59,6 @@ export function useProfileSession() {
await $fetch(buildUrl('/session/profile'), { await $fetch(buildUrl('/session/profile'), {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
headers: getSessionHeaders(),
}) })
} finally { } finally {
activeProfile.value = null activeProfile.value = null

View File

@@ -1,9 +1,13 @@
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports' import { useState, useRuntimeConfig } from '#imports'
export interface Profile { export interface Profile {
id: string id: string
firstName: string firstName: string
lastName: string lastName: string
email?: string | null
isActive?: boolean
hasPassword?: boolean
roles?: string[]
[key: string]: unknown [key: string]: unknown
} }
@@ -18,19 +22,12 @@ export function useProfiles() {
const loadingProfiles = useState<boolean>('profiles:loading', () => false) const loadingProfiles = useState<boolean>('profiles:loading', () => false)
const profilesLoaded = useState<boolean>('profiles:loaded', () => false) const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
const getSessionHeaders = (): Record<string, string> | undefined => {
if (!import.meta.server) { return undefined }
const headers = useRequestHeaders(['cookie'])
return headers?.cookie ? { cookie: headers.cookie } : undefined
}
const fetchProfiles = async (): Promise<Profile[]> => { const fetchProfiles = async (): Promise<Profile[]> => {
loadingProfiles.value = true loadingProfiles.value = true
try { try {
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), { profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
headers: getSessionHeaders(),
}) })
profilesLoaded.value = true profilesLoaded.value = true
} catch (error) { } catch (error) {
@@ -43,32 +40,10 @@ export function useProfiles() {
return profiles.value return profiles.value
} }
const createProfile = async ({ firstName, lastName }: { firstName: string; lastName: string }): Promise<Profile> => {
const profile = await $fetch<Profile>(buildUrl('/session/profiles'), {
method: 'POST',
credentials: 'include',
body: { firstName, lastName },
headers: getSessionHeaders(),
})
await fetchProfiles()
return profile
}
const deleteProfile = async (profileId: string): Promise<void> => {
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
method: 'DELETE',
credentials: 'include',
headers: getSessionHeaders(),
})
await fetchProfiles()
}
return { return {
profiles, profiles,
loadingProfiles, loadingProfiles,
profilesLoaded, profilesLoaded,
fetchProfiles, fetchProfiles,
createProfile,
deleteProfile,
} }
} }

View File

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

View File

@@ -13,8 +13,19 @@ const toasts = ref<Toast[]>([])
const MAX_TOASTS = 3 const MAX_TOASTS = 3
let nextId = 1 let nextId = 1
// Anti-doublon : ignore un toast identique affiché dans les 2 dernières secondes
const recentMessages = new Map<string, number>()
const DEDUP_WINDOW = 2000
export function useToast() { export function useToast() {
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => { const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
const dedupKey = `${type}::${message}`
const lastShown = recentMessages.get(dedupKey)
if (lastShown && Date.now() - lastShown < DEDUP_WINDOW) {
return -1
}
recentMessages.set(dedupKey, Date.now())
const id = nextId++ const id = nextId++
const toast: Toast = { const toast: Toast = {
id, id,

View File

@@ -1,8 +1,7 @@
import { useProfileSession } from "#imports"; import { useProfileSession, usePermissions } from "#imports";
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
const { ensureSession, fetchCurrentProfile, activeProfile } = const { ensureSession, activeProfile } = useProfileSession();
useProfileSession();
await ensureSession(); await ensureSession();
const rawPath = to?.path ?? ""; const rawPath = to?.path ?? "";
@@ -14,11 +13,21 @@ export default defineNuxtRouteMiddleware(async (to) => {
fullPath.startsWith("/profiles") || fullPath.startsWith("/profiles") ||
routeName.startsWith("profiles"); routeName.startsWith("profiles");
if (process.client && !activeProfile.value) { // Redirect to login if no active profile
await fetchCurrentProfile(); if (!activeProfile.value && !isProfilesRoute) {
}
if (process.client && !activeProfile.value && !isProfilesRoute) {
return navigateTo("/profiles"); return navigateTo("/profiles");
} }
// Permission checks
if (activeProfile.value) {
const { isAdmin } = usePermissions();
// Admin-only routes
if (normalizedPath.startsWith("/admin")) {
if (!isAdmin.value) {
return navigateTo("/");
}
}
}
}); });

View File

@@ -9,8 +9,24 @@
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <DataTable
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center"> :columns="columns"
:rows="entries"
:loading="loading"
:pagination="paginationState"
:show-per-page="true"
:show-counter="true"
:expandable="true"
:expanded-keys="expandedIds"
:can-expand="canExpandRow"
row-key="id"
empty-message="Aucune activité enregistrée."
no-results-message="Aucune activité ne correspond à vos filtres."
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@toggle-expand="toggleExpanded"
>
<template #toolbar>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label <label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70" class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
@@ -22,7 +38,7 @@
id="activity-entity-type" id="activity-entity-type"
v-model="entityTypeFilter" v-model="entityTypeFilter"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleFilterChange" @change="table.handleFilterChange"
> >
<option value="">Tous</option> <option value="">Tous</option>
<option value="piece">Pièce</option> <option value="piece">Pièce</option>
@@ -42,7 +58,7 @@
id="activity-action" id="activity-action"
v-model="actionFilter" v-model="actionFilter"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleFilterChange" @change="table.handleFilterChange"
> >
<option value="">Toutes</option> <option value="">Toutes</option>
<option value="create">Création</option> <option value="create">Création</option>
@@ -50,157 +66,111 @@
<option value="delete">Suppression</option> <option value="delete">Suppression</option>
</select> </select>
</div> </div>
</template>
<div class="flex items-center gap-2"> <template #cell-createdAt="{ row }">
<label <span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
class="text-xs font-semibold uppercase tracking-wide text-base-content/70" </template>
for="activity-per-page"
<template #cell-action="{ row }">
<span
class="badge badge-sm"
:class="actionBadgeClass(row.action)"
>
{{ historyActionLabel(row.action) }}
</span>
</template>
<template #cell-entityType="{ row }">
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(row.entityType) }}
</span>
</template>
<template #cell-entity="{ row }">
<NuxtLink
v-if="row.action !== 'delete'"
:to="entityEditLink(row)"
class="link link-hover link-primary"
>
{{ row.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ row.entityName || 'Sans nom' }}
</span>
<span
v-if="row.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ row.entityRef }})
</span>
</template>
<template #cell-author="{ row }">
{{ row.actor?.label || '—' }}
</template>
<template #row-expanded="{ row }">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(row, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
> >
Par page <span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
</label> <span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<select <span></span>
id="activity-per-page" <span class="text-success">{{ diffEntry.toLabel }}</span>
v-model.number="itemsPerPage" </div>
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div> </div>
</div> </template>
</DataTable>
<p class="text-xs text-base-content/50 lg:text-right">
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!total" class="text-sm text-base-content/70">
Aucune activité enregistrée.
</p>
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
Aucune activité ne correspond à vos filtres.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Type</th>
<th>Entité</th>
<th>Auteur</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
<template v-for="entry in entries" :key="entry.id">
<tr>
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
<td>
<span
class="badge badge-sm"
:class="actionBadgeClass(entry.action)"
>
{{ historyActionLabel(entry.action) }}
</span>
</td>
<td>
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(entry.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="entry.action !== 'delete'"
:to="entityEditLink(entry)"
class="link link-hover link-primary"
>
{{ entry.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ entry.entityName || 'Sans nom' }}
</span>
<span
v-if="entry.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ entry.entityRef }})
</span>
</td>
<td>{{ entry.actor?.label || '—' }}</td>
<td>
<button
v-if="hasDiff(entry)"
type="button"
class="btn btn-ghost btn-xs"
@click="toggleExpanded(entry.id)"
>
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
</button>
<span v-else class="text-xs text-base-content/50"></span>
</td>
</tr>
<tr v-if="expandedIds.has(entry.id)">
<td colspan="6" class="bg-base-200/50 p-4">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
>
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<span></span>
<span class="text-success">{{ diffEntry.toLabel }}</span>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useActivityLog } from '~/composables/useActivityLog' import { useActivityLog } from '~/composables/useActivityLog'
import type { ActivityLogEntry } from '~/composables/useActivityLog' import type { ActivityLogEntry } from '~/composables/useActivityLog'
import { useDataTable } from '~/composables/useDataTable'
import { import {
historyActionLabel, historyActionLabel,
formatHistoryDate, formatHistoryDate,
historyDiffEntries, historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils' } from '~/shared/utils/historyDisplayUtils'
import Pagination from '~/components/common/Pagination.vue'
const { entries, total, loading, loadActivityLog } = useActivityLog() const { entries, total, loading, loadActivityLog } = useActivityLog()
const currentPage = ref(1) const table = useDataTable(
const itemsPerPage = ref(50) { fetchData: fetchLog },
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1) {
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 50,
persistToUrl: true,
extraParams: {
entityType: { default: '' },
action: { default: '' },
},
},
)
const entityTypeFilter = ref('') const entityTypeFilter = table.filters.entityType as Ref<string>
const actionFilter = ref('') const actionFilter = table.filters.action as Ref<string>
const entriesOnPage = computed(() => entries.value.length)
const paginationState = table.pagination(total, entriesOnPage)
const columns = [
{ key: 'createdAt', label: 'Date' },
{ key: 'action', label: 'Action' },
{ key: 'entityType', label: 'Type' },
{ key: 'entity', label: 'Entité' },
{ key: 'author', label: 'Auteur' },
]
const expandedIds = reactive(new Set<string>()) const expandedIds = reactive(new Set<string>())
@@ -209,28 +179,18 @@ const toggleExpanded = (id: string) => {
else expandedIds.add(id) else expandedIds.add(id)
} }
const hasDiff = (entry: ActivityLogEntry) => const canExpandRow = (row: any) =>
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0 row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
const fetchLog = () => { function fetchLog() {
loadActivityLog({ loadActivityLog({
page: currentPage.value, page: table.currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: table.itemsPerPage.value,
entityType: entityTypeFilter.value || undefined, entityType: entityTypeFilter.value || undefined,
action: actionFilter.value || undefined, action: actionFilter.value || undefined,
}) })
} }
const handleFilterChange = () => {
currentPage.value = 1
fetchLog()
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchLog()
}
const ENTITY_TYPE_LABELS: Record<string, string> = { const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce', piece: 'Pièce',
product: 'Produit', product: 'Produit',

270
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,270 @@
<template>
<div class="container mx-auto p-6 max-w-6xl">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">
Administration des profils
</h1>
<button class="btn btn-primary btn-sm" @click="showCreateDialog = true">
Nouveau profil
</button>
</div>
<DataTable
:columns="columns"
:rows="sortedProfiles"
:loading="isLoading"
:sort="sortState"
:show-counter="false"
table-class="table-zebra"
empty-message="Aucun profil."
@sort="handleSort"
>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.firstName }} {{ row.lastName }}</span>
</template>
<template #cell-email="{ row }">
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
</template>
<template #cell-role="{ row }">
<select
class="select select-bordered select-xs"
:value="primaryRole(row)"
@change="handleRoleChange(row.id, $event.target.value)"
>
<option value="ROLE_ADMIN">Admin</option>
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
<option value="ROLE_VIEWER">Viewer</option>
</select>
</template>
<template #cell-password="{ row }">
<span v-if="row.hasPassword" class="badge badge-success badge-sm">Oui</span>
<span v-else class="badge badge-ghost badge-sm">Non</span>
<button
class="btn btn-ghost btn-xs ml-1"
@click="openPasswordDialog(row.id)"
>
{{ row.hasPassword ? 'Changer' : 'Definir' }}
</button>
</template>
<template #cell-status="{ row }">
<span
class="badge badge-sm"
:class="row.isActive ? 'badge-success' : 'badge-error'"
>
{{ row.isActive ? 'Actif' : 'Inactif' }}
</span>
</template>
<template #cell-actions="{ row }">
<button
v-if="row.isActive"
class="btn btn-ghost btn-xs text-error"
@click="handleDeactivate(row.id)"
>
Desactiver
</button>
</template>
</DataTable>
<!-- Create Profile Dialog -->
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Nouveau profil
</h3>
<form @submit.prevent="handleCreate">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Prenom</span></label>
<input v-model="createForm.firstName" type="text" class="input input-bordered" required>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="createForm.lastName" type="text" class="input input-bordered" required>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Email</span></label>
<input v-model="createForm.email" type="email" class="input input-bordered">
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Mot de passe</span></label>
<input v-model="createForm.password" type="password" class="input input-bordered">
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Role</span></label>
<select v-model="createForm.role" class="select select-bordered">
<option value="ROLE_ADMIN">Admin</option>
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
<option value="ROLE_VIEWER">Viewer</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="showCreateDialog = false">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="creating">
<span v-if="creating" class="loading loading-spinner loading-xs" />
Creer
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" @click="showCreateDialog = false">
close
</button>
</form>
</dialog>
<!-- Set Password Dialog -->
<dialog ref="passwordDialog" class="modal" :open="showPasswordDialog || undefined">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Definir le mot de passe
</h3>
<form @submit.prevent="handleSetPassword">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nouveau mot de passe</span></label>
<input v-model="newPassword" type="password" class="input input-bordered" required>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="showPasswordDialog = false">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="settingPassword">
<span v-if="settingPassword" class="loading loading-spinner loading-xs" />
Valider
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" @click="showPasswordDialog = false">
close
</button>
</form>
</dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useAdminProfiles } from '#imports'
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
const loaded = ref(false)
const isLoading = computed(() => loading.value || !loaded.value)
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Role', sortable: true },
{ key: 'password', label: 'Mot de passe' },
{ key: 'status', label: 'Statut', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const sortState = ref({ field: 'name', direction: 'asc' })
const handleSort = (sort) => {
sortState.value = sort
}
const sortedProfiles = computed(() => {
const { field, direction } = sortState.value
const dir = direction === 'desc' ? -1 : 1
return [...profiles.value].sort((a, b) => {
let valA, valB
if (field === 'name') {
valA = `${a.firstName} ${a.lastName}`.toLowerCase()
valB = `${b.firstName} ${b.lastName}`.toLowerCase()
}
else if (field === 'role') {
valA = primaryRole(a)
valB = primaryRole(b)
}
else if (field === 'status') {
valA = a.isActive ? '1' : '0'
valB = b.isActive ? '1' : '0'
}
else {
valA = (a[field] || '').toLowerCase()
valB = (b[field] || '').toLowerCase()
}
return dir * valA.localeCompare(valB)
})
})
const showCreateDialog = ref(false)
const showPasswordDialog = ref(false)
const creating = ref(false)
const settingPassword = ref(false)
const passwordProfileId = ref(null)
const newPassword = ref('')
const createForm = ref({
firstName: '',
lastName: '',
email: '',
password: '',
role: 'ROLE_VIEWER',
})
const primaryRole = (profile) => {
const roles = profile.roles || []
if (roles.includes('ROLE_ADMIN')) { return 'ROLE_ADMIN' }
if (roles.includes('ROLE_GESTIONNAIRE')) { return 'ROLE_GESTIONNAIRE' }
return 'ROLE_VIEWER'
}
const handleCreate = async () => {
creating.value = true
try {
const data = { ...createForm.value }
if (!data.email) { delete data.email }
if (!data.password) { delete data.password }
await createProfile(data)
showCreateDialog.value = false
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
}
finally {
creating.value = false
}
}
const handleRoleChange = async (profileId, role) => {
await updateRole(profileId, role)
}
const openPasswordDialog = (profileId) => {
passwordProfileId.value = profileId
newPassword.value = ''
showPasswordDialog.value = true
}
const handleSetPassword = async () => {
if (!passwordProfileId.value) { return }
settingPassword.value = true
try {
await setPassword(passwordProfileId.value, newPassword.value)
showPasswordDialog.value = false
}
finally {
settingPassword.value = false
}
}
const handleDeactivate = async (profileId) => {
await deactivateProfile(profileId)
}
onMounted(async () => {
await fetchAll()
loaded.value = true
})
</script>

View File

@@ -69,6 +69,46 @@ const badgeClass = (type: ChangeType) => {
} }
const releases: Release[] = [ const releases: Release[] = [
{
version: 'v1.8.0',
date: '2026-03-03',
changes: [
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
],
},
{
version: 'v1.7.0',
date: '2026-03-02',
changes: [
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
],
},
{
version: 'v1.6.1',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
],
},
{ {
version: 'v1.6.0', version: 'v1.6.0',
date: '2026-02-12', date: '2026-02-12',
@@ -162,7 +202,7 @@ const releases: Release[] = [
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' }, { type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' }, { type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' }, { type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' }, { type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' }, { type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' }, { type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' }, { type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },

256
app/pages/comments.vue Normal file
View File

@@ -0,0 +1,256 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header>
<h1 class="text-3xl font-semibold text-base-content">
Commentaires
</h1>
<p class="text-sm text-gray-500">
Liste de tous les commentaires et tickets ouverts sur les fiches.
</p>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<DataTable
:columns="columns"
:rows="comments"
:loading="loadingList"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun commentaire trouvé."
no-results-message="Aucun commentaire trouvé."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="comment-status"
>
Statut
</label>
<select
id="comment-status"
v-model="statusFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="open">Ouverts</option>
<option value="resolved">Résolus</option>
<option value="">Tous</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="comment-entity-type"
>
Type
</label>
<select
id="comment-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="">Tous</option>
<option value="machine">Machine</option>
<option value="piece">Pièce</option>
<option value="composant">Composant</option>
<option value="product">Produit</option>
<option value="piece_category">Catégorie pièce</option>
<option value="component_category">Catégorie composant</option>
<option value="product_category">Catégorie produit</option>
<option value="machine_skeleton">Squelette machine</option>
</select>
</div>
</template>
<template #cell-content="{ row }">
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
</template>
<template #cell-entityType="{ row }">
<span class="badge badge-outline badge-sm">
{{ entityTypeLabel(row.entityType) }}
</span>
</template>
<template #cell-entity="{ row }">
<NuxtLink
v-if="getEntityRoute(row)"
:to="getEntityRoute(row)!"
class="link link-primary text-sm font-medium"
>
{{ row.entityName || row.entityId }}
</NuxtLink>
<span v-else class="text-sm">
{{ row.entityName || row.entityId }}
</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatCommentDate(row.createdAt) }}</span>
</template>
<template #cell-status="{ row }">
<span
class="badge badge-sm"
:class="row.status === 'open' ? 'badge-warning' : 'badge-success'"
>
{{ row.status === 'open' ? 'Ouvert' : 'Résolu' }}
</span>
</template>
<template v-if="canEdit" #cell-actions="{ row }">
<button
v-if="row.status === 'open'"
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(row.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<span v-else class="text-xs text-base-content/50">
{{ row.resolvedByName }}
</span>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComments, type Comment } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check'
const { canEdit } = usePermissions()
const {
loading,
fetchAllComments,
resolveComment,
} = useComments()
const comments = ref<Comment[]>([])
const total = ref(0)
const loadingList = ref(true)
const table = useDataTable(
{ fetchData: loadComments },
{
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 20,
persistToUrl: true,
extraParams: {
status: { default: 'open' },
entityType: { default: '' },
},
},
)
const statusFilter = table.filters.status as Ref<string>
const entityTypeFilter = table.filters.entityType as Ref<string>
const commentsOnPage = computed(() => comments.value.length)
const paginationState = table.pagination(total, commentsOnPage)
const columns = computed(() => {
const cols = [
{ key: 'content', label: 'Contenu', class: 'max-w-xs' },
{ key: 'entityType', label: 'Type' },
{ key: 'entity', label: 'Item', filterable: true, filterPlaceholder: 'Rechercher…' },
{ key: 'authorName', label: 'Auteur', sortable: true, sortKey: 'authorName' },
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
{ key: 'status', label: 'Statut', sortable: true, sortKey: 'status' },
]
if (canEdit.value) {
cols.push({ key: 'actions', label: 'Actions' })
}
return cols
})
const ENTITY_TYPE_LABELS: Record<string, string> = {
machine: 'Machine',
piece: 'Pièce',
composant: 'Composant',
product: 'Produit',
piece_category: 'Cat. pièce',
component_category: 'Cat. composant',
product_category: 'Cat. produit',
machine_skeleton: 'Squelette',
}
const entityTypeLabel = (type: string): string =>
ENTITY_TYPE_LABELS[type] ?? type
const formatCommentDate = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
async function loadComments() {
loadingList.value = true
const result = await fetchAllComments({
status: statusFilter.value || undefined,
entityType: entityTypeFilter.value || undefined,
entityName: table.columnFilters.value.entity || undefined,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value,
})
if (result.success) {
comments.value = result.data ?? []
total.value = result.total ?? 0
}
loadingList.value = false
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {
await loadComments()
}
}
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
machine: (id: string) => `/machine/${id}`,
piece: (id: string) => `/pieces/${id}/edit`,
composant: (id: string) => `/component/${id}/edit`,
product: (id: string) => `/product/${id}/edit`,
piece_category: (id: string) => `/piece-category/${id}/edit`,
component_category: (id: string) => `/component-category/${id}/edit`,
product_category: (id: string) => `/product-category/${id}/edit`,
machine_skeleton: (id: string) => `/type/${id}`,
}
const getEntityRoute = (comment: Comment): string | null => {
const builder = ENTITY_ROUTE_MAP[comment.entityType]
return builder ? builder(comment.entityId) : null
}
onMounted(() => {
loadComments()
})
</script>

View File

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

View File

@@ -26,6 +26,7 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked" :disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage" :disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode" :restricted-mode="isRestrictedMode"
@@ -34,6 +35,16 @@
@cancel="handleCancel" @cancel="handleCancel"
/> />
</section> </section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="component_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
</main> </main>
</template> </template>
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
@@ -128,6 +140,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) { if (guardSubmitOrNotify()) {
return return
} }

View File

@@ -20,6 +20,7 @@
initial-category="COMPONENT" initial-category="COMPONENT"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes' import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({ useHead(() => ({
title: 'Nouvelle catégorie de composant', title: 'Nouvelle catégorie de composant',
})) }))
@@ -50,6 +53,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => { const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true saving.value = true
try { try {
const enrichedPayload = { const enrichedPayload = {

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
@@ -72,13 +73,26 @@
v-model="editionForm.name" v-model="editionForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue" placeholder="Nom affiché dans le catalogue"
required required
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -88,7 +102,7 @@
v-model="editionForm.reference" v-model="editionForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
@@ -100,7 +114,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="editionForm.constructeurIds" v-model="editionForm.constructeurIds"
class="w-full" class="w-full"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []" :initial-options="component?.constructeurs || []"
/> />
@@ -118,7 +132,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Valeur indicatrice" placeholder="Valeur indicatrice"
> >
</div> </div>
@@ -277,7 +291,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -286,14 +300,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -304,24 +318,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="saving" :disabled="!canEdit || saving"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else v-else
@@ -329,7 +343,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
</div> </div>
</div> </div>
@@ -347,7 +361,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload <DocumentUpload
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -373,8 +387,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -419,6 +433,7 @@
Télécharger Télécharger
</button> </button>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-error btn-xs"
:disabled="uploadingDocuments" :disabled="uploadingDocuments"
@@ -511,6 +526,16 @@
Enregistrer les modifications Enregistrer les modifications
</button> </button>
</div> </div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -566,6 +591,7 @@ interface ComponentCatalogType extends ModelType {
customFields?: Array<Record<string, any>> customFields?: Array<Record<string, any>>
} }
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
@@ -613,6 +639,7 @@ const historyDiffEntries = (entry: ComponentHistoryEntry) =>
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -746,6 +773,7 @@ const requiredCustomFieldsFilled = computed(() =>
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
canEdit.value &&
component.value && component.value &&
editionForm.name && editionForm.name &&
requiredCustomFieldsFilled.value && requiredCustomFieldsFilled.value &&
@@ -792,6 +820,7 @@ watch(
selectedTypeId.value = resolvedTypeId selectedTypeId.value = resolvedTypeId
editionForm.name = currentComponent.name || '' editionForm.name = currentComponent.name || ''
editionForm.description = currentComponent.description || ''
editionForm.reference = currentComponent.reference || '' editionForm.reference = currentComponent.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent, currentComponent,
@@ -832,6 +861,7 @@ const submitEdition = async () => {
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: editionForm.name.trim(), name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
} }
const reference = editionForm.reference.trim() const reference = editionForm.reference.trim()

View File

@@ -28,7 +28,7 @@
empty-text="Aucune catégorie disponible" empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel" :option-label="typeOptionLabel"
:option-description="typeOptionDescription" :option-description="typeOptionDescription"
:disabled="loadingTypes || submitting" :disabled="!canEdit || loadingTypes || submitting"
/> />
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1"> <p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories Chargement des catégories
@@ -45,13 +45,26 @@
v-model="creationForm.name" v-model="creationForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue" placeholder="Nom affiché dans le catalogue"
required required
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -61,7 +74,7 @@
v-model="creationForm.reference" v-model="creationForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
@@ -73,7 +86,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="creationForm.constructeurIds" v-model="creationForm.constructeurIds"
class="w-full" class="w-full"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
@@ -90,7 +103,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice" placeholder="Valeur indicatrice"
> >
</div> </div>
@@ -244,7 +257,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -253,14 +266,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -271,24 +284,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<input <input
v-else v-else
@@ -296,7 +309,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
</div> </div>
</div> </div>
@@ -314,7 +327,7 @@
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }} {{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': submitting }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload <DocumentUpload
v-model="selectedDocuments" v-model="selectedDocuments"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -357,6 +370,7 @@ import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes' import { useProductTypes } from '~/composables/useProductTypes'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
@@ -401,12 +415,14 @@ const {
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '') const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value) const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false) const submitting = ref(false)
const creationForm = reactive({ const creationForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -755,6 +771,7 @@ const requiredCustomFieldsFilled = computed(() =>
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value && selectedType.value &&
creationForm.name && creationForm.name &&
requiredCustomFieldsFilled.value && requiredCustomFieldsFilled.value &&
@@ -887,6 +904,7 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.description = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurIds = [] creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
@@ -904,6 +922,11 @@ const submitCreation = async () => {
typeComposantId: selectedType.value.id, typeComposantId: selectedType.value.id,
} }
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim() const reference = creationForm.reference.trim()
if (reference) { if (reference) {
payload.reference = reference payload.reference = reference
@@ -976,7 +999,7 @@ const submitCreation = async () => {
toast.showError(result.error) toast.showError(result.error)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du composant') toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
} finally { } finally {
submitting.value = false submitting.value = false
uploadingDocuments.value = false uploadingDocuments.value = false

View File

@@ -9,7 +9,7 @@
Gérez les fournisseurs et leurs coordonnées. Gérez les fournisseurs et leurs coordonnées.
</p> </p>
</div> </div>
<button class="btn btn-primary" @click="openCreateModal"> <button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" /> <IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Nouveau fournisseur Nouveau fournisseur
</button> </button>
@@ -17,95 +17,70 @@
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4"> <DataTable
<div class="flex-1"> :columns="columns"
<label class="label"><span class="label-text">Recherche</span></label> :rows="filteredConstructeurs"
<input :loading="loading"
v-model="searchTerm" :sort="currentSort"
type="search" :show-counter="false"
class="input input-bordered w-full" empty-message="Aucun fournisseur trouvé."
placeholder="Nom, email ou téléphone" no-results-message="Aucun fournisseur trouvé."
@input="debouncedSearch" @sort="handleSort"
> >
</div> <template #toolbar>
<div class="md:w-1/3"> <label class="w-full sm:w-72">
<label class="label"><span class="label-text">Tri</span></label> <span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<select v-model="sortKey" class="select select-bordered w-full"> <input
<option value="name"> v-model="searchTerm"
Nom type="search"
</option> class="input input-bordered input-sm w-full mt-1"
<option value="email"> placeholder="Nom, email ou téléphone"
Email @input="debouncedSearch"
</option> />
<option value="phone"> </label>
Téléphone </template>
</option>
</select>
</div>
</div>
<div v-if="loading" class="py-16 text-center text-sm text-gray-500"> <template #cell-phone="{ row }">
<span class="loading loading-spinner loading-lg mb-2" /> {{ formatPhoneDisplay(row.phone) }}
Chargement des fournisseurs... </template>
</div>
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500"> <template #cell-createdAt="{ row }">
Aucun fournisseur trouvé. <span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</div> </template>
<div v-else class="overflow-x-auto"> <template #cell-actions="{ row }">
<table class="table"> <div class="flex justify-end gap-2">
<thead> <button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
<tr class="text-xs uppercase"> {{ canEdit ? 'Modifier' : 'Consulter' }}
<th>Nom</th> </button>
<th>Email</th> <button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)">
<th>Téléphone</th> Supprimer
<th class="text-right"> </button>
Actions </div>
</th> </template>
</tr> </DataTable>
</thead>
<tbody>
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
<td>{{ constructeur.name }}</td>
<td>{{ constructeur.email || '—' }}</td>
<td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
Modifier
</button>
<button class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
<dialog class="modal" :class="{ 'modal-open': modalOpen }"> <dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg mb-4"> <h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur {{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
</h3> </h3>
<form class="space-y-4" @submit.prevent="saveConstructeur"> <form class="space-y-4" @submit.prevent="saveConstructeur">
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text">Nom</span></label> <label class="label"><span class="label-text">Nom</span></label>
<input v-model="form.name" type="text" class="input input-bordered" required> <input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FieldEmail v-model="form.email" label="Email" /> <FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<FieldPhone v-model="form.phone" label="Téléphone" /> <FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" @click="closeModal"> <button type="button" class="btn" @click="closeModal">
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="saving"> <button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" /> <span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }} {{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
</button> </button>
@@ -117,7 +92,8 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue' import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue' import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
@@ -126,25 +102,50 @@ import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs() const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError } = useToast() const { showError } = useToast()
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const searchTerm = ref('') const searchTerm = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name') const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
const currentSort = computed(() => ({
field: sortKey.value,
direction: sortDir.value,
}))
const handleSort = (sort) => {
sortKey.value = sort.field
sortDir.value = sort.direction
}
const modalOpen = ref(false) const modalOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const editingConstructeur = ref(null) const editingConstructeur = ref(null)
const form = ref({ name: '', email: '', phone: '' }) const form = ref({ name: '', email: '', phone: '' })
const filteredConstructeurs = computed(() => { const filteredConstructeurs = computed(() => {
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
const sorted = [...constructeurs.value].sort((a, b) => { const sorted = [...constructeurs.value].sort((a, b) => {
const key = sortKey.value if (key === 'createdAt') {
return (a[key] || '').localeCompare(b[key] || '') return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
}
return dir * (a[key] || '').localeCompare(b[key] || '')
}) })
if (!searchTerm.value) { return sorted } if (!searchTerm.value) { return sorted }
const term = searchTerm.value.toLowerCase() const term = searchTerm.value.toLowerCase()
return sorted.filter(item => return sorted.filter(item =>
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)) [item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
) )
}) })
@@ -152,6 +153,17 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value) await searchConstructeurs(searchTerm.value)
}, 300) }, 300)
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
}
const formatPhoneDisplay = (value) => { const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value) const formatted = formatPhone(value)
if (formatted) { if (formatted) {
@@ -160,7 +172,7 @@ const formatPhoneDisplay = (value) => {
return value || '—' return value || '—'
} }
function debounce (fn, delay) { function debounce(fn, delay) {
let timeout let timeout
return (...args) => { return (...args) => {
clearTimeout(timeout) clearTimeout(timeout)
@@ -183,7 +195,7 @@ const openEditModal = (constructeur) => {
form.value = { form.value = {
name: constructeur.name, name: constructeur.name,
email: constructeur.email || '', email: constructeur.email || '',
phone: constructeur.phone || '' phone: constructeur.phone || '',
} }
modalOpen.value = true modalOpen.value = true
} }
@@ -194,14 +206,25 @@ const closeModal = () => {
} }
const saveConstructeur = async () => { const saveConstructeur = async () => {
const trimmedName = form.value.name.trim()
const duplicate = constructeurs.value.find(
c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id,
)
if (duplicate) {
showError(`Un fournisseur "${duplicate.name}" existe déjà.`)
return
}
saving.value = true saving.value = true
const payload = { ...form.value } const payload = { ...form.value, name: trimmedName }
if (!payload.email) { delete payload.email } if (!payload.email) { delete payload.email }
if (!payload.phone) { delete payload.phone } if (!payload.phone) { delete payload.phone }
let result let result
if (editingConstructeur.value) { if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload) result = await updateConstructeur(editingConstructeur.value.id, payload)
} else { }
else {
result = await createConstructeur(payload) result = await createConstructeur(payload)
} }
saving.value = false saving.value = false
@@ -221,8 +244,5 @@ const confirmDelete = async (constructeur) => {
} }
} }
loadConstructeurs() onMounted(() => loadConstructeurs())
</script> </script>
<style scoped>
</style>

View File

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

View File

@@ -104,10 +104,11 @@
Commencez par ajouter des sites et des machines. Commencez par ajouter des sites et des machines.
</p> </p>
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<button class="btn btn-primary" @click="showAddSiteModal = true"> <button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true">
Ajouter un site Ajouter un site
</button> </button>
<button <button
v-if="canEdit"
class="btn btn-secondary" class="btn btn-secondary"
@click="showAddMachineModal = true" @click="showAddMachineModal = true"
> >
@@ -239,12 +240,14 @@
<div class="card-actions justify-end mt-3"> <div class="card-actions justify-end mt-3">
<button <button
v-if="canEdit"
class="btn btn-xs btn-outline" class="btn btn-xs btn-outline"
@click.stop="editMachine(machine)" @click.stop="editMachine(machine)"
> >
Modifier Modifier
</button> </button>
<button <button
v-if="canEdit"
class="btn btn-xs btn-error" class="btn btn-xs btn-error"
@click.stop="confirmDeleteMachine(machine)" @click.stop="confirmDeleteMachine(machine)"
> >
@@ -277,6 +280,7 @@
Aucune machine dans ce site Aucune machine dans ce site
</p> </p>
<button <button
v-if="canEdit"
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
@click="addMachineToSite(site)" @click="addMachineToSite(site)"
> >
@@ -304,11 +308,12 @@
type="text" type="text"
placeholder="Ex: Usine de production" placeholder="Ex: Usine de production"
class="input input-bordered" class="input input-bordered"
:disabled="!canEdit"
required required
> >
</div> </div>
<SiteContactFormFields :form="newSite" /> <SiteContactFormFields :form="newSite" :disabled="!canEdit" />
<div class="modal-action"> <div class="modal-action">
<button <button
@@ -318,7 +323,7 @@
> >
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer le site Créer le site
</button> </button>
</div> </div>
@@ -343,6 +348,7 @@
type="text" type="text"
placeholder="Ex: Presse hydraulique #1" placeholder="Ex: Presse hydraulique #1"
class="input input-bordered" class="input input-bordered"
:disabled="!canEdit"
required required
> >
</div> </div>
@@ -354,6 +360,7 @@
<select <select
v-model="newMachine.siteId" v-model="newMachine.siteId"
class="select select-bordered" class="select select-bordered"
:disabled="!canEdit"
required required
> >
<option value=""> <option value="">
@@ -374,6 +381,7 @@
<select <select
v-model="newMachine.typeMachineId" v-model="newMachine.typeMachineId"
class="select select-bordered" class="select select-bordered"
:disabled="!canEdit"
required required
> >
<option value=""> <option value="">
@@ -398,6 +406,7 @@
type="text" type="text"
placeholder="Ex: PRESS-001" placeholder="Ex: PRESS-001"
class="input input-bordered" class="input input-bordered"
:disabled="!canEdit"
> >
</div> </div>
</div> </div>
@@ -446,7 +455,7 @@
> >
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer la machine Créer la machine
</button> </button>
</div> </div>
@@ -463,6 +472,7 @@ import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucideFactory from '~icons/lucide/factory' import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin' import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideUser from '~icons/lucide/user' import IconLucideUser from '~icons/lucide/user'
@@ -474,6 +484,7 @@ import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const { sites, loading, loadSites, createSite } = useSites() const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi() const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines() const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
@@ -721,10 +732,10 @@ const confirmDeleteMachine = async (machine) => {
showSuccess(`Machine "${machine.name}" supprimée avec succès`) showSuccess(`Machine "${machine.name}" supprimée avec succès`)
await loadMachines() await loadMachines()
} else { } else {
showError(`Erreur lors de la suppression: ${result.error}`) showError(`Impossible de supprimer la machine : ${result.error}`)
} }
} catch (error) { } catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`) showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
} }
} }
} }

View File

@@ -68,6 +68,7 @@
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button <button
v-if="canEdit"
class="btn btn-sm btn-error" class="btn btn-sm btn-error"
@click.stop="confirmDeleteType(type)" @click.stop="confirmDeleteType(type)"
> >
@@ -103,11 +104,13 @@
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useMachineTypesApi } from "~/composables/useMachineTypesApi"; import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
import { useToast } from "~/composables/useToast"; import { useToast } from "~/composables/useToast";
import { humanizeError } from "~/shared/utils/errorMessages";
import IconLucidePlus from "~icons/lucide/plus"; import IconLucidePlus from "~icons/lucide/plus";
import IconLucidePackage from "~icons/lucide/package"; import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid"; import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box"; import IconLucideBox from "~icons/lucide/box";
const { canEdit } = usePermissions();
const { machineTypes, loadMachineTypes, deleteMachineType } = const { machineTypes, loadMachineTypes, deleteMachineType } =
useMachineTypesApi(); useMachineTypesApi();
@@ -146,10 +149,10 @@ const confirmDeleteType = async (type) => {
if (result.success) { if (result.success) {
showSuccess(`Type "${type.name}" supprimé avec succès`); showSuccess(`Type "${type.name}" supprimé avec succès`);
} else { } else {
showError(`Erreur lors de la suppression: ${result.error}`); showError(`Impossible de supprimer le type : ${result.error}`);
} }
} catch (error) { } catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`); showError(`Impossible de supprimer le type : ${humanizeError(error.message)}`);
} }
} }
}; };

View File

@@ -19,15 +19,17 @@
</div> </div>
</div> </div>
<TypeEditForm <div :class="{ 'pointer-events-none opacity-60': !canEdit }">
:key="formKey" <TypeEditForm
v-model="draftType" :key="formKey"
:saving="creating" v-model="draftType"
:resettable="false" :saving="!canEdit || creating"
submit-label="Créer le type" :resettable="false"
submit-loading-label="Création..." submit-label="Créer le type"
@submit="handleSubmit" submit-loading-label="Création..."
/> @submit="handleSubmit"
/>
</div>
</div> </div>
</div> </div>
@@ -94,6 +96,7 @@ import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi() const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast() const { showError } = useToast()
const { canEdit } = usePermissions()
const formKey = ref(0) const formKey = ref(0)
const creating = ref(false) const creating = ref(false)

View File

@@ -10,6 +10,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="d.previewDocument.value" :document="d.previewDocument.value"
:visible="d.previewVisible.value" :visible="d.previewVisible.value"
:documents="d.machineDocumentsList.value"
@close="d.closePreview" @close="d.closePreview"
/> />
@@ -108,6 +109,16 @@
@edit-piece="d.editPiece" @edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField" @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> </template>
<template v-else> <template v-else>
@@ -163,6 +174,7 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute() const route = useRoute()
const machineId = route.params.id const machineId = route.params.id
const { canEdit } = usePermissions()
if (!machineId) { if (!machineId) {
console.error('ID de machine manquant') console.error('ID de machine manquant')
@@ -212,7 +224,7 @@ onMounted(() => {
d.loadMachineData() d.loadMachineData()
d.loadInitialData() d.loadInitialData()
if (route.query.edit === 'true') { if (route.query.edit === 'true' && canEdit.value) {
d.isEditMode.value = true d.isEditMode.value = true
} }
}) })

View File

@@ -118,7 +118,7 @@
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)"> <button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
Modifier Modifier
</button> </button>
<button class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)"> <button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
Supprimer Supprimer
</button> </button>
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary"> <NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
@@ -138,12 +138,14 @@ import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory' import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin' import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideSettings2 from '~icons/lucide/settings-2' import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag' import IconLucideTag from '~icons/lucide/tag'
const { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines() const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi() const { machineTypes, loadMachineTypes } = useMachineTypesApi()
@@ -213,10 +215,10 @@ const confirmDeleteMachine = async (machine) => {
if (result.success) { if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`) showSuccess(`Machine "${machine.name}" supprimée avec succès`)
} else { } else {
showError(`Erreur lors de la suppression: ${result.error}`) showError(`Impossible de supprimer la machine : ${result.error}`)
} }
} catch (error) { } catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`) showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
} }
} }
} }

View File

@@ -30,6 +30,7 @@
type="text" type="text"
placeholder="Ex: Presse hydraulique #1" placeholder="Ex: Presse hydraulique #1"
class="input input-bordered" class="input input-bordered"
:disabled="!canEdit"
required required
> >
</div> </div>
@@ -38,7 +39,7 @@
<label class="label" for="machine-field-site"> <label class="label" for="machine-field-site">
<span class="label-text">Site</span> <span class="label-text">Site</span>
</label> </label>
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" required> <select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" :disabled="!canEdit" required>
<option value=""> <option value="">
Sélectionner un site Sélectionner un site
</option> </option>
@@ -58,6 +59,7 @@
v-model="c.newMachine.typeMachineId" v-model="c.newMachine.typeMachineId"
:options="c.machineTypes" :options="c.machineTypes"
:loading="c.machineTypesLoading" :loading="c.machineTypesLoading"
:disabled="!canEdit"
placeholder="Rechercher un type…" placeholder="Rechercher un type…"
empty-text="Aucun type trouvé" empty-text="Aucun type trouvé"
:option-label="c.machineTypeLabel" :option-label="c.machineTypeLabel"
@@ -74,6 +76,7 @@
type="text" type="text"
placeholder="Ex: PRESS-001" placeholder="Ex: PRESS-001"
class="input input-bordered" class="input input-bordered"
:disabled="!canEdit"
> >
</div> </div>
</div> </div>
@@ -171,7 +174,7 @@
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
:disabled="!c.canCreateMachine || c.submitting" :disabled="!canEdit || !c.canCreateMachine || c.submitting"
:class="{ loading: c.submitting }" :class="{ loading: c.submitting }"
> >
Créer la machine Créer la machine
@@ -194,4 +197,5 @@ import RequirementProductSelector from '~/components/machine/create/RequirementP
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue' import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
const c = proxyRefs(useMachineCreatePage()) const c = proxyRefs(useMachineCreatePage())
const { canEdit } = usePermissions()
</script> </script>

View File

@@ -26,6 +26,7 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked" :disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage" :disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode" :restricted-mode="isRestrictedMode"
@@ -34,6 +35,16 @@
@cancel="handleCancel" @cancel="handleCancel"
/> />
</section> </section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
</main> </main>
</template> </template>
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
@@ -126,6 +138,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) { if (guardSubmitOrNotify()) {
return return
} }

View File

@@ -20,6 +20,7 @@
initial-category="PIECE" initial-category="PIECE"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes' import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({ useHead(() => ({
title: 'Nouvelle catégorie de pièce', title: 'Nouvelle catégorie de pièce',
})) }))
@@ -50,6 +53,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => { const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true saving.value = true
try { try {
const enrichedPayload = { const enrichedPayload = {

View File

@@ -16,6 +16,7 @@
</NuxtLink> </NuxtLink>
</div> </div>
</header> </header>
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
@@ -25,301 +26,205 @@
</p> </p>
</header> </header>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <DataTable
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center"> :columns="columns"
:rows="pieceRows"
:loading="loadingPieces"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucune pièce n'a encore été créée."
no-results-message="Aucune pièce ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72"> <label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span> <span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input <input
v-model="searchTerm" v-model="table.searchTerm.value"
type="text" type="text"
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence" placeholder="Nom ou référence"
@input="debouncedSearch" @input="table.debouncedSearch"
/> />
</label> </label>
<div class="flex items-center gap-2"> </template>
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70" <template #cell-preview="{ row }">
for="piece-catalog-sort" <DocumentThumbnail
> :document="resolvePrimaryDocument(row.piece)"
Trier par :alt="resolvePreviewAlt(row.piece)"
</label> />
<select </template>
id="piece-catalog-sort"
v-model="sortField" <template #cell-name="{ row }">
class="select select-bordered select-sm" {{ row.piece.name || 'Pièce sans nom' }}
@change="handleSortChange" </template>
>
<option value="name">Nom</option> <template #cell-reference="{ row }">
<option value="createdAt">Date de création</option> {{ row.piece.reference || '—' }}
</select> </template>
<template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
</div>
</div> </div>
<div class="flex items-center gap-2"> <span v-else>—</span>
<label </template>
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="piece-catalog-dir" <template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
> >
Ordre {{ supplier }}
</label> </span>
<select <span
id="piece-catalog-dir" v-if="row.suppliers.overflow"
v-model="sortDirection" class="badge badge-outline badge-sm"
class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="asc">Ascendant</option> +{{ row.suppliers.overflow }}
<option value="desc">Descendant</option> </span>
</select>
</div> </div>
<span v-else>—</span>
</template>
<template #cell-typePiece="{ row }">
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label <NuxtLink
class="text-xs font-semibold uppercase tracking-wide text-base-content/70" :to="`/pieces/${row.piece.id}/edit`"
for="piece-catalog-per-page" class="btn btn-ghost btn-xs"
> >
Par page Modifier
</label> </NuxtLink>
<select <button
id="piece-catalog-per-page" v-if="canEdit"
v-model.number="itemsPerPage" type="button"
class="select select-bordered select-sm" class="btn btn-error btn-xs"
@change="handlePerPageChange" :disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
> >
<option :value="20">20</option> Supprimer
<option :value="50">50</option> </button>
<option :value="100">100</option>
</select>
</div> </div>
</div> </template>
<p class="text-xs text-base-content/50 lg:text-right"> </DataTable>
{{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loadingPieces" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!piecesTotal" class="text-sm text-base-content/70">
Aucune pièce n'a encore été créée.
</p>
<p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
Aucune pièce ne correspond à votre recherche.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-24">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Fournisseurs</th>
<th>Type de pièce</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="row in pieceRows" :key="row.piece.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</td>
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
<td>{{ row.piece.reference || '—' }}</td>
<td>
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else></span>
</td>
<td>
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/pieces/${row.piece.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useUrlState } from '~/composables/useUrlState' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
const { showError } = useToast() const { showError } = useToast()
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces() const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value) const loadingPieces = computed(() => loadingPiecesRef.value)
// State synced with URL query params (preserved on back/forward navigation) const table = useDataTable(
const { { fetchData: fetchPieces },
page: currentPage, { defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
perPage: itemsPerPage, )
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchPieces(),
})
const piecesTotal = computed(() => total.value) const columns = [
const piecesOnPage = computed(() => pieces.value.length) { key: 'preview', label: 'Aperçu', width: 'w-24' },
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1) { key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
// Search debounce for API calls const piecesOnPage = computed(() => pieceRows.value.length)
let searchTimeout: ReturnType<typeof setTimeout> | null = null const paginationState = table.pagination(total, piecesOnPage)
const debouncedSearch = () => { // Enrich pieces with full type data
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchPieces()
}, 300)
}
// Enrichir les pièces avec les types de pièces complets
const piecesList = computed(() => { const piecesList = computed(() => {
return (pieces.value || []).map((piece) => { return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId) const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return { return { ...piece, typePiece: typePiece || piece.typePiece || null }
...piece,
typePiece: typePiece || piece.typePiece || null
}
}) })
}) })
const fetchPieces = async () => { const pieceRows = computed(() =>
piecesList.value.map(piece => ({
id: piece.id,
piece,
suppliers: buildPieceSuppliersDisplay(piece),
})),
)
async function fetchPieces() {
await loadPieces({ await loadPieces({
search: searchTerm.value, search: table.searchTerm.value,
page: currentPage.value, page: table.currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: table.itemsPerPage.value,
orderBy: sortField.value, orderBy: table.sortField.value,
orderDir: sortDirection.value as 'asc' | 'desc', orderDir: table.sortDirection.value as 'asc' | 'desc',
force: true typeName: table.columnFilters.value.typePiece || undefined,
force: true,
}) })
} }
const handlePageChange = (page: number) => {
currentPage.value = page
fetchPieces()
}
const handleSortChange = () => {
currentPage.value = 1
fetchPieces()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchPieces()
}
const resolvePrimaryDocument = (piece: Record<string, any>) => { const resolvePrimaryDocument = (piece: Record<string, any>) => {
const documents = Array.isArray(piece?.documents) ? piece.documents : [] const documents = Array.isArray(piece?.documents) ? piece.documents : []
if (!documents.length) { if (!documents.length) return null
return null const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
} const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const normalized = documents.filter((doc) => doc && typeof doc === 'object') const pdf = withPath.find((doc: any) => isPdfDocument(doc))
const withPath = normalized.filter((doc) => doc?.path) if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
const pdf = withPath.find((doc) => isPdfDocument(doc)) if (image) return image
if (pdf) {
return pdf
}
const image = withPath.find((doc) => isImageDocument(doc))
if (image) {
return image
}
return withPath[0] ?? normalized[0] ?? null return withPath[0] ?? normalized[0] ?? null
} }
const resolvePreviewAlt = (piece: Record<string, any>) => { const resolvePreviewAlt = (piece: Record<string, any>) => {
const parts = [piece?.name, piece?.reference].filter(Boolean) const parts = [piece?.name, piece?.reference].filter(Boolean)
if (parts.length) { return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
} }
const resolvePieceType = (piece: Record<string, any>) => { const resolvePieceType = (piece: Record<string, any>) => {
const type = piece?.typePiece if (piece?.typePiece?.name) return piece.typePiece.name
if (type?.name) { if (piece?.typePieceLabel) return piece.typePieceLabel
return type.name
}
if (piece?.typePieceLabel) {
return piece.typePieceLabel
}
return '—' return '—'
} }
@@ -330,61 +235,36 @@ const resolvePieceSuppliers = (piece: Record<string, any>) => {
const seen = new Set<string>() const seen = new Set<string>()
const pushName = (maybeName: unknown) => { const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') { if (typeof maybeName !== 'string') return
return
}
const normalized = maybeName.trim().replace(/\s+/g, ' ') const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) { if (!normalized.length) return
return
}
const key = normalized.toLowerCase() const key = normalized.toLowerCase()
if (seen.has(key)) { if (seen.has(key)) return
return
}
seen.add(key) seen.add(key)
names.push(normalized) names.push(normalized)
} }
const collectConstructeurs = (value: unknown): void => { const collectConstructeurs = (value: unknown): void => {
if (!value) { if (!value) return
return if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
} if (typeof value === 'string') { pushName(value); return }
if (Array.isArray(value)) {
value.forEach(collectConstructeurs)
return
}
if (typeof value === 'string') {
pushName(value)
return
}
if (typeof value === 'object') { if (typeof value === 'object') {
const record = value as Record<string, any> const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null) pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) { if (record?.constructeur) collectConstructeurs(record.constructeur)
collectConstructeurs(record.constructeur) if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
if (Array.isArray(record?.constructeurs)) {
collectConstructeurs(record.constructeurs)
}
} }
} }
const collectFromLabel = (value: unknown): void => { const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') { if (typeof value !== 'string') return
return value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
value
.split(/[,;\\/•·|]+/)
.map((part) => part.trim())
.filter(Boolean)
.forEach(pushName)
} }
collectConstructeurs(piece?.constructeurs) collectConstructeurs(piece?.constructeurs)
collectConstructeurs(piece?.constructeur) collectConstructeurs(piece?.constructeur)
collectConstructeurs(piece?.product?.constructeurs) collectConstructeurs(piece?.product?.constructeurs)
collectConstructeurs(piece?.product?.constructeur) collectConstructeurs(piece?.product?.constructeur)
collectFromLabel(piece?.constructeursLabel) collectFromLabel(piece?.constructeursLabel)
collectFromLabel(piece?.supplierLabel) collectFromLabel(piece?.supplierLabel)
collectFromLabel(piece?.product?.constructeursLabel) collectFromLabel(piece?.product?.constructeursLabel)
@@ -397,83 +277,45 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
const suppliers = resolvePieceSuppliers(piece) const suppliers = resolvePieceSuppliers(piece)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS) const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0) const overflow = Math.max(suppliers.length - visible.length, 0)
return { return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
suppliers,
visible,
overflow,
tooltip: suppliers.length ? suppliers.join(', ') : '',
}
} }
const resolveDeleteGuard = (piece: Record<string, any>) => { const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = [] const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks) const machineLinks = Array.isArray(piece?.machineLinks) ? piece.machineLinks.length : piece?.machineLinksCount ?? 0
? piece.machineLinks.length const documents = Array.isArray(piece?.documents) ? piece.documents.length : piece?.documentsCount ?? 0
: piece?.machineLinksCount ?? 0 const customFields = Array.isArray(piece?.customFieldValues) ? piece.customFieldValues.length : piece?.customFieldValuesCount ?? 0
const documents = Array.isArray(piece?.documents) if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
? piece.documents.length if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
: piece?.documentsCount ?? 0 return { blockingReasons, hasCustomFields: customFields > 0 }
const customFields = Array.isArray(piece?.customFieldValues)
? piece.customFieldValues.length
: piece?.customFieldValuesCount ?? 0
if (machineLinks > 0) {
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
}
if (documents > 0) {
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
}
return {
blockingReasons,
hasCustomFields: customFields > 0,
}
} }
const pieceRows = computed(() =>
piecesList.value.map((piece) => ({
piece,
suppliers: buildPieceSuppliersDisplay(piece),
})),
)
const handleDeletePiece = async (piece: Record<string, any>) => { const handleDeletePiece = async (piece: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece) const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
if (blockingReasons.length) { if (blockingReasons.length) {
showError( showError(`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(
', ',
)}. Supprimez ou détachez ces éléments avant de réessayer.`
)
return return
} }
const pieceName = piece?.name || 'cette pièce' const pieceName = piece?.name || 'cette pièce'
const confirmLines = [ const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`]
`Voulez-vous vraiment supprimer ${pieceName} ?`,
]
if (hasCustomFields) { if (hasCustomFields) {
confirmLines.push( confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
'Les valeurs de champs personnalisés associées seront également supprimées.'
)
} }
const { confirm } = useConfirm() const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') }) const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) { if (!confirmed) return
return
}
await deletePiece(piece.id) await deletePiece(piece.id)
// Reload current page after deletion
fetchPieces() fetchPieces()
} }
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
}
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([fetchPieces(), loadPieceTypes()])
fetchPieces(),
loadPieceTypes()
])
}) })
</script> </script>

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
@@ -72,13 +73,26 @@
v-model="editionForm.name" v-model="editionForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue" placeholder="Nom affiché dans le catalogue"
required required
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -88,7 +102,7 @@
v-model="editionForm.reference" v-model="editionForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
@@ -100,7 +114,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="editionForm.constructeurIds" v-model="editionForm.constructeurIds"
class="w-full" class="w-full"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []" :initial-options="piece?.constructeurs || []"
/> />
@@ -118,7 +132,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Valeur indicatrice" placeholder="Valeur indicatrice"
> >
</div> </div>
@@ -159,7 +173,7 @@
</label> </label>
<ProductSelect <ProductSelect
:model-value="productSelections[entry.index] || null" :model-value="productSelections[entry.index] || null"
:disabled="saving" :disabled="!canEdit || saving"
:type-product-id="entry.typeProductId" :type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce." helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)" @update:model-value="(value) => setProductSelection(entry.index, value)"
@@ -224,7 +238,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -233,14 +247,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -251,24 +265,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="saving" :disabled="!canEdit || saving"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else v-else
@@ -276,7 +290,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
</div> </div>
</div> </div>
@@ -294,7 +308,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload <DocumentUpload
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -320,8 +334,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -366,6 +380,7 @@
Télécharger Télécharger
</button> </button>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-error btn-xs"
:disabled="uploadingDocuments" :disabled="uploadingDocuments"
@@ -458,6 +473,16 @@
Enregistrer les modifications Enregistrer les modifications
</button> </button>
</div> </div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -511,6 +536,7 @@ interface PieceCatalogType extends ModelType {
customFields?: Array<Record<string, any>> customFields?: Array<Record<string, any>>
} }
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
@@ -556,6 +582,7 @@ const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null) const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -731,6 +758,7 @@ const requiredCustomFieldsFilled = computed(() =>
const canSubmit = computed(() => const canSubmit = computed(() =>
Boolean( Boolean(
canEdit.value &&
piece.value && piece.value &&
editionForm.name && editionForm.name &&
requiredCustomFieldsFilled.value && requiredCustomFieldsFilled.value &&
@@ -810,6 +838,7 @@ watch(
selectedTypeId.value = resolvedTypeId selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || '' editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || '' editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece, currentPiece,
@@ -882,6 +911,7 @@ const submitEdition = async () => {
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: editionForm.name.trim(), name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
constructeurIds, constructeurIds,
} }

View File

@@ -28,7 +28,7 @@
empty-text="Aucune catégorie disponible" empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel" :option-label="typeOptionLabel"
:option-description="typeOptionDescription" :option-description="typeOptionDescription"
:disabled="loadingTypes || submitting" :disabled="!canEdit || loadingTypes || submitting"
/> />
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1"> <p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories Chargement des catégories
@@ -45,13 +45,26 @@
v-model="creationForm.name" v-model="creationForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue" placeholder="Nom affiché dans le catalogue"
required required
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -61,7 +74,7 @@
v-model="creationForm.reference" v-model="creationForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
@@ -73,7 +86,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="creationForm.constructeurIds" v-model="creationForm.constructeurIds"
class="w-full" class="w-full"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
@@ -90,7 +103,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice" placeholder="Valeur indicatrice"
> >
</div> </div>
@@ -131,7 +144,7 @@
</label> </label>
<ProductSelect <ProductSelect
:model-value="productSelections[entry.index] || null" :model-value="productSelections[entry.index] || null"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId" :type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce." helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)" @update:model-value="(value) => setProductSelection(entry.index, value)"
@@ -196,7 +209,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -205,14 +218,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -223,24 +236,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<input <input
v-else v-else
@@ -248,7 +261,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
</div> </div>
</div> </div>
@@ -266,7 +279,7 @@
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }} {{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': submitting }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload <DocumentUpload
v-model="selectedDocuments" v-model="selectedDocuments"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -302,6 +315,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -329,12 +343,14 @@ const { createPiece } = usePieces()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '') const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value) const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false) const submitting = ref(false)
const creationForm = reactive({ const creationForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -478,6 +494,7 @@ const requiredCustomFieldsFilled = computed(() =>
const canSubmit = computed(() => const canSubmit = computed(() =>
Boolean( Boolean(
canEdit.value &&
selectedType.value && selectedType.value &&
creationForm.name && creationForm.name &&
requiredCustomFieldsFilled.value && requiredCustomFieldsFilled.value &&
@@ -488,6 +505,7 @@ const canSubmit = computed(() =>
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.description = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurIds = [] creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
@@ -511,6 +529,11 @@ const submitCreation = async () => {
typePieceId: selectedType.value.id, typePieceId: selectedType.value.id,
} }
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim() const reference = creationForm.reference.trim()
if (reference) { if (reference) {
payload.reference = reference payload.reference = reference
@@ -577,7 +600,7 @@ const submitCreation = async () => {
toast.showError(result.error) toast.showError(result.error)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création de la pièce') toast.showError(humanizeError(error?.message) || 'Impossible de créer la pièce')
} finally { } finally {
submitting.value = false submitting.value = false
uploadingDocuments.value = false uploadingDocuments.value = false

View File

@@ -19,51 +19,8 @@
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
/>
</label>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
<select
id="product-sort"
v-model="sortField"
class="select select-bordered select-sm"
>
<option value="name">Nom</option>
<option value="createdAt">Date de création</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-dir">Ordre</label>
<select
id="product-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/60 lg:text-right">
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-10">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<div <div
v-else-if="errorMessage" v-if="errorMessage"
class="alert alert-error" class="alert alert-error"
> >
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -75,95 +32,107 @@
</button> </button>
</div> </div>
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70"> <DataTable
Chargement du catalogue… v-else
</p> :columns="columns"
:rows="productRows"
:loading="loadingProducts"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun produit n'a encore été enregistré."
no-results-message="Aucun produit ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="table.debouncedSearch"
/>
</label>
</template>
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70"> <template #cell-preview="{ row }">
Aucun produit n'a encore été enregistré. <DocumentThumbnail
</p> :document="resolvePrimaryDocument(row.product)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70"> <template #cell-name="{ row }">
Aucun produit ne correspond à votre recherche. <span class="font-medium">{{ row.product.name }}</span>
</p> </template>
<div v-else class="overflow-x-auto"> <template #cell-reference="{ row }">
<table class="table table-sm md:table-md"> {{ row.product.reference || '—' }}
<thead> </template>
<tr>
<th class="w-16">Aperçu</th> <template #cell-typeProduct="{ row }">
<th>Nom</th> <NuxtLink
<th>Référence</th> v-if="row.product.typeProduct?.id"
<th>Type de produit</th> :to="`/product-category/${row.product.typeProduct.id}/edit`"
<th>Fournisseurs</th> class="link link-hover link-primary"
<th class="text-right">Prix indicatif</th> >
<th class="w-32 text-right">Actions</th> {{ row.product.typeProduct.name }}
</tr> </NuxtLink>
</thead> <span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
<tbody> </template>
<tr v-for="row in productRows" :key="row.product.id">
<td class="align-middle"> <template #cell-suppliers="{ row }">
<DocumentThumbnail <div
:document="resolvePrimaryDocument(row.product)" v-if="row.suppliers.visible.length"
:alt="resolvePreviewAlt(row.product)" class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
/> :title="row.suppliers.tooltip"
</td> >
<td class="font-medium">{{ row.product.name }}</td> <span
<td>{{ row.product.reference || '—' }}</td> v-for="supplier in row.suppliers.visible"
<td> :key="supplier"
<NuxtLink class="badge badge-ghost badge-sm whitespace-nowrap"
v-if="row.product.typeProduct?.id" >
:to="`/product-category/${row.product.typeProduct.id}/edit`" {{ supplier }}
class="link link-hover link-primary" </span>
> <span
{{ row.product.typeProduct.name }} v-if="row.suppliers.overflow"
</NuxtLink> class="badge badge-outline badge-sm"
<span v-else>{{ row.product.typeProduct?.name || '' }}</span> >
</td> +{{ row.suppliers.overflow }}
<td> </span>
<div </div>
v-if="row.suppliers.visible.length" <span v-else class="text-sm text-base-content/50">—</span>
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm" </template>
:title="row.suppliers.tooltip"
> <template #cell-price="{ row }">
<span {{ formatPrice(row.product.supplierPrice) }}
v-for="supplier in row.suppliers.visible" </template>
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap" <template #cell-actions="{ row }">
> <div class="flex justify-end gap-2">
{{ supplier }} <NuxtLink
</span> :to="`/product/${row.product.id}/edit`"
<span class="btn btn-ghost btn-xs"
v-if="row.suppliers.overflow" >
class="badge badge-outline badge-sm" Modifier
> </NuxtLink>
+{{ row.suppliers.overflow }} <button
</span> v-if="canEdit"
</div> type="button"
<span v-else class="text-sm text-base-content/50"></span> class="btn btn-ghost btn-xs text-error"
</td> @click="confirmDelete(row.product)"
<td class="text-right"> >
{{ formatPrice(row.product.supplierPrice) }} Supprimer
</td> </button>
<td class="text-right space-x-2"> </div>
<NuxtLink </template>
:to="`/product/${row.product.id}/edit`" </DataTable>
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -172,22 +141,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useHead } from '#imports' import { useHead } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes' import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useUrlState } from '~/composables/useUrlState' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
useHead(() => ({ const { canEdit } = usePermissions()
title: 'Catalogue des produits',
})) useHead(() => ({ title: 'Catalogue des produits' }))
const { const {
products, products,
total, total,
loading, loading,
loaded,
error, error,
loadProducts, loadProducts,
deleteProduct, deleteProduct,
@@ -195,65 +164,54 @@ const {
const { productTypes, loadProductTypes } = useProductTypes() const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast() const toast = useToast()
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({ const table = useDataTable(
q: { default: '', debounce: 300 }, { fetchData: fetchProducts },
sort: { default: 'name' }, { defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
dir: { default: 'asc' }, )
})
// Enrichir les produits avec les types de produits complets const loadingProducts = computed(() => loading.value)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
]
const productsOnPage = computed(() => productRows.value.length)
const paginationState = table.pagination(total, productsOnPage)
// Enrich products with full type data
const normalizedProducts = computed(() => { const normalizedProducts = computed(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => { return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId) const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return { return { ...product, typeProduct: typeProduct || product.typeProduct || null }
...product,
typeProduct: typeProduct || product.typeProduct || null
}
})
})
const hasLoaded = computed(() => loaded.value)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const filteredProducts = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const items = normalizedProducts.value.slice()
const filtered = term
? items.filter((product) => {
const name = (product?.name || '').toLowerCase()
const reference = (product?.reference || '').toLowerCase()
const typeName = (product?.typeProduct?.name || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term) ||
typeName.includes(term)
)
})
: items
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
) * direction
}
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
return (dateA - dateB) * direction
}) })
}) })
const filteredCount = computed(() => filteredProducts.value.length) const productRows = computed(() =>
const totalCount = computed(() => { normalizedProducts.value.map(product => ({
const reported = Number(total.value) id: product.id,
if (!Number.isFinite(reported) || reported < 0) { product,
return normalizedProducts.value.length suppliers: buildSuppliersDisplay(product),
} })),
return reported )
})
async function fetchProducts() {
await loadProducts({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeProduct || undefined,
force: true,
})
}
const priceFormatter = new Intl.NumberFormat('fr-FR', { const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency', style: 'currency',
@@ -262,14 +220,9 @@ const priceFormatter = new Intl.NumberFormat('fr-FR', {
}) })
const formatPrice = (value: any) => { const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') return '—'
return '—'
}
const number = Number(value) const number = Number(value)
if (Number.isNaN(number)) { return Number.isNaN(number) ? '—' : priceFormatter.format(number)
return '—'
}
return priceFormatter.format(number)
} }
const MAX_VISIBLE_SUPPLIERS = 3 const MAX_VISIBLE_SUPPLIERS = 3
@@ -279,59 +232,34 @@ const resolveProductSuppliers = (product: Record<string, any>) => {
const seen = new Set<string>() const seen = new Set<string>()
const pushName = (maybeName: unknown) => { const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') { if (typeof maybeName !== 'string') return
return
}
const normalized = maybeName.trim().replace(/\s+/g, ' ') const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) { if (!normalized.length) return
return
}
const key = normalized.toLowerCase() const key = normalized.toLowerCase()
if (seen.has(key)) { if (seen.has(key)) return
return
}
seen.add(key) seen.add(key)
names.push(normalized) names.push(normalized)
} }
const collectConstructeurs = (value: unknown): void => { const collectConstructeurs = (value: unknown): void => {
if (!value) { if (!value) return
return if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
} if (typeof value === 'string') { pushName(value); return }
if (Array.isArray(value)) {
value.forEach(collectConstructeurs)
return
}
if (typeof value === 'string') {
pushName(value)
return
}
if (typeof value === 'object') { if (typeof value === 'object') {
const record = value as Record<string, any> const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null) pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) { if (record?.constructeur) collectConstructeurs(record.constructeur)
collectConstructeurs(record.constructeur) if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
if (Array.isArray(record?.constructeurs)) {
collectConstructeurs(record.constructeurs)
}
} }
} }
const collectFromLabel = (value: unknown): void => { const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') { if (typeof value !== 'string') return
return value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
value
.split(/[,;\\/•·|]+/)
.map((part) => part.trim())
.filter(Boolean)
.forEach(pushName)
} }
collectConstructeurs(product?.constructeurs) collectConstructeurs(product?.constructeurs)
collectConstructeurs(product?.constructeur) collectConstructeurs(product?.constructeur)
collectFromLabel(product?.constructeursLabel) collectFromLabel(product?.constructeursLabel)
collectFromLabel(product?.supplierLabel) collectFromLabel(product?.supplierLabel)
collectFromLabel(product?.suppliers) collectFromLabel(product?.suppliers)
@@ -343,53 +271,28 @@ const buildSuppliersDisplay = (product: Record<string, any>) => {
const suppliers = resolveProductSuppliers(product) const suppliers = resolveProductSuppliers(product)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS) const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0) const overflow = Math.max(suppliers.length - visible.length, 0)
return { return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
suppliers,
visible,
overflow,
tooltip: suppliers.length ? suppliers.join(', ') : '',
}
} }
const productRows = computed(() =>
filteredProducts.value.map((product) => ({
product,
suppliers: buildSuppliersDisplay(product),
})),
)
const resolvePrimaryDocument = (product: Record<string, any>) => { const resolvePrimaryDocument = (product: Record<string, any>) => {
const documents = Array.isArray(product?.documents) ? product.documents : [] const documents = Array.isArray(product?.documents) ? product.documents : []
if (!documents.length) { if (!documents.length) return null
return null const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
} const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const normalized = documents.filter((doc) => doc && typeof doc === 'object') if (!withPath.length) return normalized[0] ?? null
const withPath = normalized.filter((doc) => doc?.path) const images = withPath.filter((doc: any) => isImageDocument(doc))
if (!withPath.length) { if (images.length) return images[0]
return normalized[0] ?? null const pdf = withPath.find((doc: any) => isPdfDocument(doc))
} if (pdf) return pdf
const images = withPath.filter((doc) => isImageDocument(doc))
if (images.length) {
return images[0]
}
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
return withPath[0] return withPath[0]
} }
const resolvePreviewAlt = (product: Record<string, any>) => { const resolvePreviewAlt = (product: Record<string, any>) => {
const parts = [product?.name, product?.reference].filter(Boolean) const parts = [product?.name, product?.reference].filter(Boolean)
if (parts.length) { return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
} }
const reload = async () => { const reload = () => fetchProducts()
await loadProducts({ itemsPerPage: 200, force: true })
}
const { confirm } = useConfirm() const { confirm } = useConfirm()
@@ -397,10 +300,7 @@ const confirmDelete = async (product: Record<string, any>) => {
const confirmed = await confirm({ const confirmed = await confirm({
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`, message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
}) })
if (!confirmed) { if (!confirmed) return
return
}
const result = await deleteProduct(product.id) const result = await deleteProduct(product.id)
if (result.success) { if (result.success) {
toast.showSuccess(`Produit "${product.name}" supprimé`) toast.showSuccess(`Produit "${product.name}" supprimé`)
@@ -408,9 +308,6 @@ const confirmDelete = async (product: Record<string, any>) => {
} }
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([fetchProducts(), loadProductTypes()])
loadProducts({ itemsPerPage: 200, force: true }),
loadProductTypes()
])
}) })
</script> </script>

View File

@@ -26,6 +26,7 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked" :disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage" :disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode" :restricted-mode="isRestrictedMode"
@@ -34,6 +35,16 @@
@cancel="handleCancel" @cancel="handleCancel"
/> />
</section> </section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
</main> </main>
</template> </template>
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes' import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
@@ -126,6 +138,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) { if (guardSubmitOrNotify()) {
return return
} }

View File

@@ -20,6 +20,7 @@
initial-category="PRODUCT" initial-category="PRODUCT"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes' import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({ useHead(() => ({
title: 'Nouvelle catégorie de produit', title: 'Nouvelle catégorie de produit',
})) }))
@@ -50,6 +53,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => { const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true saving.value = true
try { try {
const enrichedPayload = { const enrichedPayload = {

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="productDocuments"
@close="closePreview" @close="closePreview"
/> />
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
@@ -64,7 +65,7 @@
v-model="editionForm.name" v-model="editionForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
required required
> >
</div> </div>
@@ -79,7 +80,7 @@
v-model="editionForm.reference" v-model="editionForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
> >
</div> </div>
@@ -90,7 +91,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="editionForm.constructeurIds" v-model="editionForm.constructeurIds"
class="w-full" class="w-full"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []" :initial-options="product?.constructeurs || []"
/> />
@@ -108,7 +109,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
> >
</div> </div>
</div> </div>
@@ -148,7 +149,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -157,14 +158,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -175,24 +176,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="saving" :disabled="!canEdit || saving"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else v-else
@@ -200,7 +201,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
</div> </div>
</div> </div>
@@ -218,7 +219,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload <DocumentUpload
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -244,8 +245,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -286,6 +287,7 @@
Télécharger Télécharger
</button> </button>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-error btn-xs"
:disabled="uploadingDocuments || saving" :disabled="uploadingDocuments || saving"
@@ -381,6 +383,16 @@
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right"> <p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires. Merci de renseigner tous les champs personnalisés obligatoires.
</p> </p>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -395,6 +407,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory' import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
@@ -424,6 +437,7 @@ import {
historyDiffEntries as _historyDiffEntries, historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils' } from '~/shared/utils/historyDisplayUtils'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -489,7 +503,7 @@ const requiredCustomFieldsFilled = computed(() =>
) )
const canSubmit = computed(() => const canSubmit = computed(() =>
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value), Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
) )
const structurePreview = computed(() => formatProductStructurePreview(structure.value)) const structurePreview = computed(() => formatProductStructurePreview(structure.value))
@@ -687,7 +701,7 @@ const submitEdition = async () => {
await router.push('/product-catalog') await router.push('/product-catalog')
} }
} catch (error: any) { } catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit') toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
} finally { } finally {
saving.value = false saving.value = false
} }

View File

@@ -28,7 +28,7 @@
empty-text="Aucune catégorie disponible" empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel" :option-label="typeOptionLabel"
:option-description="typeOptionDescription" :option-description="typeOptionDescription"
:disabled="loadingTypes || submitting" :disabled="!canEdit || loadingTypes || submitting"
/> />
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1"> <p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories Chargement des catégories
@@ -45,7 +45,7 @@
v-model="creationForm.name" v-model="creationForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue" placeholder="Nom affiché dans le catalogue"
required required
> >
@@ -61,7 +61,7 @@
v-model="creationForm.reference" v-model="creationForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
@@ -73,7 +73,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="creationForm.constructeurIds" v-model="creationForm.constructeurIds"
class="w-full" class="w-full"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
@@ -90,7 +90,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice" placeholder="Valeur indicatrice"
> >
</div> </div>
@@ -135,7 +135,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -144,14 +144,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -162,24 +162,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
<input <input
v-else v-else
@@ -187,7 +187,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="submitting" :disabled="!canEdit || submitting"
> >
</div> </div>
</div> </div>
@@ -205,7 +205,7 @@
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }} {{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload <DocumentUpload
v-model="selectedDocuments" v-model="selectedDocuments"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -267,6 +267,7 @@ const { createProduct } = useProducts()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '') const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value) const selectedTypeId = ref<string>(initialTypeId.value)
@@ -346,6 +347,7 @@ const requiredCustomFieldsFilled = computed(() =>
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value && selectedType.value &&
creationForm.name.trim().length >= 2 && creationForm.name.trim().length >= 2 &&
requiredCustomFieldsFilled.value && requiredCustomFieldsFilled.value &&

View File

@@ -1,55 +1,74 @@
<template> <template>
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6"> <main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div class="w-full max-w-2xl"> <div class="w-full max-w-md">
<div class="card bg-base-100 shadow-2xl"> <div class="card bg-base-100 shadow-2xl">
<div class="card-body"> <div class="card-body">
<h1 class="text-2xl font-bold mb-2"> <h1 class="text-2xl font-bold mb-6 text-center">
Choisir un profil Connexion
</h1> </h1>
<p class="text-sm text-base-content/70 mb-6">
Sélectionnez votre profil pour accéder à l'application. La création et la gestion se font via le menu utilisateur.
</p>
<section class="space-y-4"> <div v-if="loadingProfiles" class="flex justify-center py-8">
<header class="flex items-center justify-between"> <span class="loading loading-spinner loading-lg" />
<h2 class="font-semibold"> </div>
Profils disponibles
</h2>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="loadingProfiles"
@click="refreshProfiles"
>
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
<span v-else>Rafraîchir</span>
</button>
</header>
<div v-if="profiles.length" class="space-y-2 max-h-64 overflow-y-auto"> <form v-else @submit.prevent="handleLogin">
<button <div class="form-control mb-4">
v-for="profile in profiles" <label class="label">
:key="profile.id" <span class="label-text">Profil</span>
type="button" </label>
class="btn btn-outline btn-sm w-full justify-between" <select
@click="selectProfile(profile.id)" v-model="selectedProfileId"
class="select select-bordered w-full"
required
> >
<span>{{ profile.firstName }} {{ profile.lastName }}</span> <option value="" disabled>
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" /> Choisir un profil...
</button> </option>
<option
v-for="profile in profiles"
:key="profile.id"
:value="profile.id"
>
{{ profile.firstName }} {{ profile.lastName }}
</option>
</select>
</div> </div>
<p v-else class="text-sm text-base-content/60">
Aucun profil enregistré.
</p>
</section>
<footer v-if="activeProfile" class="mt-6 flex justify-between items-center"> <div class="form-control mb-2">
<label class="label">
<span class="label-text">Mot de passe</span>
</label>
<input
ref="passwordInput"
v-model="password"
type="password"
placeholder="Mot de passe"
class="input input-bordered w-full"
:class="{ 'input-error': loginError }"
>
</div>
<p v-if="loginError" class="text-error text-sm mb-4">
{{ loginError }}
</p>
<button
type="submit"
class="btn btn-primary w-full mt-4"
:disabled="!selectedProfileId || submitting"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
Se connecter
</button>
</form>
<footer v-if="activeProfile" class="mt-6 pt-4 border-t border-base-300 flex justify-between items-center">
<div class="text-sm text-base-content/70"> <div class="text-sm text-base-content/70">
Profil actuel : Connecte :
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span> <span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
</div> </div>
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout"> <button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
connexion Deconnexion
</button> </button>
</footer> </footer>
</div> </div>
@@ -59,32 +78,43 @@
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useProfiles, useProfileSession } from '#imports' import { useProfiles, useProfileSession } from '#imports'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
const router = useRouter() const router = useRouter()
const { profiles, loadingProfiles, fetchProfiles } = useProfiles() const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession() const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
const refreshProfiles = async () => { const selectedProfileId = ref('')
await fetchProfiles() const password = ref('')
} const loginError = ref('')
const submitting = ref(false)
const passwordInput = ref(null)
const selectProfile = async (profileId) => { const handleLogin = async () => {
if (!selectedProfileId.value) { return }
submitting.value = true
loginError.value = ''
try { try {
await activateProfile(profileId) await activateProfile(selectedProfileId.value, password.value || undefined)
await fetchProfiles()
await router.push('/') await router.push('/')
} catch (error) { } catch (error) {
console.error('Erreur lors de la sélection du profil', error) const err = error
if (err?.status === 401 || err?.statusCode === 401) {
loginError.value = 'Mot de passe incorrect.'
} else {
loginError.value = 'Erreur lors de la connexion.'
}
} finally {
submitting.value = false
} }
} }
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
await router.push('/profiles') selectedProfileId.value = ''
password.value = ''
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -1,196 +0,0 @@
<template>
<main class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">
Gestion des profils
</h1>
<p class="text-sm text-base-content/70">
Sélectionnez, créez ou supprimez des profils.
</p>
</div>
<NuxtLink to="/" class="btn btn-ghost btn-sm">
Retour
</NuxtLink>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<header class="flex items-center justify-between">
<h2 class="card-title text-lg">
Profils existants
</h2>
<button type="button" class="btn btn-ghost btn-xs" :disabled="loadingProfiles" @click="refresh">
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
<span v-else>Rafraîchir</span>
</button>
</header>
<div v-if="profiles.length" class="space-y-2 max-h-80 overflow-y-auto">
<div
v-for="profile in profiles"
:key="profile.id"
class="flex items-center justify-between rounded-lg border border-base-200 bg-base-100 px-3 py-2"
>
<div>
<p class="font-medium">
{{ profile.firstName }} {{ profile.lastName }}
</p>
<p class="text-xs text-base-content/60">
ID : {{ profile.id }}
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-sm"
:class="profile.id === activeProfile?.id ? 'btn-primary' : 'btn-outline'"
@click="select(profile.id)"
>
{{ profile.id === activeProfile?.id ? 'Actif' : 'Activer' }}
</button>
<button
type="button"
class="btn btn-error btn-sm"
@click="remove(profile.id)"
>
<span v-if="deleting === profile.id" class="loading loading-spinner loading-xs" />
<span v-else>Supprimer</span>
</button>
</div>
</div>
</div>
<p v-else class="text-sm text-base-content/60">
Aucun profil enregistré.
</p>
</div>
</section>
<section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<h2 class="card-title text-lg">
Créer un profil
</h2>
<form class="space-y-3" @submit.prevent="create">
<div class="form-control">
<label class="label"><span class="label-text">Prénom</span></label>
<input
v-model="createForm.firstName"
type="text"
class="input input-bordered"
placeholder="Prénom"
required
>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input
v-model="createForm.lastName"
type="text"
class="input input-bordered"
placeholder="Nom"
required
>
</div>
<button type="submit" class="btn btn-primary w-full" :disabled="creating">
<span v-if="creating" class="loading loading-spinner loading-sm" />
<span v-else>Créer et activer</span>
</button>
</form>
</div>
</section>
</div>
<div v-if="activeProfile" class="mt-8 flex items-center justify-between bg-base-100 shadow-lg rounded-lg p-4">
<div>
<p class="text-sm text-base-content/70">
Profil actif :
</p>
<p class="font-semibold text-base-content">
{{ activeProfile.firstName }} {{ activeProfile.lastName }}
</p>
</div>
<button type="button" class="btn btn-outline" @click="handleLogout">
Déconnexion
</button>
</div>
</div>
</main>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProfiles, useProfileSession } from '#imports'
const router = useRouter()
const { profiles, loadingProfiles, fetchProfiles, createProfile, deleteProfile } = useProfiles()
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
const createForm = reactive({
firstName: '',
lastName: ''
})
const creating = ref(false)
const deleting = ref(null)
const refresh = async () => {
await fetchProfiles()
await fetchCurrentProfile()
}
const select = async (profileId) => {
try {
await activateProfile(profileId)
await refresh()
} catch (error) {
console.error('Erreur lors de la sélection du profil', error)
}
}
const create = async () => {
creating.value = true
try {
const profile = await createProfile({
firstName: createForm.firstName,
lastName: createForm.lastName
})
createForm.firstName = ''
createForm.lastName = ''
await activateProfile(profile.id)
await refresh()
} catch (error) {
console.error('Erreur lors de la création du profil', error)
} finally {
creating.value = false
}
}
const { confirm } = useConfirm()
const remove = async (profileId) => {
if (!await confirm({ message: 'Supprimer ce profil ?' })) { return }
deleting.value = profileId
try {
await deleteProfile(profileId)
await refresh()
} catch (error) {
console.error('Erreur lors de la suppression du profil', error)
} finally {
deleting.value = null
}
}
const handleLogout = async () => {
await logout()
await refresh()
await router.push('/profiles')
}
onMounted(async () => {
await refresh()
})
</script>

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="siteDocuments"
@close="closePreview" @close="closePreview"
/> />
@@ -11,7 +12,7 @@
<h2 class="text-2xl font-bold"> <h2 class="text-2xl font-bold">
Sites Sites
</h2> </h2>
<button class="btn btn-primary" @click="openCreateSiteModal"> <button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Ajouter un site Ajouter un site
</button> </button>
@@ -30,7 +31,7 @@
<p class="text-gray-500 mb-4"> <p class="text-gray-500 mb-4">
Commencez par ajouter votre premier site. Commencez par ajouter votre premier site.
</p> </p>
<button class="btn btn-primary" @click="openCreateSiteModal"> <button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
Ajouter un site Ajouter un site
</button> </button>
</div> </div>
@@ -50,6 +51,7 @@
<SiteCreateModal <SiteCreateModal
:visible="showAddSiteModal" :visible="showAddSiteModal"
:site="newSite" :site="newSite"
:disabled="!canEdit"
@close="closeCreateModal" @close="closeCreateModal"
@submit="handleCreateSite" @submit="handleCreateSite"
/> />
@@ -64,6 +66,7 @@
:can-preview-document="canPreviewDocument" :can-preview-document="canPreviewDocument"
:document-icon="documentIcon" :document-icon="documentIcon"
:format-size="formatSize" :format-size="formatSize"
:disabled="!canEdit"
@close="closeEditModal" @close="closeEditModal"
@submit="handleUpdateSite" @submit="handleUpdateSite"
@remove-document="handleRemoveSiteDocument" @remove-document="handleRemoveSiteDocument"
@@ -83,6 +86,8 @@ import SiteCreateModal from '~/components/sites/SiteCreateModal.vue'
import SiteEditModal from '~/components/sites/SiteEditModal.vue' import SiteEditModal from '~/components/sites/SiteEditModal.vue'
import { useSiteManagement } from '~/composables/useSiteManagement' import { useSiteManagement } from '~/composables/useSiteManagement'
const { canEdit } = usePermissions()
const { const {
sites, sites,
loading, loading,

View File

@@ -127,6 +127,13 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Commentaires -->
<CommentSection
entity-type="machine_skeleton"
:entity-id="type.id"
:entity-name="type.name"
/>
</div> </div>
<!-- Error State --> <!-- Error State -->
@@ -153,6 +160,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import IconLucideSquarePen from '~icons/lucide/square-pen' import IconLucideSquarePen from '~icons/lucide/square-pen'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const { getMachineTypeById } = useMachineTypesApi() const { getMachineTypeById } = useMachineTypesApi()
const { showError } = useToast() const { showError } = useToast()

View File

@@ -8,6 +8,26 @@
</p> </p>
</div> </div>
<!-- Locked: machines linked -->
<div v-else-if="type && hasMachines" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
{{ type.name }}
</h2>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
<div class="alert alert-warning">
<IconLucideTriangleAlert class="w-5 h-5" />
<span>Ce squelette ne peut pas être modifié car des machines y sont rattachées.</span>
</div>
</div>
</div>
</div>
<!-- Edit Form --> <!-- Edit Form -->
<div v-else-if="type" class="my-8"> <div v-else-if="type" class="my-8">
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
@@ -48,12 +68,14 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { getMachineTypeById, updateMachineType } = useMachineTypesApi() const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
@@ -62,6 +84,10 @@ const { showSuccess, showError } = useToast()
const type = ref(null) const type = ref(null)
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const hasMachines = computed(() => {
const machines = type.value?.machines
return Array.isArray(machines) && machines.length > 0
})
// Données éditées du type // Données éditées du type
const editedType = ref({ const editedType = ref({
@@ -204,6 +230,10 @@ const saveChanges = async () => {
// Charger le type au montage // Charger le type au montage
onMounted(async () => { onMounted(async () => {
if (!canEdit.value) {
router.replace(`/type/${route.params.id}`)
return
}
try { try {
const typeId = route.params.id const typeId = route.params.id
const result = await getMachineTypeById(typeId, true) const result = await getMachineTypeById(typeId, true)

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

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

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

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.

35
e2e/auth.setup.ts Normal file
View File

@@ -0,0 +1,35 @@
import { test as setup, expect } from '@playwright/test'
const AUTH_FILE = 'e2e/.auth/session.json'
/**
* Authentication setup: selects the first available profile
* to establish a session cookie before running any test.
*
* The app uses a profile-based session system:
* - GET /api/session/profiles → list available profiles
* - POST /api/session/profile → activate a profile (sets cookie)
*
* The global middleware (profile.global.ts) redirects to /profiles
* if no active profile is found.
*/
setup('select a profile to authenticate', async ({ page }) => {
// Go to the profiles page
await page.goto('/profiles')
// Wait for profiles to load
await expect(page.getByRole('heading', { name: 'Choisir un profil' })).toBeVisible({ timeout: 15_000 })
// Wait for at least one profile button to appear
const profileButton = page.locator('button.btn-outline').first()
await expect(profileButton).toBeVisible({ timeout: 10_000 })
// Click the first available profile
await profileButton.click()
// Wait for redirect to home page (profile selected → session cookie set)
await page.waitForURL('/', { timeout: 10_000 })
// Save authenticated state (cookies + localStorage)
await page.context().storageState({ path: AUTH_FILE })
})

View File

@@ -0,0 +1,166 @@
import { test, expect } from '@playwright/test'
/**
* E2E tests for Product Category CRUD operations.
*
* Prerequisites:
* - Frontend running on http://localhost:3001 (npm run dev)
* - Backend running on http://localhost:8081 (docker compose up)
* - Auth setup must run first (profile selected)
*/
const UNIQUE = Date.now()
const CATEGORY_NAME = `E2E Catégorie Produit ${UNIQUE}`
const CATEGORY_NOTES = `Notes de test automatisé ${UNIQUE}`
const CATEGORY_NAME_UPDATED = `${CATEGORY_NAME} modifié`
const CATEGORY_NOTES_UPDATED = `${CATEGORY_NOTES} — mis à jour`
test.describe('Product Category CRUD', () => {
test.describe.configure({ mode: 'serial' })
// ──────────────────────────────────────────────
// CREATE
// ──────────────────────────────────────────────
test('should display the product category list page', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('heading', { name: 'Catégories de produit' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByText('Catégories enregistrées')).toBeVisible()
})
test('should navigate to the create form', async ({ page }) => {
await page.goto('/product-category')
// The toolbar button text is "Créer" (with a plus icon)
await page.getByRole('button', { name: /créer/i }).click()
await expect(page).toHaveURL('/product-category/new')
await expect(page.getByRole('heading', { name: 'Nouvelle catégorie de produit' })).toBeVisible()
})
test('should show validation error for short name', async ({ page }) => {
await page.goto('/product-category/new')
await page.locator('#model-type-name').fill('A')
// The form submit button in ModelTypeForm is also "Créer"
await page.locator('button[type="submit"]').click()
await expect(page.getByText('Le nom doit contenir au moins 2 caractères')).toBeVisible()
})
test('should create a new product category', async ({ page }) => {
await page.goto('/product-category/new')
await page.locator('#model-type-name').fill(CATEGORY_NAME)
await page.locator('#model-type-notes').fill(CATEGORY_NOTES)
// Verify category is locked to PRODUCT
const categorySelect = page.locator('#model-type-category')
await expect(categorySelect).toBeDisabled()
await expect(categorySelect).toHaveValue('PRODUCT')
await page.locator('button[type="submit"]').click()
// Should redirect to list and show success toast
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible()
})
// ──────────────────────────────────────────────
// READ
// ──────────────────────────────────────────────
test('should display the created category in the list', async ({ page }) => {
await page.goto('/product-category')
// Target the table cell specifically (desktop view also renders a mobile card)
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
})
test('should find the category via search', async ({ page }) => {
await page.goto('/product-category')
// Type in search input (placeholder: "Rechercher par nom…")
const searchInput = page.getByPlaceholder('Rechercher par nom…')
await searchInput.fill(UNIQUE.toString())
// Wait for debounce (300ms) + API response
await page.waitForTimeout(500)
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 5_000 })
})
// ──────────────────────────────────────────────
// UPDATE
// ──────────────────────────────────────────────
test('should navigate to the edit page', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
// Find the row with our category and click "Éditer"
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME })
await row.getByRole('button', { name: 'Éditer' }).click()
await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 })
})
test('should edit the category name and notes', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME })
await row.getByRole('button', { name: 'Éditer' }).click()
await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 })
// Update name
const nameInput = page.locator('#model-type-name')
await nameInput.clear()
await nameInput.fill(CATEGORY_NAME_UPDATED)
// Update notes
const notesTextarea = page.locator('#model-type-notes')
await notesTextarea.clear()
await notesTextarea.fill(CATEGORY_NOTES_UPDATED)
await page.locator('button[type="submit"]').click()
// Should redirect and show success
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
await expect(page.getByText('Catégorie de produit mise à jour avec succès')).toBeVisible()
})
test('should display updated category in the list', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
})
// ──────────────────────────────────────────────
// DELETE
// ──────────────────────────────────────────────
test('should cancel deletion when clicking Annuler', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirmation modal should appear
await expect(page.getByText('Supprimer ce type ?')).toBeVisible()
await page.getByRole('button', { name: 'Annuler' }).click()
// Category should still be present
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible()
})
test('should delete the category', async ({ page }) => {
await page.goto('/product-category')
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirm deletion in modal
await expect(page.getByText('Supprimer ce type ?')).toBeVisible()
// Click the confirm "Supprimer" button inside the modal (btn-error style)
await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click()
// Should show success toast and category should disappear
await expect(page.getByText(/supprimé avec succès/i)).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).not.toBeVisible()
})
})

299
e2e/product-crud.spec.ts Normal file
View File

@@ -0,0 +1,299 @@
import { test, expect, type Page } from '@playwright/test'
/**
* E2E tests for Product CRUD operations.
*
* Prerequisites:
* - Frontend running on http://localhost:3001 (npm run dev)
* - Backend running on http://localhost:8081 (docker compose up)
* - Auth setup must run first (profile selected)
*
* These tests create a temporary product category, use it to test
* the full product CRUD, then clean up both.
*/
const UNIQUE = Date.now()
const TEST_CATEGORY_NAME = `E2E Cat Produit ${UNIQUE}`
const PRODUCT_NAME = `E2E Produit Test ${UNIQUE}`
const PRODUCT_REFERENCE = `REF-E2E-${UNIQUE}`
const PRODUCT_PRICE = '42.50'
const PRODUCT_NAME_UPDATED = `${PRODUCT_NAME} modifié`
const PRODUCT_REFERENCE_UPDATED = `${PRODUCT_REFERENCE}-UPD`
const PRODUCT_PRICE_UPDATED = '99.99'
// ──────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────
/**
* Creates a product category via the UI.
*/
async function createTestCategory(page: Page) {
await page.goto('/product-category/new')
await page.locator('#model-type-name').fill(TEST_CATEGORY_NAME)
await page.locator('button[type="submit"]').click()
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible()
}
/**
* Selects an option in a SearchSelect component.
*
* The SearchSelect renders:
* .search-select > .relative > input[placeholder]
* .search-select > .relative > div (dropdown) > ul > li > button
*/
async function selectSearchOption(page: Page, placeholder: string, searchText: string) {
const input = page.getByPlaceholder(placeholder)
await input.click()
await input.fill(searchText)
// The dropdown is inside .search-select > .relative > div > ul > li > button
const option = page.locator('.search-select ul li button')
.filter({ hasText: searchText })
.first()
await option.waitFor({ state: 'visible', timeout: 10_000 })
await option.click()
}
/**
* Cleans up test data: deletes the category via the UI.
*/
async function cleanupTestCategory(page: Page) {
await page.goto('/product-category')
// Wait for list to load
await page.waitForTimeout(1_000)
const row = page.getByRole('row').filter({ hasText: TEST_CATEGORY_NAME })
if (await row.isVisible().catch(() => false)) {
await row.getByRole('button', { name: 'Supprimer' }).click()
const confirmBtn = page.locator('button.btn-error').filter({ hasText: 'Supprimer' })
await confirmBtn.waitFor({ state: 'visible', timeout: 5_000 })
await confirmBtn.click()
await page.waitForTimeout(1_000)
}
}
test.describe('Product CRUD', () => {
test.describe.configure({ mode: 'serial' })
// ──────────────────────────────────────────────
// SETUP: Create a test category
// ──────────────────────────────────────────────
test('setup: create a test product category', async ({ page }) => {
await createTestCategory(page)
})
// ──────────────────────────────────────────────
// LIST PAGE
// ──────────────────────────────────────────────
test('should display the product catalog page', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('link', { name: /ajouter un produit/i })).toBeVisible()
await expect(page.getByRole('link', { name: /gérer les catégories/i })).toBeVisible()
})
test('should navigate to create product page', async ({ page }) => {
await page.goto('/product-catalog')
await page.getByRole('link', { name: /ajouter un produit/i }).click()
await expect(page).toHaveURL('/product/create')
await expect(page.getByRole('heading', { name: 'Nouveau produit' })).toBeVisible()
})
// ──────────────────────────────────────────────
// CREATE
// ──────────────────────────────────────────────
test('should show disabled fields until category is selected', async ({ page }) => {
await page.goto('/product/create')
// Name input should be disabled before selecting a category
const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue')
await expect(nameInput).toBeDisabled()
})
test('should create a product with all fields', async ({ page }) => {
await page.goto('/product/create')
// 1. Select category via SearchSelect
await selectSearchOption(page, 'Rechercher une catégorie...', TEST_CATEGORY_NAME)
// Wait for form to enable after category selection
const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue')
await expect(nameInput).toBeEnabled({ timeout: 5_000 })
// 2. Fill form fields
await nameInput.clear()
await nameInput.fill(PRODUCT_NAME)
const referenceInput = page.getByPlaceholder('Référence interne ou fournisseur')
await referenceInput.fill(PRODUCT_REFERENCE)
const priceInput = page.getByPlaceholder('Valeur indicatrice')
await priceInput.fill(PRODUCT_PRICE)
// 3. Submit
await page.getByRole('button', { name: /créer le produit/i }).click()
// Should redirect to catalog and show success
await expect(page).toHaveURL('/product-catalog', { timeout: 15_000 })
await expect(page.getByText('Produit créé avec succès')).toBeVisible()
})
// ──────────────────────────────────────────────
// READ
// ──────────────────────────────────────────────
test('should display the created product in the catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
})
test('should show product reference in the catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible({ timeout: 10_000 })
})
test('should find the product via search', async ({ page }) => {
await page.goto('/product-catalog')
const searchInput = page.getByPlaceholder('Nom ou référence…')
await searchInput.fill(PRODUCT_REFERENCE)
// Wait for client-side filtering
await page.waitForTimeout(300)
await expect(page.getByText(PRODUCT_NAME)).toBeVisible()
await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible()
})
test('should show category link in the catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await expect(row.getByText(TEST_CATEGORY_NAME)).toBeVisible()
})
test('should sort products by name', async ({ page }) => {
await page.goto('/product-catalog')
await page.locator('#product-sort').selectOption('name')
await page.locator('#product-dir').selectOption('asc')
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible()
})
test('should sort products by creation date', async ({ page }) => {
await page.goto('/product-catalog')
await page.locator('#product-sort').selectOption('createdAt')
await page.locator('#product-dir').selectOption('desc')
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible()
})
// ──────────────────────────────────────────────
// UPDATE
// ──────────────────────────────────────────────
test('should navigate to edit page from catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await row.getByRole('link', { name: 'Modifier' }).click()
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
})
test('should show category note on edit page', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await row.getByRole('link', { name: 'Modifier' }).click()
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
// Category should be displayed but disabled
await expect(page.getByText("La catégorie d'origine ne peut pas être modifiée")).toBeVisible()
})
test('should update the product', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
await row.getByRole('link', { name: 'Modifier' }).click()
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
// Update name (label-text "Nom du produit" → sibling input)
const nameInput = page.locator('.form-control').filter({ hasText: 'Nom du produit' }).locator('input')
await nameInput.clear()
await nameInput.fill(PRODUCT_NAME_UPDATED)
// Update reference
const refInput = page.locator('.form-control').filter({ hasText: 'Référence' }).locator('input')
await refInput.clear()
await refInput.fill(PRODUCT_REFERENCE_UPDATED)
// Update price
const priceInput = page.locator('.form-control').filter({ hasText: 'Prix fournisseur' }).locator('input')
await priceInput.clear()
await priceInput.fill(PRODUCT_PRICE_UPDATED)
// Submit
await page.getByRole('button', { name: /enregistrer les modifications/i }).click()
await expect(page).toHaveURL('/product-catalog', { timeout: 10_000 })
await expect(page.getByText('Produit mis à jour avec succès')).toBeVisible()
})
test('should display updated product in catalog', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
await expect(page.getByText(PRODUCT_REFERENCE_UPDATED)).toBeVisible()
})
// ──────────────────────────────────────────────
// DELETE
// ──────────────────────────────────────────────
test('should cancel product deletion', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirmation modal
await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible()
await page.getByRole('button', { name: 'Annuler' }).click()
// Product should still be here
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible()
})
test('should delete the product', async ({ page }) => {
await page.goto('/product-catalog')
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED })
await row.getByRole('button', { name: 'Supprimer' }).click()
// Confirm deletion in modal
await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible()
await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click()
await expect(page.getByText(/supprimé/i)).toBeVisible({ timeout: 10_000 })
// The toast message contains the product name, so check the table specifically
const table = page.locator('table')
await expect(table.getByText(PRODUCT_NAME_UPDATED)).not.toBeVisible({ timeout: 5_000 })
})
// ──────────────────────────────────────────────
// CLEANUP: Remove the test category
// ──────────────────────────────────────────────
test('cleanup: delete the test product category', async ({ page }) => {
await cleanupTestCategory(page)
})
})

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

@@ -41,7 +41,7 @@ export default defineNuxtConfig({
|| process.env.NUXT_PUBLIC_API_BASE_URL || process.env.NUXT_PUBLIC_API_BASE_URL
|| 'http://localhost/api', || 'http://localhost/api',
public: { public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api', apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '/api',
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001', appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System', appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
appVersion: appVersion, appVersion: appVersion,
@@ -54,7 +54,15 @@ export default defineNuxtConfig({
} }
}, },
vite: { vite: {
plugins: [tailwindcss()] plugins: [tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost',
changeOrigin: true,
},
},
},
}, },
css: ['~/assets/app.css'], css: ['~/assets/app.css'],
router: { router: {

81
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.68", "@iconify-json/lucide": "^1.2.68",
"@nuxt/eslint-config": "^1.9.0", "@nuxt/eslint-config": "^1.9.0",
"@playwright/test": "^1.58.2",
"@rushstack/eslint-patch": "^1.12.0", "@rushstack/eslint-patch": "^1.12.0",
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/eslint-plugin": "^8.44.1",
@@ -3202,6 +3203,22 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -5533,12 +5550,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.7", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==", "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
@@ -5830,9 +5850,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001745", "version": "1.0.30001775",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
"integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -10828,6 +10848,53 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View File

@@ -12,7 +12,10 @@
"lint": "eslint . --ext .js,.ts,.vue", "lint": "eslint . --ext .js,.ts,.vue",
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
}, },
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -26,6 +29,7 @@
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.68", "@iconify-json/lucide": "^1.2.68",
"@nuxt/eslint-config": "^1.9.0", "@nuxt/eslint-config": "^1.9.0",
"@playwright/test": "^1.58.2",
"@rushstack/eslint-patch": "^1.12.0", "@rushstack/eslint-patch": "^1.12.0",
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/eslint-plugin": "^8.44.1",

37
playwright.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test'
const AUTH_FILE = 'e2e/.auth/session.json'
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
timeout: 30_000,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Auth setup: selects a profile to get a session cookie
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
},
// All tests run after auth setup, with the saved session
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: AUTH_FILE,
},
dependencies: ['auth-setup'],
},
],
})