32 Commits

Author SHA1 Message Date
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
Matthieu
2fffe4a368 chore(release) : update changelog for v1.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:43 +01:00
Matthieu
c9054e5b4d feat(categories): add bidirectional piece/component category conversion
Add a "Convertir" button on piece and component category lists that allows
converting an entire category (and all its items) between piece and component.
Includes a modal with eligibility checks and blocker display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:26:41 +01:00
Matthieu
5cab15422d fix(documents) : exclude path from collection to prevent OOM, lazy-load on demand
The path field contains base64 data URIs that can be several MB each.
Loading 200 documents at once exceeded the 128MB PHP memory limit.
Now the collection endpoint uses document:list group (without path)
and the frontend fetches the full document on demand when the user
clicks download or preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:16:15 +01:00
Matthieu
439db8117a feat(changelog) : add changelog page accessible from footer version link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:01:28 +01:00
Matthieu
675820532c Merge branch 'develop' into master — v1.5.0 2026-02-11 16:50:44 +01:00
Matthieu
4edfc55c37 Merge branch 'fix/filtres-listes' into develop 2026-02-11 16:50:39 +01:00
Matthieu
480aaa24b2 feat(navigation) : preserve list state in URL and use browser history for back buttons
Add useUrlState composable to sync page, search, sort and filter state
with URL query params. Back/forward navigation now restores the exact
list position. Replace hardcoded NuxtLink back buttons with
router.back() across all create/edit pages. Fix documents attachment
filter that checked non-existent ID fields instead of relation objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:48:40 +01:00
Matthieu
185af65519 fix(filters) : repair broken filters on catalog and document pages
- modelTypes.ts: use API Platform OrderFilter format (order[field]=dir) and proper page param
- product-catalog: load all products (itemsPerPage: 200) instead of default 30
- documents: load all documents (itemsPerPage: 200) instead of default 30
- useDocuments: support itemsPerPage option in loadDocuments/loadFromEndpoint
- pieces-catalog + component-catalog: add force:true to bypass stale cache on sort/filter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:32:54 +01:00
Matthieu
8fecf67a7f fix(api): reduce itemsPerPage from 500 to 200 on bulk catalog loads
Prevents memory exhaustion (OOM) on production server when loading
pieces, products, and composants in the component edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:10:52 +01:00
Matthieu
79d2df8bc6 perf(composables) : add smart cache to usePieces and useComposants
Align with useProducts pattern: loaded flag, cache-first return,
loading guard, and clearCache helper to avoid redundant API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:18:42 +01:00
Matthieu
23da4ba4c7 style(theme) : apply Malio brand colors
Primary #304998 (bleu Malio), base #FBFAFA (gris), accent #ED8521
(orange), secondary #A5ACD0 (lavande). Focus ring updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:06:20 +01:00
Matthieu
635b8f0461 feat(activity-log) : add global activity log page with filters and pagination
New /activity-log page showing all audit entries across pieces, products
and composants. Includes entity type and action filters, expandable
diffs, clickable entity links and pagination. Navbar link added under
Ressources liées.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:12 +01:00
Matthieu
bf74a50f57 feat(catalog) : make category types clickable in catalog pages
Type columns in piece, component and product catalogs now link
directly to the category edit page for quick access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:07 +01:00
Matthieu
7c44778f25 fix(edit-pages): resolve custom field display race condition
The init watcher destructured currentType/currentStructure before
setting selectedTypeId, so the values were stale (null). This caused
refreshCustomFieldInputs to receive null structure → empty definitions,
permanently wiping custom field display on piece and component edit pages.

Read selectedType.value / selectedTypeStructure.value after setting the
ID so the computed is already updated. Also remove the guard on the
piece selectedType watcher that prevented recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:47:54 +01:00
87 changed files with 4484 additions and 1215 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/

111
README.md
View File

@@ -1,75 +1,78 @@
# Nuxt Minimal Starter # Inventory Frontend
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. Frontend de l'application de gestion d'inventaire industriel Malio.
## Setup ## Stack
Make sure to install dependencies: | Tech | Version |
|------|---------|
| Nuxt | 4 (SPA, SSR off) |
| Vue | 3 Composition API |
| TypeScript | 5.7 |
| CSS | TailwindCSS 4 + DaisyUI 5 |
| Icônes | unplugin-icons (Lucide) |
| Tests | Vitest |
## Prérequis
- Node.js >= 20
- npm
- Backend Symfony démarré (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 Le serveur de dev est accessible sur `http://localhost:3001`.
Build the application for production: ## Commandes
```bash ```bash
# npm npm run dev # Serveur de développement
npm run build npm run build # Build production
npm run lint:fix # Correction ESLint
# pnpm npx nuxi typecheck # Vérification TypeScript (0 erreurs attendu)
pnpm build npm run test # Tests unitaires Vitest
# yarn
yarn build
# bun
bun run build
``` ```
Locally preview production build: ## Structure
```bash ```
# npm app/
npm run preview ├── pages/ # Pages Nuxt (file-based routing)
├── components/ # Composants Vue (auto-imported)
# pnpm ├── composables/ # Composables Vue (auto-imported)
pnpm preview ├── shared/ # Types, utils, validation
│ └── utils/ # Utilitaires partagés
# yarn ├── middleware/ # Auth middleware global
yarn preview ├── services/ # Service layer (wrappers useApi)
└── utils/ # Utilitaires Nuxt
# bun
bun run preview
``` ```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. ## Conventions
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
- **Communication** : Props + Events uniquement (pas de provide/inject)
- **API** : `useApi.ts` avec `credentials: 'include'` (auth session/cookie)
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
- **Auto-imports** : Nuxt auto-importe `components/` et `composables/`
## Auth
Authentification par session (cookies), pas de JWT. Le middleware global `profile.global.ts` protège les routes.
## Submodule
Ce repo est un submodule git du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory). Workflow :
1. Commiter ici d'abord
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
3. Pousser les deux repos

View File

@@ -19,7 +19,9 @@
<footer class="footer p-4 bg-neutral text-neutral-content"> <footer class="footer p-4 bg-neutral text-neutral-content">
<div class="items-center grid-flow-col"> <div class="items-center grid-flow-col">
<p>@Malio 2025 · v{{ appVersion }}</p> <p>
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
</p>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -6,26 +6,31 @@
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */ prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
color-scheme: light; /* color of browser-provided UI */ color-scheme: light; /* color of browser-provided UI */
--color-base-100: oklch(98% 0.02 240); /* #FBFAFA — gris clair */
--color-base-200: oklch(95% 0.03 240); --color-base-100: oklch(98% 0.003 0);
--color-base-300: oklch(92% 0.04 240); --color-base-200: oklch(94% 0.01 262);
--color-base-content: oklch(20% 0.05 240); --color-base-300: oklch(90% 0.02 262);
--color-primary: oklch(55% 0.3 240); --color-base-content: oklch(20% 0.03 262);
--color-primary-content: oklch(98% 0.01 240); /* #304998 — bleu Malio */
--color-secondary: oklch(70% 0.25 200); --color-primary: oklch(37% 0.15 262);
--color-secondary-content: oklch(98% 0.01 200); --color-primary-content: oklch(98% 0.005 262);
--color-accent: oklch(65% 0.25 160); /* #A5ACD0 — lavande */
--color-accent-content: oklch(98% 0.01 160); --color-secondary: oklch(75% 0.055 270);
--color-neutral: oklch(50% 0.05 240); --color-secondary-content: oklch(20% 0.03 270);
--color-neutral-content: oklch(98% 0.01 240); /* #ED8521 — orange */
--color-info: oklch(70% 0.2 220); --color-accent: oklch(71% 0.17 58);
--color-info-content: oklch(98% 0.01 220); --color-accent-content: oklch(98% 0.005 58);
--color-success: oklch(65% 0.25 140); /* neutral dérivé du bleu Malio */
--color-success-content: oklch(98% 0.01 140); --color-neutral: oklch(37% 0.08 262);
--color-warning: oklch(80% 0.25 80); --color-neutral-content: oklch(98% 0.005 262);
--color-warning-content: oklch(20% 0.05 80); --color-info: oklch(55% 0.12 262);
--color-error: oklch(65% 0.3 30); --color-info-content: oklch(98% 0.005 262);
--color-error-content: oklch(98% 0.01 30); --color-success: oklch(65% 0.2 145);
--color-success-content: oklch(98% 0.005 145);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(20% 0.05 70);
--color-error: oklch(60% 0.25 25);
--color-error-content: oklch(98% 0.005 25);
/* border radius */ /* border radius */
--radius-selector: 1rem; --radius-selector: 1rem;
@@ -114,7 +119,7 @@
/* Focus visible pour l'accessibilité */ /* Focus visible pour l'accessibilité */
*:focus-visible { *:focus-visible {
outline: 2px solid #3b82f6; outline: 2px solid #304998;
outline-offset: 2px; outline-offset: 2px;
} }

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

@@ -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,24 @@ 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'], 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é' },
], ],
}, },
] ]
@@ -287,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 === '/') {
@@ -317,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

@@ -0,0 +1,172 @@
<template>
<dialog class="modal" :class="{ 'modal-open': open }">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold text-base-content">
Convertir la catégorie
</h3>
<!-- Loading state -->
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Vérification de la conversion
</div>
<!-- Error state -->
<div v-else-if="checkError" class="mt-4 text-sm text-error">
{{ checkError }}
</div>
<!-- Blocked state -->
<template v-else-if="checkResult && !checkResult.canConvert">
<p class="mt-3 text-sm text-base-content/70">
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
</p>
<ul class="mt-3 space-y-1">
<li
v-for="(blocker, i) in checkResult.blockers"
:key="i"
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
>
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
{{ blocker }}
</li>
</ul>
</template>
<!-- Eligible state -->
<template v-else-if="checkResult && checkResult.canConvert">
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
<p class="text-sm font-medium text-warning">
{{ directionLabel }}
</p>
<p class="mt-1 text-sm text-base-content/70">
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
</p>
</div>
<div
v-if="checkResult.names.length > 0"
class="mt-3 rounded-xl border border-base-200 bg-base-100"
>
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
Éléments concernés :
</p>
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
<li
v-for="(name, i) in checkResult.names"
:key="i"
class="py-1.5 text-sm text-base-content"
>
{{ name }}
</li>
</ul>
</div>
<div v-if="convertError" class="mt-3 text-sm text-error">
{{ convertError }}
</div>
</template>
<div class="modal-action">
<button
type="button"
class="btn"
:disabled="converting"
@click="emit('close')"
>
Fermer
</button>
<button
v-if="checkResult?.canConvert"
type="button"
class="btn btn-warning"
:disabled="converting"
@click="doConvert"
>
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Convertir
</button>
</div>
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import IconLucideCircleX from '~icons/lucide/circle-x';
import {
checkConversion,
convertCategory,
type ConversionCheck,
type ModelType,
} from '~/services/modelTypes';
const props = defineProps<{
open: boolean;
modelType: ModelType | null;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'converted'): void;
}>();
const checking = ref(false);
const checkError = ref<string | null>(null);
const checkResult = ref<ConversionCheck | null>(null);
const converting = ref(false);
const convertError = ref<string | null>(null);
const directionLabel = computed(() => {
if (!checkResult.value) return '';
return checkResult.value.direction === 'piece_to_component'
? 'Conversion : Catégorie de pièce → Catégorie de composant'
: 'Conversion : Catégorie de composant → Catégorie de pièce';
});
watch(
() => props.open,
async (isOpen) => {
if (!isOpen || !props.modelType) {
return;
}
checking.value = true;
checkError.value = null;
checkResult.value = null;
convertError.value = null;
try {
checkResult.value = await checkConversion(props.modelType.id);
} catch (err: any) {
checkError.value =
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
} finally {
checking.value = false;
}
},
);
const doConvert = async () => {
if (!props.modelType) return;
converting.value = true;
convertError.value = null;
try {
const result = await convertCategory(props.modelType.id);
if (!result.success) {
convertError.value = result.error || 'La conversion a échoué.';
return;
}
emit('converted');
} catch (err: any) {
convertError.value =
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
} finally {
converting.value = false;
}
};
</script>

View File

@@ -16,6 +16,7 @@
:dir="dir" :dir="dir"
:loading="loading" :loading="loading"
:show-category-tabs="allowCategorySwitch" :show-category-tabs="allowCategorySwitch"
:can-edit="canEdit"
@update:category="onCategoryChange" @update:category="onCategoryChange"
@update:search="onSearchInput" @update:search="onSearchInput"
@update:sort="onSortChange" @update:sort="onSortChange"
@@ -29,12 +30,22 @@
:total="total" :total="total"
:limit="limit" :limit="limit"
:offset="offset" :offset="offset"
:category="selectedCategory"
:can-edit="canEdit"
@related="openRelatedModal" @related="openRelatedModal"
@edit="openEditPage" @edit="openEditPage"
@delete="confirmDelete" @delete="confirmDelete"
@convert="openConversionModal"
@update:offset="onOffsetChange" @update:offset="onOffsetChange"
/> />
<ModelTypesConversionModal
:open="conversionModalOpen"
:model-type="conversionTarget"
@close="closeConversionModal"
@converted="onConverted"
/>
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }"> <dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
<div class="modal-box max-w-3xl"> <div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold text-base-content"> <h3 class="text-lg font-bold text-base-content">
@@ -92,11 +103,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
import { useHead, useRouter } from "#imports"; import { useHead, useRouter } from "#imports";
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue"; import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
import ModelTypesTable from "~/components/model-types/Table.vue"; import ModelTypesTable from "~/components/model-types/Table.vue";
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
import { useApi } from "~/composables/useApi"; import { useApi } from "~/composables/useApi";
import { useUrlState } from "~/composables/useUrlState";
import { extractCollection } from "~/shared/utils/apiHelpers"; import { extractCollection } from "~/shared/utils/apiHelpers";
import { import {
deleteModelType, deleteModelType,
@@ -106,6 +119,7 @@ import {
type ModelTypeListResponse, type ModelTypeListResponse,
} from "~/services/modelTypes"; } from "~/services/modelTypes";
import { useToast } from "~/composables/useToast"; import { useToast } from "~/composables/useToast";
import { humanizeError } from "~/shared/utils/errorMessages";
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes"; import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
const DEFAULT_DESCRIPTION = const DEFAULT_DESCRIPTION =
@@ -125,11 +139,28 @@ const props = withDefaults(
const selectedCategory = ref<ModelCategory>(props.category); const selectedCategory = ref<ModelCategory>(props.category);
const searchInput = ref(""); const searchInput = ref("");
const searchTerm = ref("");
const sort = ref<"name" | "createdAt">("name"); // State synced with URL query params (preserved on back/forward navigation)
const dir = ref<"asc" | "desc">("asc"); const urlState = useUrlState({
const limit = ref(20); q: { default: '' },
const offset = ref(0); sort: { default: 'name' },
dir: { default: 'asc' },
limit: { default: 20, type: 'number' },
offset: { default: 0, type: 'number' },
}, {
onRestore: () => {
searchInput.value = urlState.q.value;
refresh();
},
});
const searchTerm = urlState.q;
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
const dir = urlState.dir as Ref<'asc' | 'desc'>;
const limit = urlState.limit;
const offset = urlState.offset;
// Initialize searchInput from URL (for direct navigation with ?q=...)
searchInput.value = searchTerm.value;
const items = ref<ModelType[]>([]); const items = ref<ModelType[]>([]);
const total = ref(0); const total = ref(0);
@@ -141,6 +172,7 @@ 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(
@@ -152,7 +184,8 @@ useHead(() => ({
title: headingText.value, title: headingText.value,
})); }));
const extractErrorMessage = (error: unknown) => { const extractErrorMessage = (error: unknown): string => {
let raw: string | null = null;
if (error && typeof error === "object") { if (error && typeof error === "object") {
const maybeFetchError = error as { const maybeFetchError = error as {
data?: Record<string, unknown>; data?: Record<string, unknown>;
@@ -161,21 +194,16 @@ const extractErrorMessage = (error: unknown) => {
}; };
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 refresh = async ({
@@ -466,6 +494,26 @@ const closeRelatedModal = () => {
relatedModalOpen.value = false; relatedModalOpen.value = false;
}; };
const conversionModalOpen = ref(false);
const conversionTarget = ref<ModelType | null>(null);
const openConversionModal = (item: ModelType) => {
conversionTarget.value = item;
conversionModalOpen.value = true;
};
const closeConversionModal = () => {
conversionModalOpen.value = false;
};
const onConverted = () => {
conversionModalOpen.value = false;
invalidateEntityTypeCache("PIECE");
invalidateEntityTypeCache("COMPONENT");
showSuccess("Catégorie convertie avec succès.");
refresh();
};
watch( watch(
() => searchInput.value, () => searchInput.value,
(value) => { (value) => {

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

@@ -48,10 +48,19 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés Liés
</button> </button>
<button
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
>
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
Convertir
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)"> <button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer Supprimer
</button> </button>
</td> </td>
@@ -78,10 +87,19 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés Liés
</button> </button>
<button
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
>
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
Convertir
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)"> <button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer Supprimer
</button> </button>
</footer> </footer>
@@ -118,6 +136,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import IconLucideInbox from '~icons/lucide/inbox'; import IconLucideInbox from '~icons/lucide/inbox';
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
import type { ModelType, ModelCategory } from '~/services/modelTypes'; import type { ModelType, ModelCategory } from '~/services/modelTypes';
const props = defineProps<{ const props = defineProps<{
@@ -126,15 +145,22 @@ const props = defineProps<{
total: number; total: number;
limit: number; limit: number;
offset: number; offset: number;
category?: ModelCategory;
canEdit?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'related', item: ModelType): void; (e: 'related', item: ModelType): void;
(e: 'edit', item: ModelType): void; (e: 'edit', item: ModelType): void;
(e: 'delete', item: ModelType): void; (e: 'delete', item: ModelType): void;
(e: 'convert', item: ModelType): void;
(e: 'update:offset', offset: number): void; (e: 'update:offset', offset: number): void;
}>(); }>();
const showConvertButton = computed(() =>
props.category === 'PIECE' || props.category === 'COMPONENT',
);
const categoryDictionary: Record<ModelCategory, string> = { const categoryDictionary: Record<ModelCategory, string> = {
COMPONENT: 'Composants', COMPONENT: 'Composants',
PIECE: 'Pièces', PIECE: 'Pièces',

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

View File

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

View File

@@ -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,184 @@
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
page?: number
itemsPerPage?: number
} = {}): Promise<CommentListResult> => {
loading.value = true
try {
const params = new URLSearchParams()
if (options.status) params.set('status', options.status)
if (options.entityType) params.set('entityType', options.entityType)
params.set('order[createdAt]', 'desc')
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,11 +41,13 @@ interface LoadComposantsOptions {
itemsPerPage?: number itemsPerPage?: number
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
force?: boolean
} }
const composants = ref<Composant[]>([]) const composants = ref<Composant[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => { const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null const p = payload as Record<string, unknown> | null
@@ -98,15 +101,31 @@ export function useComposants() {
} }
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => { const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true loading.value = true
try { try {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
} = options
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
@@ -124,6 +143,7 @@ export function useComposants() {
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems composants.value = enrichedItems
total.value = extractTotal(result.data, items.length) total.value = extractTotal(result.data, items.length)
loaded.value = true
return { return {
success: true, success: true,
data: { data: {
@@ -216,15 +236,23 @@ export function useComposants() {
const getComposants = () => composants.value const getComposants = () => composants.value
const isLoading = () => loading.value const isLoading = () => loading.value
const clearComposantsCache = () => {
composants.value = []
total.value = 0
loaded.value = false
}
return { return {
composants, composants,
total, total,
loading, loading,
loaded,
loadComposants, loadComposants,
createComposant, createComposant,
updateComposant: updateComposantData, updateComposant: updateComposantData,
deleteComposant, deleteComposant,
getComposants, getComposants,
isLoading, isLoading,
clearComposantsCache,
} }
} }

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

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,11 +42,13 @@ interface LoadPiecesOptions {
itemsPerPage?: number itemsPerPage?: number
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
force?: boolean
} }
const pieces = ref<Piece[]>([]) const pieces = ref<Piece[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => { const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null const p = payload as Record<string, unknown> | null
@@ -108,15 +111,31 @@ export function usePieces() {
} }
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => { const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true loading.value = true
try { try {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
} = options
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
@@ -134,6 +153,7 @@ export function usePieces() {
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems pieces.value = enrichedItems
total.value = extractTotal(result.data, items.length) total.value = extractTotal(result.data, items.length)
loaded.value = true
return { return {
success: true, success: true,
data: { data: {
@@ -226,15 +246,23 @@ export function usePieces() {
const getPieces = () => pieces.value const getPieces = () => pieces.value
const isLoading = () => loading.value const isLoading = () => loading.value
const clearPiecesCache = () => {
pieces.value = []
total.value = 0
loaded.value = false
}
return { return {
pieces, pieces,
total, total,
loading, loading,
loaded,
loadPieces, loadPieces,
createPiece, createPiece,
updatePiece: updatePieceData, updatePiece: updatePieceData,
deletePiece, deletePiece,
getPieces, getPieces,
isLoading, isLoading,
clearPiecesCache,
} }
} }

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'
@@ -168,9 +169,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 +198,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 +224,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 +249,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

@@ -0,0 +1,116 @@
import { ref, watch, nextTick, type Ref } from 'vue'
import { useRoute, useRouter } from '#imports'
interface ParamDef<T extends string | number = string | number> {
default: T
type?: 'string' | 'number'
/** Debounce URL writes (ms). Default: 0 (immediate). */
debounce?: number
}
type ParamDefs = Record<string, ParamDef>
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
type StateRefs<T extends ParamDefs> = {
[K in keyof T]: InferRef<T[K]>
}
interface UseUrlStateOptions {
/** Called when state is restored from URL (back/forward navigation). */
onRestore?: () => void
}
export function useUrlState<T extends ParamDefs>(
params: T,
options?: UseUrlStateOptions,
): StateRefs<T> {
const route = useRoute()
const router = useRouter()
const keys = Object.keys(params) as (keyof T & string)[]
const refs: Record<string, Ref<string | number>> = {}
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
for (const key of keys) {
refs[key] = ref(parseValue(route.query[key], params[key]!))
timers[key] = null
}
let isProgrammatic = false
const buildQuery = (): Record<string, string> => {
const q: Record<string, string> = {}
for (const key of keys) {
const val = refs[key]!.value
if (val !== params[key]!.default) {
q[key] = String(val)
}
}
return q
}
const pushToUrl = () => {
if (isProgrammatic) return
isProgrammatic = true
const query = buildQuery()
router
.replace({ path: route.path, query })
.catch(() => {})
.finally(() => {
nextTick(() => {
isProgrammatic = false
})
})
}
for (const key of keys) {
const ms = params[key]!.debounce ?? 0
watch(refs[key]!, () => {
if (isProgrammatic) return
if (ms > 0) {
if (timers[key]) clearTimeout(timers[key]!)
timers[key] = setTimeout(pushToUrl, ms)
} else {
pushToUrl()
}
})
}
watch(
() => ({ ...route.query }),
(newQuery) => {
if (isProgrammatic) return
isProgrammatic = true
let changed = false
for (const key of keys) {
const parsed = parseValue(newQuery[key], params[key]!)
if (refs[key]!.value !== parsed) {
refs[key]!.value = parsed
changed = true
}
}
nextTick(() => {
isProgrammatic = false
if (changed && options?.onRestore) {
options.onRestore()
}
})
},
)
return refs as StateRefs<T>
}
function parseValue(
raw: unknown,
def: ParamDef,
): string | number {
const str = typeof raw === 'string' ? raw : null
if (str === null) return def.default
if (def.type === 'number' || typeof def.default === 'number') {
const n = Number(str)
return Number.isFinite(n) ? n : def.default
}
return str
}

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("/");
}
}
}
}); });

274
app/pages/activity-log.vue Normal file
View File

@@ -0,0 +1,274 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header>
<h1 class="text-3xl font-semibold text-base-content">Journal d'activité</h1>
<p class="text-sm text-gray-500">
Historique des modifications sur l'ensemble des pièces, produits et composants.
</p>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-entity-type"
>
Type
</label>
<select
id="activity-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="">Tous</option>
<option value="piece">Pièce</option>
<option value="product">Produit</option>
<option value="composant">Composant</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-action"
>
Action
</label>
<select
id="activity-action"
v-model="actionFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="">Toutes</option>
<option value="create">Création</option>
<option value="update">Modification</option>
<option value="delete">Suppression</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-per-page"
>
Par page
</label>
<select
id="activity-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!total" class="text-sm text-base-content/70">
Aucune activité enregistrée.
</p>
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
Aucune activité ne correspond à vos filtres.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Type</th>
<th>Entité</th>
<th>Auteur</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
<template v-for="entry in entries" :key="entry.id">
<tr>
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
<td>
<span
class="badge badge-sm"
:class="actionBadgeClass(entry.action)"
>
{{ historyActionLabel(entry.action) }}
</span>
</td>
<td>
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(entry.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="entry.action !== 'delete'"
:to="entityEditLink(entry)"
class="link link-hover link-primary"
>
{{ entry.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ entry.entityName || 'Sans nom' }}
</span>
<span
v-if="entry.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ entry.entityRef }})
</span>
</td>
<td>{{ entry.actor?.label || '—' }}</td>
<td>
<button
v-if="hasDiff(entry)"
type="button"
class="btn btn-ghost btn-xs"
@click="toggleExpanded(entry.id)"
>
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
</button>
<span v-else class="text-xs text-base-content/50"></span>
</td>
</tr>
<tr v-if="expandedIds.has(entry.id)">
<td colspan="6" class="bg-base-200/50 p-4">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
>
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<span></span>
<span class="text-success">{{ diffEntry.toLabel }}</span>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useActivityLog } from '~/composables/useActivityLog'
import type { ActivityLogEntry } from '~/composables/useActivityLog'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
import Pagination from '~/components/common/Pagination.vue'
const { entries, total, loading, loadActivityLog } = useActivityLog()
const currentPage = ref(1)
const itemsPerPage = ref(50)
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
const entityTypeFilter = ref('')
const actionFilter = ref('')
const expandedIds = reactive(new Set<string>())
const toggleExpanded = (id: string) => {
if (expandedIds.has(id)) expandedIds.delete(id)
else expandedIds.add(id)
}
const hasDiff = (entry: ActivityLogEntry) =>
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
const fetchLog = () => {
loadActivityLog({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
entityType: entityTypeFilter.value || undefined,
action: actionFilter.value || undefined,
})
}
const handleFilterChange = () => {
currentPage.value = 1
fetchLog()
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchLog()
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce',
product: 'Produit',
composant: 'Composant',
}
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
const ENTITY_EDIT_ROUTES: Record<string, string> = {
piece: '/pieces',
product: '/product',
composant: '/component',
}
const entityEditLink = (entry: ActivityLogEntry) => {
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
return base ? `${base}/${entry.entityId}/edit` : '#'
}
const actionBadgeClass = (action: string) => {
if (action === 'create') return 'badge-success'
if (action === 'delete') return 'badge-error'
return 'badge-warning'
}
const globalFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
supplierPrice: 'Prix fournisseur',
typePiece: 'Type de pièce',
typeProduct: 'Type de produit',
typeComposant: 'Type de composant',
product: 'Produit',
productIds: 'Produits',
constructeurIds: 'Fournisseurs',
structure: 'Structure',
}
onMounted(fetchLog)
</script>

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

@@ -0,0 +1,245 @@
<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>
<div v-if="loading" class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="profiles.length" class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Nom</th>
<th>Email</th>
<th>Role</th>
<th>Mot de passe</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in profiles" :key="profile.id">
<td class="font-medium">
{{ profile.firstName }} {{ profile.lastName }}
</td>
<td class="text-sm text-base-content/70">
{{ profile.email || '-' }}
</td>
<td>
<select
class="select select-bordered select-xs"
:value="primaryRole(profile)"
@change="handleRoleChange(profile.id, $event.target.value)"
>
<option value="ROLE_ADMIN">
Admin
</option>
<option value="ROLE_GESTIONNAIRE">
Gestionnaire
</option>
<option value="ROLE_VIEWER">
Viewer
</option>
</select>
</td>
<td>
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
<span v-else class="badge badge-ghost badge-sm">Non</span>
<button
class="btn btn-ghost btn-xs ml-1"
@click="openPasswordDialog(profile.id)"
>
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
</button>
</td>
<td>
<span
class="badge badge-sm"
:class="profile.isActive ? 'badge-success' : 'badge-error'"
>
{{ profile.isActive ? 'Actif' : 'Inactif' }}
</span>
</td>
<td>
<button
v-if="profile.isActive"
class="btn btn-ghost btn-xs text-error"
@click="handleDeactivate(profile.id)"
>
Desactiver
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-center py-12 text-base-content/60">
Aucun profil.
</div>
<!-- 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, onMounted } from 'vue'
import { useAdminProfiles } from '#imports'
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
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(() => {
fetchAll()
})
</script>

213
app/pages/changelog.vue Normal file
View File

@@ -0,0 +1,213 @@
<template>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
<p class="text-sm text-base-content/70">
Historique des modifications et nouvelles fonctionnalités de l'application.
</p>
</header>
<section
v-for="release in releases"
:key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm"
>
<div class="card-body space-y-3">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content">
{{ release.version }}
</h2>
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
</div>
<ul class="space-y-2">
<li
v-for="(item, i) in release.changes"
:key="i"
class="flex items-start gap-2 text-sm text-base-content/80"
>
<span
class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)"
>
{{ item.type }}
</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { useHead } from '#imports'
useHead({ title: 'Changelog' })
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore'
interface Change {
type: ChangeType
text: string
}
interface Release {
version: string
date: string
changes: Change[]
}
const badgeClass = (type: ChangeType) => {
const map: Record<ChangeType, string> = {
feat: 'badge-primary',
fix: 'badge-error',
perf: 'badge-warning',
chore: 'badge-ghost',
}
return map[type] ?? 'badge-ghost'
}
const releases: Release[] = [
{
version: 'v1.8.0',
date: '2026-03-03',
changes: [
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
],
},
{
version: 'v1.7.0',
date: '2026-03-02',
changes: [
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
],
},
{
version: 'v1.6.1',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
],
},
{
version: 'v1.6.0',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
],
},
{
version: 'v1.5.0',
date: '2026-02-11',
changes: [
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
],
},
{
version: 'v1.4.0',
date: '2026-02-04',
changes: [
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
],
},
{
version: 'v1.3.0',
date: '2026-01-28',
changes: [
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
],
},
{
version: 'v1.2.0',
date: '2026-01-21',
changes: [
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
],
},
{
version: 'v1.1.1',
date: '2026-01-14',
changes: [
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
],
},
{
version: 'v1.1.0',
date: '2026-01-07',
changes: [
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
],
},
{
version: 'v1.0.0',
date: '2025-12-15',
changes: [
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
],
},
]
</script>

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

@@ -0,0 +1,331 @@
<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">
<!-- Filtres -->
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<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="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="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>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="comment-per-page"
>
Par page
</label>
<select
id="comment-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">
20
</option>
<option :value="50">
50
</option>
<option :value="100">
100
</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<!-- Loading -->
<div v-if="loadingList" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<!-- Empty states -->
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
Aucun commentaire trouvé.
</p>
<!-- Table -->
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Contenu</th>
<th>Type</th>
<th>Item</th>
<th>Auteur</th>
<th>Date</th>
<th>Statut</th>
<th v-if="canEdit">
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="comment in comments"
:key="comment.id"
class="hover"
>
<td class="max-w-xs">
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
</td>
<td>
<span class="badge badge-outline badge-sm">
{{ entityTypeLabel(comment.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="getEntityRoute(comment)"
:to="getEntityRoute(comment)!"
class="link link-primary text-sm font-medium"
>
{{ comment.entityName || comment.entityId }}
</NuxtLink>
<span v-else class="text-sm">
{{ comment.entityName || comment.entityId }}
</span>
</td>
<td class="text-sm">
{{ comment.authorName }}
</td>
<td class="text-sm whitespace-nowrap">
{{ formatCommentDate(comment.createdAt) }}
</td>
<td>
<span
class="badge badge-sm"
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
>
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
</span>
</td>
<td v-if="canEdit" @click.stop>
<button
v-if="comment.status === 'open'"
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(comment.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<span v-else class="text-xs text-base-content/50">
{{ comment.resolvedByName }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
<button
class="btn btn-sm"
:disabled="page <= 1"
@click="goToPage(page - 1)"
>
Précédent
</button>
<span class="flex items-center text-sm text-base-content/70">
Page {{ page }} / {{ totalPages }}
</span>
<button
class="btn btn-sm"
:disabled="page >= totalPages"
@click="goToPage(page + 1)"
>
Suivant
</button>
</div>
</template>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useComments, type Comment } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import IconLucideCheck from '~icons/lucide/check'
const { canEdit } = usePermissions()
const {
loading,
fetchAllComments,
resolveComment,
} = useComments()
const comments = ref<Comment[]>([])
const total = ref(0)
const page = ref(1)
const itemsPerPage = ref(20)
const statusFilter = ref('open')
const entityTypeFilter = ref('')
const loadingList = ref(false)
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
)
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)
}
const loadComments = async () => {
loadingList.value = true
const result = await fetchAllComments({
status: statusFilter.value || undefined,
entityType: entityTypeFilter.value || undefined,
page: page.value,
itemsPerPage: itemsPerPage.value,
})
if (result.success) {
comments.value = result.data ?? []
total.value = result.total ?? 0
}
loadingList.value = false
}
const handleFilterChange = () => {
page.value = 1
loadComments()
}
const goToPage = (p: number) => {
page.value = p
loadComments()
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {
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

@@ -116,6 +116,7 @@
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Description</th>
<th>Type de composant</th> <th>Type de composant</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -130,7 +131,25 @@
</td> </td>
<td>{{ component.name || 'Composant sans nom' }}</td> <td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td> <td>{{ component.reference || '—' }}</td>
<td>{{ resolveComponentType(component) }}</td> <td class="max-w-xs">
<div v-if="component.description" class="group relative">
<span class="block cursor-help truncate">{{ 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">{{ component.description }}</p>
</div>
</div>
<span v-else></span>
</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> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -140,6 +159,7 @@
Modifier Modifier
</NuxtLink> </NuxtLink>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-error btn-xs"
:disabled="loadingComposants" :disabled="loadingComposants"
@@ -167,29 +187,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue' import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
const { showError } = useToast() const { showError } = useToast()
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants() const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value) const loadingComposants = computed(() => loadingComposantsRef.value)
// Pagination state // State synced with URL query params (preserved on back/forward navigation)
const currentPage = ref(1) const {
const itemsPerPage = ref(30) page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchComposants(),
})
const composantsTotal = computed(() => total.value) const composantsTotal = computed(() => total.value)
const composantsOnPage = computed(() => composants.value.length) const composantsOnPage = computed(() => composants.value.length)
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1) const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
// Search state with debounce // Search debounce for API calls
const searchTerm = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => { const debouncedSearch = () => {
@@ -202,12 +236,6 @@ const debouncedSearch = () => {
}, 300) }, 300)
} }
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'component-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les composants avec les types de composants complets // 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) => {
@@ -225,7 +253,8 @@ const fetchComposants = async () => {
page: currentPage.value, page: currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: itemsPerPage.value,
orderBy: sortField.value, orderBy: sortField.value,
orderDir: sortDirection.value orderDir: sortDirection.value as 'asc' | 'desc',
force: true
}) })
} }
@@ -250,7 +279,7 @@ const resolvePrimaryDocument = (component: Record<string, any>) => {
return null return null
} }
const normalized = documents.filter((doc) => doc && typeof doc === 'object') const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path) const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc) => isPdfDocument(doc)) const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) { if (pdf) {
return pdf return pdf

View File

@@ -8,9 +8,9 @@
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants. Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/component-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>
@@ -26,6 +26,7 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked" :disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage" :disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode" :restricted-mode="isRestrictedMode"
@@ -34,6 +35,16 @@
@cancel="handleCancel" @cancel="handleCancel"
/> />
</section> </section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="component_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
</main> </main>
</template> </template>
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
@@ -128,6 +140,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) { if (guardSubmitOrNotify()) {
return return
} }

View File

@@ -8,9 +8,9 @@
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie. Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/component-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>
@@ -20,6 +20,7 @@
initial-category="COMPONENT" initial-category="COMPONENT"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes' import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({ useHead(() => ({
title: 'Nouvelle catégorie de composant', title: 'Nouvelle catégorie de composant',
})) }))
@@ -50,6 +53,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => { const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true saving.value = true
try { try {
const enrichedPayload = { const enrichedPayload = {

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">
@@ -19,9 +20,9 @@
</p> </p>
</div> </div>
</div> </div>
<NuxtLink to="/component-catalog" class="btn btn-primary mt-6"> <button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto"> <section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
@@ -33,9 +34,9 @@
Mettez à jour les informations du composant et ses champs personnalisés. Mettez à jour les informations du composant et ses champs personnalisés.
</p> </p>
</div> </div>
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -72,13 +73,26 @@
v-model="editionForm.name" v-model="editionForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue" placeholder="Nom affiché dans le catalogue"
required required
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -88,7 +102,7 @@
v-model="editionForm.reference" v-model="editionForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
@@ -100,7 +114,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="editionForm.constructeurIds" v-model="editionForm.constructeurIds"
class="w-full" class="w-full"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []" :initial-options="component?.constructeurs || []"
/> />
@@ -118,7 +132,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Valeur indicatrice" placeholder="Valeur indicatrice"
> >
</div> </div>
@@ -277,7 +291,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -286,14 +300,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -304,24 +318,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="saving" :disabled="!canEdit || saving"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else v-else
@@ -329,7 +343,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
</div> </div>
</div> </div>
@@ -347,7 +361,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload <DocumentUpload
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -373,8 +387,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -419,6 +433,7 @@
Télécharger Télécharger
</button> </button>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-error btn-xs"
:disabled="uploadingDocuments" :disabled="uploadingDocuments"
@@ -511,6 +526,16 @@
Enregistrer les modifications Enregistrer les modifications
</button> </button>
</div> </div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -566,6 +591,7 @@ interface ComponentCatalogType extends ModelType {
customFields?: Array<Record<string, any>> customFields?: Array<Record<string, any>>
} }
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
@@ -613,6 +639,7 @@ const historyDiffEntries = (entry: ComponentHistoryEntry) =>
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -746,6 +773,7 @@ const requiredCustomFieldsFilled = computed(() =>
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
canEdit.value &&
component.value && component.value &&
editionForm.name && editionForm.name &&
requiredCustomFieldsFilled.value && requiredCustomFieldsFilled.value &&
@@ -792,6 +820,7 @@ watch(
selectedTypeId.value = resolvedTypeId selectedTypeId.value = resolvedTypeId
editionForm.name = currentComponent.name || '' editionForm.name = currentComponent.name || ''
editionForm.description = currentComponent.description || ''
editionForm.reference = currentComponent.reference || '' editionForm.reference = currentComponent.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent, currentComponent,
@@ -803,7 +832,9 @@ watch(
void ensureConstructeurs(editionForm.constructeurIds) void ensureConstructeurs(editionForm.constructeurIds)
} }
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues) // After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
// the stale destructured currentStructure which was captured before the ID change.
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
initialized = true initialized = true
}, },
@@ -830,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()
@@ -1134,9 +1166,9 @@ onMounted(async () => {
// Defer bulk catalog loads — not needed for initial render // Defer bulk catalog loads — not needed for initial render
Promise.allSettled([ Promise.allSettled([
loadPieces({ itemsPerPage: 500 }), loadPieces({ itemsPerPage: 200 }),
loadProducts({ itemsPerPage: 500 }), loadProducts({ itemsPerPage: 200 }),
loadComposants({ itemsPerPage: 500 }), loadComposants({ itemsPerPage: 200 }),
]).catch(() => {}) ]).catch(() => {})
}) })
</script> </script>

View File

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

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>
@@ -73,9 +73,9 @@
<td class="text-right"> <td class="text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)"> <button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
Modifier {{ canEdit ? 'Modifier' : 'Consulter' }}
</button> </button>
<button class="btn btn-error btn-xs" @click="confirmDelete(constructeur)"> <button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
Supprimer Supprimer
</button> </button>
</div> </div>
@@ -90,22 +90,22 @@
<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 +117,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted } from '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,6 +126,7 @@ 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()
@@ -194,8 +195,18 @@ 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
@@ -221,7 +232,7 @@ const confirmDelete = async (constructeur) => {
} }
} }
loadConstructeurs() onMounted(() => loadConstructeurs())
</script> </script>
<style scoped> <style scoped>

View File

@@ -3,46 +3,107 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="documents"
@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"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="w-full md:w-2/3"> <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<label class="label"> <label class="w-full sm:w-72">
<span class="label-text">Recherche</span> <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 du document..."
@input="debouncedSearch"
/>
</label> </label>
<input
v-model="searchTerm" <div class="flex items-center gap-2">
type="search" <label
placeholder="Nom du document, type, site, machine..." class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
class="input input-bordered w-full" for="doc-filter"
> >
Rattachement
</label>
<select
id="doc-filter"
v-model="attachmentFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="all">Tous</option>
<option value="site">Sites</option>
<option value="machine">Machines</option>
<option value="composant">Composants</option>
<option value="piece">Pi&egrave;ces</option>
<option value="product">Produits</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-sort"
>
Trier par
</label>
<select
id="doc-sort"
v-model="sortField"
class="select select-bordered select-sm"
@change="handleSortChange"
>
<option value="createdAt">Date</option>
<option value="name">Nom</option>
<option value="size">Taille</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-dir"
>
Ordre
</label>
<select
id="doc-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
@change="handleSortChange"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-per-page"
>
Par page
</label>
<select
id="doc-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> </div>
<div class="w-full md:w-1/3"> <p class="text-xs text-base-content/50 lg:text-right">
<label class="label"> {{ documentsOnPage }} / {{ documentsTotal }} r&eacute;sultat{{ documentsTotal > 1 ? 's' : '' }}
<span class="label-text">Filtrer par rattachement</span> </p>
</label>
<select v-model="attachmentFilter" class="select select-bordered w-full">
<option value="all">
Tous
</option>
<option value="site">
Sites
</option>
<option value="machine">
Machines
</option>
<option value="composant">
Composants
</option>
<option value="piece">
Pièces
</option>
</select>
</div>
</div> </div>
<div class="divider my-0" /> <div class="divider my-0" />
@@ -52,162 +113,191 @@
Chargement des documents... Chargement des documents...
</div> </div>
<div v-else-if="filteredDocuments.length === 0" class="text-center py-16 text-sm text-gray-500"> <div v-else-if="!documentsTotal" class="text-center py-16 text-sm text-gray-500">
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" /> <IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
Aucun document ne correspond à votre recherche pour l'instant. Aucun document n'a encore &eacute;t&eacute; ajout&eacute;.
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else-if="!documents.length" class="text-center py-16 text-sm text-gray-500">
<table class="table"> <IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
<thead> Aucun document ne correspond &agrave; votre recherche.
<tr class="text-xs uppercase"> </div>
<th>Nom</th>
<th>Type</th> <template v-else>
<th>Taille</th> <div class="overflow-x-auto">
<th>Rattaché à</th> <table class="table">
<th>Date</th> <thead>
<th class="text-right"> <tr class="text-xs uppercase">
Actions <th>Nom</th>
</th> <th>Type</th>
</tr> <th>Taille</th>
</thead> <th>Rattach&eacute; &agrave;</th>
<tbody> <th>Date</th>
<tr v-for="document in filteredDocuments" :key="document.id" class="text-sm"> <th class="text-right">Actions</th>
<td> </tr>
<div class="flex items-center gap-3"> </thead>
<span class="text-xl" :class="documentIcon(document).colorClass"> <tbody>
<component <tr v-for="doc in documents" :key="doc.id" class="text-sm">
:is="documentIcon(document).component" <td>
class="h-6 w-6" <div class="flex items-center gap-3">
aria-hidden="true" <span class="text-xl" :class="documentIcon(doc).colorClass">
/> <component
</span> :is="documentIcon(doc).component"
<div> class="h-6 w-6"
<div class="font-semibold"> aria-hidden="true"
{{ document.name }} />
</div> </span>
<div class="text-xs text-gray-500"> <div>
{{ document.filename }} <div class="font-semibold">{{ doc.name }}</div>
<div class="text-xs text-gray-500">{{ doc.filename }}</div>
</div> </div>
</div> </div>
</div> </td>
</td> <td>{{ doc.mimeType || 'Inconnu' }}</td>
<td>{{ document.mimeType || 'Inconnu' }}</td> <td>{{ formatSize(doc.size) }}</td>
<td>{{ formatSize(document.size) }}</td> <td>
<td> <div class="flex flex-col text-xs">
<div class="flex flex-col text-xs"> <span v-if="doc.site">Site &middot; {{ doc.site.name }}</span>
<span v-if="document.site">Site · {{ document.site.name }}</span> <span v-else-if="doc.machine">Machine &middot; {{ doc.machine.name }}</span>
<span v-else-if="document.machine">Machine · {{ document.machine.name }}</span> <span v-else-if="doc.composant">Composant &middot; {{ doc.composant.name }}</span>
<span v-else-if="document.composant">Composant · {{ document.composant.name }}</span> <span v-else-if="doc.piece">Pi&egrave;ce &middot; {{ doc.piece.name }}</span>
<span v-else-if="document.piece">Pièce · {{ document.piece.name }}</span> <span v-else-if="doc.product">Produit &middot; {{ doc.product.name }}</span>
<span v-else class="text-gray-400">Non défini</span> <span v-else class="text-gray-400">Non d&eacute;fini</span>
</div> </div>
</td> </td>
<td>{{ formatFrenchDate(document.createdAt) }}</td> <td>{{ formatFrenchDate(doc.createdAt) }}</td>
<td class="text-right"> <td class="text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
type="button" type="button"
:disabled="!canPreviewDocument(document)" :disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'" :title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
@click="openPreview(document)" @click="openPreview(doc)"
> >
Consulter Consulter
</button> </button>
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(document)"> <button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(doc)">
Télécharger T&eacute;l&eacute;charger
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
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 Pagination from '~/components/common/Pagination.vue'
import IconLucideFileSearch from '~icons/lucide/file-search' import IconLucideFileSearch from '~icons/lucide/file-search'
const { documents, loading, loadDocuments } = useDocuments() const { documents, total, loading, loadDocuments } = useDocuments()
const searchTerm = ref('') const {
const attachmentFilter = ref('all') page: currentPage,
const previewDocument = ref(null) perPage: itemsPerPage,
q: searchTerm,
filter: attachmentFilter,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 30, type: 'number' },
q: { default: '', debounce: 300 },
filter: { default: 'all' },
sort: { default: 'createdAt' },
dir: { default: 'desc' },
}, {
onRestore: () => fetchDocuments(),
})
const previewDocument = ref<any>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
onMounted(() => { const documentsTotal = computed(() => total.value)
loadDocuments() const documentsOnPage = computed(() => documents.value.length)
}) const totalPages = computed(() => Math.ceil(documentsTotal.value / itemsPerPage.value) || 1)
const filteredDocuments = computed(() => { const fetchDocuments = async () => {
const term = searchTerm.value.trim().toLowerCase() await loadDocuments({
const filter = attachmentFilter.value search: searchTerm.value,
page: currentPage.value,
return documents.value.filter((document) => { itemsPerPage: itemsPerPage.value,
const matchesFilter = orderBy: sortField.value,
filter === 'all' || orderDir: sortDirection.value as 'asc' | 'desc',
(filter === 'site' && document.siteId) || attachmentFilter: attachmentFilter.value,
(filter === 'machine' && document.machineId) || force: true,
(filter === 'composant' && document.composantId) ||
(filter === 'piece' && document.pieceId)
if (!matchesFilter) { return false }
if (!term) { return true }
const searchable = [
document.name,
document.filename,
document.mimeType,
document.site?.name,
document.machine?.name,
document.composant?.name,
document.piece?.name
]
.filter(Boolean)
.map(value => value.toLowerCase())
return searchable.some(value => value.includes(term))
}) })
}) }
const formatSize = (size) => { // Search debounce
if (size === undefined || size === null) { return '—' } let searchTimeout: ReturnType<typeof setTimeout> | null = null
if (size === 0) { return '0 B' }
const debouncedSearch = () => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchDocuments()
}, 300)
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchDocuments()
}
const handleSortChange = () => {
currentPage.value = 1
fetchDocuments()
}
const handleFilterChange = () => {
currentPage.value = 1
fetchDocuments()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchDocuments()
}
const formatSize = (size: number | undefined | null) => {
if (size === undefined || size === null) return '\u2014'
if (size === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB'] const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))) const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index) const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}` return `${formatted.toFixed(1)} ${units[index]}`
} }
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType }) const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
const downloadDocument = (doc) => { const downloadDocument = (doc: any) => {
if (!doc?.path) { return } if (doc?.downloadUrl) {
window.open(doc.downloadUrl, '_blank')
if (doc.path.startsWith('data:')) {
const link = document.createElement('a')
link.href = doc.path
link.download = doc.filename || doc.name || 'document'
link.click()
return
} }
window.open(doc.path, '_blank')
} }
const openPreview = (doc) => { const openPreview = (doc: any) => {
if (!canPreviewDocument(doc)) { return } if (!canPreviewDocument(doc)) return
previewDocument.value = doc previewDocument.value = doc
previewVisible.value = true previewVisible.value = true
} }
@@ -216,4 +306,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

@@ -8,9 +8,9 @@
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations. Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/piece-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>
@@ -26,6 +26,7 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked" :disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage" :disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode" :restricted-mode="isRestrictedMode"
@@ -34,6 +35,16 @@
@cancel="handleCancel" @cancel="handleCancel"
/> />
</section> </section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
</main> </main>
</template> </template>
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
@@ -126,6 +138,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) { if (guardSubmitOrNotify()) {
return return
} }

View File

@@ -8,9 +8,9 @@
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie. Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/piece-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>
@@ -20,6 +20,7 @@
initial-category="PIECE" initial-category="PIECE"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes' import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({ useHead(() => ({
title: 'Nouvelle catégorie de pièce', title: 'Nouvelle catégorie de pièce',
})) }))
@@ -50,6 +53,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => { const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true saving.value = true
try { try {
const enrichedPayload = { const enrichedPayload = {

View File

@@ -115,6 +115,7 @@
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Description</th>
<th>Fournisseurs</th> <th>Fournisseurs</th>
<th>Type de pièce</th> <th>Type de pièce</th>
<th>Actions</th> <th>Actions</th>
@@ -130,6 +131,15 @@
</td> </td>
<td>{{ row.piece.name || 'Pièce sans nom' }}</td> <td>{{ row.piece.name || 'Pièce sans nom' }}</td>
<td>{{ row.piece.reference || '—' }}</td> <td>{{ row.piece.reference || '—' }}</td>
<td class="max-w-xs">
<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>
<span v-else></span>
</td>
<td> <td>
<div <div
v-if="row.suppliers.visible.length" v-if="row.suppliers.visible.length"
@@ -152,7 +162,16 @@
</div> </div>
<span v-else></span> <span v-else></span>
</td> </td>
<td>{{ resolvePieceType(row.piece) }}</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> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -162,6 +181,7 @@
Modifier Modifier
</NuxtLink> </NuxtLink>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-error btn-xs"
:disabled="loadingPieces" :disabled="loadingPieces"
@@ -189,29 +209,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue' import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
const { showError } = useToast() const { showError } = useToast()
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces() const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value) const loadingPieces = computed(() => loadingPiecesRef.value)
// Pagination state // State synced with URL query params (preserved on back/forward navigation)
const currentPage = ref(1) const {
const itemsPerPage = ref(30) page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchPieces(),
})
const piecesTotal = computed(() => total.value) const piecesTotal = computed(() => total.value)
const piecesOnPage = computed(() => pieces.value.length) const piecesOnPage = computed(() => pieces.value.length)
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1) const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
// Search state with debounce // Search debounce for API calls
const searchTerm = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => { const debouncedSearch = () => {
@@ -224,12 +258,6 @@ const debouncedSearch = () => {
}, 300) }, 300)
} }
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'pieces-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les pièces avec les types de pièces complets // 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) => {
@@ -247,7 +275,8 @@ const fetchPieces = async () => {
page: currentPage.value, page: currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: itemsPerPage.value,
orderBy: sortField.value, orderBy: sortField.value,
orderDir: sortDirection.value orderDir: sortDirection.value as 'asc' | 'desc',
force: true
}) })
} }
@@ -272,7 +301,7 @@ const resolvePrimaryDocument = (piece: Record<string, any>) => {
return null return null
} }
const normalized = documents.filter((doc) => doc && typeof doc === 'object') const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path) const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc) => isPdfDocument(doc)) const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) { if (pdf) {

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">
@@ -19,9 +20,9 @@
</p> </p>
</div> </div>
</div> </div>
<NuxtLink to="/pieces-catalog" class="btn btn-primary mt-6"> <button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto"> <section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
@@ -33,9 +34,9 @@
Ajustez les informations de la pièce et ses champs personnalisés. Ajustez les informations de la pièce et ses champs personnalisés.
</p> </p>
</div> </div>
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start"> <button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -72,13 +73,26 @@
v-model="editionForm.name" v-model="editionForm.name"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue" placeholder="Nom affiché dans le catalogue"
required required
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -88,7 +102,7 @@
v-model="editionForm.reference" v-model="editionForm.reference"
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
@@ -100,7 +114,7 @@
<ConstructeurSelect <ConstructeurSelect
v-model="editionForm.constructeurIds" v-model="editionForm.constructeurIds"
class="w-full" class="w-full"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []" :initial-options="piece?.constructeurs || []"
/> />
@@ -118,7 +132,7 @@
step="0.01" step="0.01"
min="0" min="0"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="!canEdit || saving"
placeholder="Valeur indicatrice" placeholder="Valeur indicatrice"
> >
</div> </div>
@@ -159,7 +173,7 @@
</label> </label>
<ProductSelect <ProductSelect
:model-value="productSelections[entry.index] || null" :model-value="productSelections[entry.index] || null"
:disabled="saving" :disabled="!canEdit || saving"
:type-product-id="entry.typeProductId" :type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce." helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)" @update:model-value="(value) => setProductSelection(entry.index, value)"
@@ -224,7 +238,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -233,14 +247,14 @@
step="0.01" step="0.01"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="field.value" v-model="field.value"
class="select select-bordered select-sm md:select-md" class="select select-bordered select-sm md:select-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -251,24 +265,24 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="field.value" v-model="field.value"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true" true-value="true"
false-value="false" false-value="false"
:disabled="saving" :disabled="!canEdit || saving"
> >
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="field.value" v-model="field.value"
type="date" type="date"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
<input <input
v-else v-else
@@ -276,7 +290,7 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:required="field.required" :required="field.required"
:disabled="saving" :disabled="!canEdit || saving"
> >
</div> </div>
</div> </div>
@@ -294,7 +308,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</header> </header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }"> <div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload <DocumentUpload
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer vos fichiers" title="Déposer vos fichiers"
@@ -320,8 +334,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -366,6 +380,7 @@
Télécharger Télécharger
</button> </button>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-error btn-xs"
:disabled="uploadingDocuments" :disabled="uploadingDocuments"
@@ -458,6 +473,16 @@
Enregistrer les modifications Enregistrer les modifications
</button> </button>
</div> </div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -511,6 +536,7 @@ interface PieceCatalogType extends ModelType {
customFields?: Array<Record<string, any>> customFields?: Array<Record<string, any>>
} }
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
@@ -556,6 +582,7 @@ const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null) const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -731,6 +758,7 @@ const requiredCustomFieldsFilled = computed(() =>
const canSubmit = computed(() => const canSubmit = computed(() =>
Boolean( Boolean(
canEdit.value &&
piece.value && piece.value &&
editionForm.name && editionForm.name &&
requiredCustomFieldsFilled.value && requiredCustomFieldsFilled.value &&
@@ -810,6 +838,7 @@ watch(
selectedTypeId.value = resolvedTypeId selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || '' editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || '' editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece, currentPiece,
@@ -837,7 +866,10 @@ watch(
pendingProductIds = [] pendingProductIds = []
} }
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues) // After setting selectedTypeId, read selectedType.value (now updated) instead of
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true initialized = true
}, },
@@ -848,9 +880,7 @@ watch(selectedType, (currentType) => {
if (!piece.value || !currentType) { if (!piece.value || !currentType) {
return return
} }
if (!pieceTypeDetails.value) { refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
}
}) })
watch(resolvedStructure, (currentStructure) => { watch(resolvedStructure, (currentStructure) => {
@@ -881,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

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

@@ -110,7 +110,16 @@
</td> </td>
<td class="font-medium">{{ row.product.name }}</td> <td class="font-medium">{{ row.product.name }}</td>
<td>{{ row.product.reference || '—' }}</td> <td>{{ row.product.reference || '—' }}</td>
<td>{{ row.product.typeProduct?.name || '—' }}</td> <td>
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '' }}</span>
</td>
<td> <td>
<div <div
v-if="row.suppliers.visible.length" v-if="row.suppliers.visible.length"
@@ -144,6 +153,7 @@
Modifier Modifier
</NuxtLink> </NuxtLink>
<button <button
v-if="canEdit"
type="button" type="button"
class="btn btn-ghost btn-xs text-error" class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)" @click="confirmDelete(row.product)"
@@ -161,15 +171,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import { useHead } from '#imports' import { useHead } from '#imports'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes' import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
useHead(() => ({ useHead(() => ({
title: 'Catalogue des produits', title: 'Catalogue des produits',
})) }))
@@ -186,11 +198,11 @@ const {
const { productTypes, loadProductTypes } = useProductTypes() const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast() const toast = useToast()
const searchTerm = ref('') const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>( q: { default: '', debounce: 300 },
'product-catalog', sort: { default: 'name' },
{ field: 'name', direction: 'asc' }, dir: { default: 'asc' },
) })
// Enrichir les produits avec les types de produits complets // Enrichir les produits avec les types de produits complets
const normalizedProducts = computed(() => { const normalizedProducts = computed(() => {
@@ -355,7 +367,7 @@ const resolvePrimaryDocument = (product: Record<string, any>) => {
return null return null
} }
const normalized = documents.filter((doc) => doc && typeof doc === 'object') const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path) const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
if (!withPath.length) { if (!withPath.length) {
return normalized[0] ?? null return normalized[0] ?? null
} }
@@ -379,7 +391,7 @@ const resolvePreviewAlt = (product: Record<string, any>) => {
} }
const reload = async () => { const reload = async () => {
await loadProducts({ force: true }) await loadProducts({ itemsPerPage: 200, force: true })
} }
const { confirm } = useConfirm() const { confirm } = useConfirm()
@@ -400,7 +412,7 @@ const confirmDelete = async (product: Record<string, any>) => {
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadProducts(), loadProducts({ itemsPerPage: 200, force: true }),
loadProductTypes() loadProductTypes()
]) ])
}) })

View File

@@ -8,9 +8,9 @@
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations. Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/product-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>
@@ -26,6 +26,7 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked" :disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage" :disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode" :restricted-mode="isRestrictedMode"
@@ -34,6 +35,16 @@
@cancel="handleCancel" @cancel="handleCancel"
/> />
</section> </section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
</main> </main>
</template> </template>
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes' import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
@@ -126,6 +138,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) { if (guardSubmitOrNotify()) {
return return
} }

View File

@@ -8,9 +8,9 @@
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie. Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
</p> </p>
</div> </div>
<NuxtLink class="btn btn-ghost" to="/product-category"> <button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue Retour au catalogue
</NuxtLink> </button>
</div> </div>
</header> </header>
@@ -20,6 +20,7 @@
initial-category="PRODUCT" initial-category="PRODUCT"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes' import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({ useHead(() => ({
title: 'Nouvelle catégorie de produit', title: 'Nouvelle catégorie de produit',
})) }))
@@ -50,6 +53,7 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => { const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true saving.value = true
try { try {
const enrichedPayload = { const enrichedPayload = {

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

View File

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

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

@@ -132,28 +132,19 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
if (params.category) { if (params.category) {
query.category = params.category; query.category = params.category;
} }
if (params.sort) {
query.sort = params.sort;
}
if (params.dir) {
query.dir = params.dir;
}
const hasCategoryFilter = Boolean(params.category);
const effectiveLimit = typeof params.limit === 'number' ? params.limit : undefined;
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
if (hasCategoryFilter) { // Sort: API Platform OrderFilter uses order[field]=direction
// Fetch enough items to allow client-side category filtering + pagination. const sortField = params.sort || 'name';
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200); const sortDir = params.dir || 'asc';
query.offset = 0; query[`order[${sortField}]`] = sortDir;
} else {
if (typeof params.limit === 'number') { // Pagination: API Platform uses page + itemsPerPage
query.itemsPerPage = params.limit; const effectiveLimit = typeof params.limit === 'number' ? params.limit : 20;
} const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
if (typeof params.offset === 'number') { const page = Math.floor(effectiveOffset / effectiveLimit) + 1;
query.offset = params.offset;
} query.itemsPerPage = effectiveLimit;
} query.page = page;
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({ const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
method: 'GET', method: 'GET',
@@ -168,25 +159,20 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
: Array.isArray(payload?.items) : Array.isArray(payload?.items)
? payload.items ? payload.items
: []; : [];
const filteredItems = params.category
? rawItems.filter((item: any) => item?.category === params.category) const total = typeof payload?.totalItems === 'number'
: rawItems; ? payload.totalItems
const total = params.category : typeof payload?.['hydra:totalItems'] === 'number'
? filteredItems.length ? payload['hydra:totalItems']
: typeof payload?.totalItems === 'number' : rawItems.length;
? payload.totalItems
: Array.isArray(payload?.items) const items = rawItems.map(normalizeModelType);
? payload.items.length
: rawItems.length;
const items = (params.category && typeof effectiveLimit === 'number'
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
: filteredItems).map(normalizeModelType);
return { return {
items, items,
total, total,
offset: effectiveOffset, offset: effectiveOffset,
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length, limit: effectiveLimit,
} satisfies ModelTypeListResponse; } satisfies ModelTypeListResponse;
} }
@@ -233,3 +219,33 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
signal: opts.signal, signal: opts.signal,
})).then(normalizeModelType); })).then(normalizeModelType);
} }
export interface ConversionCheck {
canConvert: boolean;
direction: 'piece_to_component' | 'component_to_piece' | null;
itemCount: number;
names: string[];
blockers: string[];
}
export interface ConversionResult {
success: boolean;
convertedCount: number;
error?: string | null;
}
export function checkConversion(id: string, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<ConversionCheck>(`${ENDPOINT}/${id}/conversion-check`, createOptions({
method: 'GET',
signal: opts.signal,
}));
}
export function convertCategory(id: string, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<ConversionResult>(`${ENDPOINT}/${id}/convert`, createOptions({
method: 'POST',
signal: opts.signal,
}));
}

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'],
},
],
})