Merge branch 'feat/ux-quick-wins' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled

# Conflicts:
#	frontend/app/components/ComponentItem.vue
#	frontend/app/components/PieceItem.vue
#	frontend/app/components/machine/MachineInfoCard.vue
#	frontend/app/composables/usePieceEdit.ts
#	frontend/app/pages/component/[id]/index.vue
#	frontend/app/pages/piece/[id].vue
#	frontend/app/pages/product/[id]/edit.vue
#	frontend/app/pages/product/[id]/index.vue
#	frontend/app/pages/product/create.vue
#	frontend/app/shared/utils/customFields.ts
This commit is contained in:
2026-04-06 11:23:21 +02:00
65 changed files with 4346 additions and 4070 deletions

View File

@@ -8,3 +8,15 @@ framework:
policy: sliding_window
limit: 5
interval: '1 minute'
when@test:
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 10000
interval: '1 minute'
login:
policy: sliding_window
limit: 10000
interval: '1 minute'

View File

@@ -0,0 +1,148 @@
# Session 04-05 avril 2026 — Refonte UX/UI complète Inventory
## Contexte
L'utilisateur (gestionnaire) remonte que les utilisateurs novices se perdent dans l'app Inventory (gestion d'inventaire industriel : machines, composants, pièces, produits). Ils découvrent le domaine ET l'app en même temps, remplissent les machines depuis de la documentation papier/PDF.
## Ce qui a été fait
### 1. Analyse UX/UI complète
- Exploration en profondeur des 65+ composants, toutes les pages, composables et patterns
- Diagnostic : navigation top-down uniquement, pas de liens inverses, pas de breadcrumbs, navbar mélange tout, pages trop longues, mode lecture ressemble à un formulaire disabled
- Identification de 23 améliorations organisées en 4 phases
### 2. Spec rédigée
**Fichier :** `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md`
23 sections couvrant :
- Réorganisation navbar par domaine métier
- Breadcrumbs contextuels
- Liaisons inverses "Utilisé dans"
- Liens cliquables dans la hiérarchie machine
- Système d'onglets partagé (machine + composant + pièce + produit)
- Pages catalogue unifiées (catalogue + catégories en onglets)
- Recherche globale (**retirée** à la demande de l'utilisateur)
- Raccourcis clavier (**retirés** à la demande)
- Mode lecture texte brut, empty states, toasts, responsive, etc.
### 3. Phase 1 — Quick wins (9 améliorations, 0 backend)
**Plan :** `docs/superpowers/plans/2026-04-04-ux-quick-wins.md`
| Changement | Fichiers modifiés |
|-----------|-------------------|
| Liens cliquables dans hiérarchie machine | ComponentItem, PieceItem, MachineProductsCard |
| Site → machines (badge cliquable) | SiteCard, index.vue |
| Retour contextuel (NuxtLink au lieu de router.back) | DetailHeader |
| Confirmations sur toutes les suppressions | CommentSection, machine/[id].vue |
| Header sticky composants expanded | ComponentItem |
| DataTable fixedLayout opt-in + minWidth | DataTable.vue, dataTable.ts |
| Mode lecture texte brut (26 div-inputs → `<p>`) | MachineInfoCard, 3 pages détail |
| Compteurs titres sections machine | MachineComponentsCard, MachinePiecesCard, MachineDocumentsCard |
| Cohérence fiches (liens catégorie + EntityVersionList) | 3 pages détail entité |
**Review Phase 1 :** a détecté 4 issues corrigées :
- `component.entityId``component.composantId` (property n'existait pas)
- `piece.entityId``piece.pieceId`
- `table-fixed` global → opt-in via prop `fixedLayout`
- NuxtLinks sans `?from=machine&machineId=xxx` → ajouté
### 4. Phase 2 — Refactoring structurel (7 améliorations)
**Plan :** `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md`
| Changement | Fichiers créés/modifiés |
|-----------|------------------------|
| EntityTabs composant partagé | `components/common/EntityTabs.vue` (nouveau) |
| Onglets page machine + header compact | machine/[id].vue, MachineDetailHeader.vue |
| Onglets composant/pièce/produit | 3 pages détail |
| Pages catalogue unifiées /catalogues/* | 3 nouvelles pages + ManagementView modifié |
| Navbar réorganisée (Catalogues + Administration) | AppNavbar.vue |
| Breadcrumbs contextuels | `components/layout/AppBreadcrumb.vue` (nouveau), app.vue |
| Redirections legacy URLs | `middleware/legacy-redirects.global.ts` (nouveau) |
| Guard modifications non sauvegardées | `composables/useUnsavedGuard.ts` (nouveau) |
### 5. Phase 3 — Harmonisation visuelle (3 améliorations)
| Changement | Fichiers |
|-----------|----------|
| EmptyState composant partagé | `components/common/EmptyState.vue` (nouveau), 3 pages |
| Toasts erreur persistent + barre progression | useToast.ts, ToastContainer.vue |
| Responsive mobile (breadcrumbs tronqués, tabs scroll) | AppBreadcrumb, EntityTabs, vérification grids |
### 6. Phase 4 — Backend + reverse links (6 améliorations)
| Changement | Fichiers |
|-----------|----------|
| Endpoint `/api/{entity}/{id}/used-in` | `src/Controller/UsedInController.php` (nouveau) |
| UsedInSection frontend | `composables/useUsedIn.ts` + `components/common/UsedInSection.vue` (nouveaux), 3 pages détail |
| Endpoint `/api/constructeurs/stats` | `src/Controller/ConstructeurStatsController.php` (nouveau) |
| Page fournisseurs enrichie (compteurs cliquables) | constructeurs.vue |
| Endpoint `/api/model_types/{id}/related-items` | `src/Controller/ModelTypeRelatedItemsController.php` (nouveau) |
| Modal catégorie enrichie (machine count + liens) | RelatedItemsModal.vue |
## Bugs découverts et corrigés en cours de route
| Bug | Cause | Fix |
|-----|-------|-----|
| `<script setup>` sans `lang="ts"` | Agents ont ajouté `as string` dans des fichiers JS | Ajouté `lang="ts"` sur ComponentItem, PieceItem, machine/[id] |
| `Cannot access 'selectedType' before initialization` | Bug pré-existant dans usePieceEdit.ts — `resolvedStructure` utilisait `selectedType` avant sa déclaration | Déplacé `resolvedStructure` avant `useCustomFieldInputs` |
| `CommonEmptyState` non résolu | `pathPrefix: false` dans nuxt.config → les composants dans `common/` s'importent sans préfixe | Renommé `CommonEmptyState``EmptyState`, `CommonUsedInSection``UsedInSection` |
| `/api/constructeurs/stats` retourne 404 | Route API Platform `/api/constructeurs/{id}` matchait "stats" comme un {id} | Ajouté `priority: 1` sur la route bulk stats |
| Compteurs fournisseurs tous à 0 | Tables `*_constructeur_links` vides — liens jamais migrés depuis les tables legacy M2M | Restauré depuis backup + créé migration Doctrine |
| Pages `/catalogues/*` manquantes sur le disque | Fichiers committés par agents mais perdus dans le working tree (confusion `frontend/` vs `app/`) | Restauré depuis git history |
## Problème de données découvert
Les **liens constructeur ↔ entités** n'avaient jamais été migrés des anciennes tables ManyToMany (`_composantconstructeurs`, `_piececonstructeurs`) vers les nouvelles tables de liens (`*_constructeur_links`). Ce problème est **pré-existant** au refactoring UX.
### Données restaurées en local
- 3 liens composant-constructeur
- 23 liens pièce-constructeur (dont 6 Limatech remappé avec le nouvel ID)
### Données irrémédiablement perdues (entités supprimées)
- **Convoyeur à Bande** → était lié à Brillaud + Bühler
- **Sangle E12** → était liée à NETCO
- **Arbre du tambour tête E6** → était lié à Dexis
### Migrations créées pour la prod
1. `migrations/Version20260405_MigrateConstructeurLinks.php` — copie depuis les tables legacy M2M (si elles existent)
2. `migrations/Version20260405_RestoreConstructeurLinksFromBackup.php` — fallback : insère directement les données du backup (3), nettoie les orphelins
**Pour restaurer en prod :** `php bin/console doctrine:migrations:migrate`
## Fichiers de référence
| Fichier | Contenu |
|---------|---------|
| `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md` | Spec complète des 23 améliorations |
| `docs/superpowers/plans/2026-04-04-ux-quick-wins.md` | Plan Phase 1 (11 tasks) |
| `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md` | Plan Phase 2 (11 tasks) |
| `docs/superpowers/session-2026-04-04-ux-overhaul.md` | Ce résumé |
## Branche
`feat/ux-quick-wins` — ~30 commits depuis `develop`
## Nouveaux composants/composables créés
- `app/components/common/EntityTabs.vue`
- `app/components/common/EmptyState.vue`
- `app/components/common/UsedInSection.vue`
- `app/components/layout/AppBreadcrumb.vue`
- `app/composables/useUsedIn.ts`
- `app/composables/useUnsavedGuard.ts`
- `app/middleware/legacy-redirects.global.ts`
- `app/pages/catalogues/composants.vue`
- `app/pages/catalogues/pieces.vue`
- `app/pages/catalogues/produits.vue`
## Nouveaux controllers backend
- `src/Controller/UsedInController.php`
- `src/Controller/ConstructeurStatsController.php`
- `src/Controller/ModelTypeRelatedItemsController.php`
## Points d'attention pour la suite
1. **Tester visuellement** toutes les pages sur `localhost:3001` avant merge
2. **Lancer les migrations en prod** pour restaurer les liens constructeur
3. Les anciennes URLs (`/component-catalog`, `/pieces-catalog`, etc.) redirigent automatiquement
4. Le menu Administration n'est visible que pour les gestionnaires/admins (`canEdit`)
5. L'onglet Catégories dans les pages catalogue n'est visible que pour `canEdit`
6. Le `useUnsavedGuard` n'est pas encore intégré dans les pages (composable créé, pas branché)

View File

@@ -422,16 +422,16 @@ INSERT INTO public.constructeurs (id, name, email, phone, createdat, updatedat)
--
-- Data for Name: _composantconstructeurs; Type: TABLE DATA; Schema: public; Owner: -
-- Data for Name: composant_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000001', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000002', 'cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000005', 'cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000006', 'cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000007', 'cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
--
@@ -461,7 +461,7 @@ INSERT INTO public.machines (id, name, reference, prix, createdat, updatedat, si
--
-- Data for Name: _machineconstructeurs; Type: TABLE DATA; Schema: public; Owner: -
-- Data for Name: machine_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
--
@@ -588,25 +588,25 @@ INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, type
--
-- Data for Name: _piececonstructeurs; Type: TABLE DATA; Schema: public; Owner: -
-- Data for Name: piece_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000001', 'cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000002', 'cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000003', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000004', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000005', 'cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000006', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000007', 'cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000008', 'cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000009', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000010', 'cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000011', 'cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000012', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000013', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000014', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000015', 'cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000016', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
--

View File

@@ -7,6 +7,7 @@
@open-settings="displaySettingsOpen = true"
@logout="handleLogout"
/>
<AppBreadcrumb />
<main class="flex-1">
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />

View File

@@ -255,7 +255,16 @@ const handleResolve = async (commentId: string) => {
}
}
const { confirm } = useConfirm()
const handleDelete = async (commentId: string) => {
const ok = await confirm({
title: 'Supprimer ce commentaire ?',
message: 'Cette action est irréversible.',
confirmText: 'Supprimer',
dangerous: true,
})
if (!ok) return
const result = await deleteComment(commentId)
if (result.success) {
comments.value = comments.value.filter(c => c.id !== commentId)

View File

@@ -14,7 +14,14 @@
/>
<!-- Component Header -->
<div class="flex items-center gap-3 p-3 rounded-lg cursor-pointer" :class="component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'" @click="toggleCollapse">
<div
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-shadow"
:class="[
component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200',
!isCollapsed ? 'sticky top-16 z-10 shadow-sm' : '',
]"
@click="toggleCollapse"
>
<IconLucideChevronRight
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
:class="{ 'rotate-90': !isCollapsed }"
@@ -23,7 +30,17 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
{{ component.name }}
<NuxtLink
v-if="!isEditMode && !component.pendingEntity && component.composantId"
:to="machineId
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
: `/component/${component.composantId}`"
class="hover:underline hover:text-primary transition-colors"
@click.stop
>
{{ component.name }}
</NuxtLink>
<span v-else>{{ component.name }}</span>
</h3>
<button
v-if="component.pendingEntity"
@@ -312,7 +329,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import PieceItem from './PieceItem.vue'
import DocumentUpload from './DocumentUpload.vue'
@@ -338,6 +355,9 @@ import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useCustomFields } from '~/composables/useCustomFields'
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({
component: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },

View File

@@ -15,9 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour au catalogue
</button>
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }}
</NuxtLink>
</div>
</div>
</template>
@@ -25,8 +26,9 @@
<script setup lang="ts">
import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const router = useRouter()
const route = useRoute()
const props = defineProps<{
title: string
@@ -34,18 +36,24 @@ const props = defineProps<{
isEditMode: boolean
canEdit: boolean
backLink: string
backLinkLabel?: string
}>()
defineEmits<{
'toggle-edit': []
}>()
function goBack() {
if (window.history.length > 1) {
router.back()
const backDestination = computed(() => {
if (route.query.from === 'machine' && route.query.machineId) {
return `/machine/${route.query.machineId}`
}
else {
navigateTo(props.backLink)
return props.backLink
})
const backLabel = computed(() => {
if (route.query.from === 'machine') {
return 'Retour à la machine'
}
}
return props.backLinkLabel ?? 'Retour au catalogue'
})
</script>

View File

@@ -29,7 +29,17 @@
</button>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
{{ pieceData.name }}
<NuxtLink
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
:to="machineId
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
: `/piece/${piece.pieceId}`"
class="hover:underline hover:text-primary transition-colors"
@click.stop
>
{{ pieceData.name }}
</NuxtLink>
<template v-else>{{ pieceData.name }}</template>
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<button
v-if="piece.pendingEntity"
@@ -304,7 +314,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
@@ -324,6 +334,9 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({
piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },

View File

@@ -13,7 +13,7 @@
]"
>
<div
class="alert toast-card shadow-md px-3 py-2 text-sm"
class="alert toast-card relative shadow-md px-3 py-2 text-sm overflow-hidden"
:class="getToastClasses(toast.type)"
>
<div class="flex items-center gap-2">
@@ -54,13 +54,20 @@
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</div>
<!-- Progress bar for auto-dismiss toasts -->
<div
v-if="toast.duration > 0"
class="absolute bottom-0 left-0 h-0.5 bg-current opacity-30 rounded-full"
:style="{ animation: `toast-progress ${toast.duration}ms linear forwards` }"
/>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
<script setup lang="ts">
import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
@@ -70,7 +77,7 @@ import IconLucideInfo from '~icons/lucide/info'
const { toasts, removeToast } = useToast()
const getToastClasses = (type) => {
const getToastClasses = (type: ToastType) => {
switch (type) {
case 'success':
return 'alert-success text-success-content'
@@ -111,4 +118,9 @@ const getToastClasses = (type) => {
pointer-events: auto;
border-radius: 0.75rem;
}
@keyframes toast-progress {
from { width: 100%; }
to { width: 0%; }
}
</style>

View File

@@ -71,7 +71,7 @@
>
<span class="loading loading-spinner text-primary" aria-hidden="true" />
</div>
<table :class="['table table-sm md:table-md', tableClass]">
<table :class="['table table-sm md:table-md', tableClass, { 'table-fixed': fixedLayout }]">
<thead>
<!-- Header labels + sort -->
<tr>
@@ -85,6 +85,7 @@
alignClass(col),
{ 'hidden sm:table-cell': col.hiddenMobile },
]"
:style="col.minWidth ? { minWidth: col.minWidth } : undefined"
>
<slot :name="`header-${col.key}`" :column="col">
<span
@@ -221,6 +222,8 @@ const props = withDefaults(defineProps<{
tableClass?: string
showCounter?: boolean
showPerPage?: boolean
/** Use table-layout: fixed for stable column widths. Only enable on tables where columns define width/minWidth. */
fixedLayout?: boolean
}>(), {
rowKey: 'id',
loading: false,

View File

@@ -0,0 +1,33 @@
<template>
<div class="text-center py-12">
<div v-if="icon" class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<component :is="icon" class="w-8 h-8 text-base-content/30" aria-hidden="true" />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">{{ title }}</h3>
<p v-if="description" class="text-sm text-base-content/50 mb-6">{{ description }}</p>
<slot>
<NuxtLink v-if="actionTo" :to="actionTo" class="btn btn-primary btn-sm">
{{ actionLabel }}
</NuxtLink>
<button v-else-if="actionLabel" type="button" class="btn btn-primary btn-sm" @click="$emit('action')">
{{ actionLabel }}
</button>
</slot>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
title: string
description?: string
icon?: Component
actionLabel?: string
actionTo?: string
}>()
defineEmits<{
action: []
}>()
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div>
<nav class="tabs tabs-bordered mb-6 overflow-x-auto flex-nowrap" role="tablist" :aria-label="ariaLabel">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="tab"
:class="{ 'tab-active': modelValue === tab.key }"
role="tab"
:aria-selected="modelValue === tab.key"
@click="emit('update:modelValue', tab.key)"
>
{{ tab.label }}
<span v-if="tab.count !== undefined && tab.count > 0" class="badge badge-outline badge-xs ml-1.5">
{{ tab.count }}
</span>
</button>
</nav>
<div role="tabpanel">
<slot :name="`tab-${modelValue}`" />
</div>
</div>
</template>
<script setup lang="ts">
export interface TabDefinition {
key: string
label: string
count?: number
}
defineProps<{
tabs: TabDefinition[]
modelValue: string
ariaLabel?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div v-if="!loading && totalCount > 0" class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4">
<h3 class="font-semibold text-base-content">Utilisé dans</h3>
<div v-if="data.machines.length" class="space-y-1">
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Machines</p>
<div v-for="m in data.machines" :key="m.id" class="flex items-center gap-2 text-sm">
<NuxtLink :to="`/machine/${m.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
{{ m.name }}
</NuxtLink>
<span v-if="m.site?.name" class="badge badge-ghost badge-xs">{{ m.site.name }}</span>
</div>
</div>
<div v-if="data.composants.length" class="space-y-1">
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Composants</p>
<div v-for="c in data.composants" :key="c.id" class="text-sm">
<NuxtLink :to="`/component/${c.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
{{ c.name }}
</NuxtLink>
</div>
</div>
<div v-if="data.pieces.length" class="space-y-1">
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Pièces</p>
<div v-for="p in data.pieces" :key="p.id" class="text-sm">
<NuxtLink :to="`/piece/${p.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
{{ p.name }}
</NuxtLink>
</div>
</div>
</div>
<div v-else-if="loading" class="flex justify-center py-4">
<span class="loading loading-spinner loading-sm" />
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
entityType: 'composants' | 'pieces' | 'products'
entityId: string | null
}>()
const { data, loading, totalCount } = useUsedIn(
computed(() => props.entityType),
computed(() => props.entityId),
)
</script>

View File

@@ -0,0 +1,137 @@
<template>
<nav v-if="crumbs.length > 1" class="container mx-auto px-6 pt-4" aria-label="Fil d'Ariane">
<div class="text-sm breadcrumbs py-0">
<ul>
<!-- First crumb (always visible) -->
<li>
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumbs[0].label }}
</NuxtLink>
</li>
<!-- Ellipsis on mobile when there are middle crumbs -->
<li v-if="crumbs.length > 2" class="sm:hidden">
<span class="text-base-content/40"></span>
</li>
<!-- Middle crumbs: hidden on mobile, visible sm+ -->
<li
v-for="(crumb, i) in crumbs.slice(1, crumbs.length - 1)"
:key="i"
class="hidden sm:list-item"
>
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumb.label }}
</NuxtLink>
</li>
<!-- Last crumb (always visible, current page) -->
<li v-if="crumbs.length > 1">
<span class="text-base-content font-medium">{{ crumbs[crumbs.length - 1].label }}</span>
</li>
</ul>
</div>
</nav>
</template>
<script setup lang="ts">
interface Crumb {
label: string
path: string
}
const route = useRoute()
const crumbs = computed<Crumb[]>(() => {
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
const path = route.path
// Home page — no breadcrumb
if (path === '/') return []
// Machine context from query param (when navigating from a machine detail page)
if (route.query.from === 'machine' && route.query.machineId) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
}
// Machines
if (path === '/machines') {
result.push({ label: 'Parc machines', path: '/machines' })
} else if (path.startsWith('/machine/') && !route.query.from) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path })
}
// Catalogs
else if (path.startsWith('/catalogues/composants')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
} else if (path.startsWith('/catalogues/pieces')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
} else if (path.startsWith('/catalogues/produits')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
}
// Entity detail pages (when NOT from machine context)
else if (path.startsWith('/component/') && !route.query.from) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Composant', path })
} else if (path.startsWith('/piece/') && !route.query.from) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Pièce', path })
} else if (path.startsWith('/product/') && !route.query.from) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Produit', path })
}
// Entity detail pages WITH machine context — add entity as last crumb
else if (path.startsWith('/component/') && route.query.from === 'machine') {
result.push({ label: 'Composant', path })
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
result.push({ label: 'Pièce', path })
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
result.push({ label: 'Produit', path })
}
// Admin pages
else if (path.startsWith('/sites')) {
result.push({ label: 'Sites', path: '/sites' })
} else if (path.startsWith('/constructeurs')) {
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
} else if (path.startsWith('/activity-log')) {
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
} else if (path.startsWith('/admin')) {
result.push({ label: 'Administration', path: '/admin' })
} else if (path.startsWith('/documents')) {
result.push({ label: 'Documents', path: '/documents' })
} else if (path.startsWith('/comments')) {
result.push({ label: 'Commentaires', path: '/comments' })
}
// Category pages
else if (path.startsWith('/component-category')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Catégorie', path })
} else if (path.startsWith('/piece-category')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Catégorie', path })
} else if (path.startsWith('/product-category')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Catégorie', path })
}
// Create pages
else if (path.startsWith('/pieces/create')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Nouvelle pièce', path })
} else if (path.startsWith('/component/create')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Nouveau composant', path })
} else if (path.startsWith('/product/create')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Nouveau produit', path })
} else if (path === '/machines/new') {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Nouvelle machine', path })
}
return result
})
</script>

View File

@@ -44,7 +44,7 @@
<!-- Mobile: dropdown groups -->
<li
v-for="group in navGroups"
v-for="group in visibleGroups"
:key="group.id + '-mobile'"
class="mt-1 border-t border-base-200 pt-2"
>
@@ -122,7 +122,7 @@
<!-- Desktop: dropdown groups -->
<li
v-for="group in navGroups"
v-for="group in visibleGroups"
:key="group.id + '-desktop'"
class="relative"
@mouseenter="setDropdown(group.id + '-desktop')"
@@ -270,11 +270,9 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideBookOpen from '~icons/lucide/book-open'
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 IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
@@ -296,55 +294,40 @@ interface NavGroup {
icon?: Component
activePaths: string[]
children: NavLink[]
requiresEdit?: boolean
}
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
{ to: '/doc', label: 'Documentation', icon: IconLucideBookOpen },
]
const navGroups: NavGroup[] = [
{
id: 'component',
label: 'Composants',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{
id: 'pieces',
label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'],
children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
{ to: '/piece-category', label: 'Catégorie de pièce' },
],
},
{
id: 'products',
label: 'Produits',
id: 'catalogues',
label: 'Catalogues',
icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'],
activePaths: ['/catalogues', '/component', '/piece', '/product'],
children: [
{ to: '/product-catalog', label: 'Catalogue des produits' },
{ to: '/product-category', label: 'Catégorie de produit' },
{ to: '/catalogues/composants', label: 'Composants' },
{ to: '/catalogues/pieces', label: 'Pièces' },
{ to: '/catalogues/produits', label: 'Produits' },
],
},
{
id: 'resources',
label: 'Ressources liées',
icon: IconLucideLink,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
id: 'admin',
label: 'Administration',
icon: IconLucideSettings,
activePaths: ['/sites', '/constructeurs', '/activity-log', '/admin', '/documents', '/comments', '/component-category', '/piece-category', '/product-category'],
requiresEdit: true,
children: [
{ to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/comments', label: 'Commentaires' },
{ to: '/activity-log', label: 'Journal d\'activité' },
{ to: '/admin', label: 'Profils' },
],
},
]
@@ -353,6 +336,10 @@ const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions()
const visibleGroups = computed(() =>
navGroups.filter(g => !g.requiresEdit || canEdit.value)
)
const { fetchUnresolvedCount } = useComments()
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()

View File

@@ -2,7 +2,10 @@
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2>
<h2 class="card-title">
Composants
<span v-if="components.length" class="badge badge-outline badge-sm ml-1">{{ components.length }}</span>
</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"

View File

@@ -1,40 +1,46 @@
<template>
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-bold">
{{ title }}
</h1>
</div>
<div class="flex items-center gap-2 print:hidden" data-print-hide>
<button
@click="$emit('toggle-edit')"
class="btn btn-primary"
:class="{ 'btn-outline': isEditMode }"
>
<IconLucideSquarePen
<div class="space-y-3">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold">{{ title }}</h1>
<div
v-if="siteName"
class="badge badge-outline font-semibold"
:style="siteStyle"
>
{{ siteName }}
</div>
<div v-if="reference" class="badge badge-outline">{{ reference }}</div>
</div>
<p v-if="description" class="text-sm text-base-content/60">{{ description }}</p>
</div>
<div class="flex items-center gap-2 print:hidden">
<button
v-if="canEdit"
type="button"
class="btn btn-primary btn-sm md:btn-md"
:class="{ 'btn-outline': isEditMode }"
@click="$emit('toggle-edit')"
>
<IconLucideSquarePen v-if="!isEditMode" class="w-4 h-4 mr-1" aria-hidden="true" />
<IconLucideEye v-else class="w-4 h-4 mr-1" aria-hidden="true" />
{{ isEditMode ? 'Voir d\u00e9tails' : 'Modifier' }}
</button>
<button
v-if="!isEditMode"
class="w-5 h-5 mr-2"
aria-hidden="true"
/>
<IconLucideEye
v-else
class="w-5 h-5 mr-2"
aria-hidden="true"
/>
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<button
v-if="!isEditMode"
@click="$emit('open-print')"
type="button"
class="btn btn-outline btn-secondary"
>
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
Imprimer
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour aux machines
</button>
type="button"
class="btn btn-ghost btn-sm md:btn-md"
title="Imprimer"
@click="$emit('open-print')"
>
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button>
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines
</NuxtLink>
</div>
</div>
</div>
</template>
@@ -43,11 +49,16 @@
import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const router = useRouter()
const { canEdit } = usePermissions()
defineProps<{
const props = defineProps<{
title: string
description?: string
siteName?: string
siteColor?: string
reference?: string
isEditMode: boolean
}>()
@@ -56,12 +67,12 @@ defineEmits<{
'open-print': []
}>()
function goBack() {
if (window.history.length > 1) {
router.back()
const siteStyle = computed(() => {
if (!props.siteColor) return {}
return {
borderColor: props.siteColor + '60',
backgroundColor: props.siteColor + '25',
color: props.siteColor,
}
else {
navigateTo('/machines')
}
}
})
</script>

View File

@@ -3,7 +3,10 @@
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="card-title">Documents de la machine</h2>
<h2 class="card-title">
Documents de la machine
<span v-if="documents.length" class="badge badge-outline badge-sm ml-1">{{ documents.length }}</span>
</h2>
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
</div>
<span v-if="isEditMode && files.length" class="badge badge-outline">

View File

@@ -15,9 +15,9 @@
class="input input-bordered"
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
/>
<div v-else class="input input-bordered bg-base-200">
<p v-else class="text-sm font-medium text-base-content py-1">
{{ machineName }}
</div>
</p>
</div>
<div class="form-control">
<label class="label">
@@ -38,9 +38,9 @@
{{ site.name }}
</option>
</select>
<div v-else class="input input-bordered bg-base-200">
<p v-else class="text-sm font-medium text-base-content py-1">
{{ machineSiteName || 'Non défini' }}
</div>
</p>
</div>
<div v-if="isEditMode || machineReference" class="form-control">
<label class="label">
@@ -54,9 +54,9 @@
class="input input-bordered"
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
/>
<div v-else class="input input-bordered bg-base-200">
<p v-else class="text-sm font-medium text-base-content py-1">
{{ machineReference }}
</div>
</p>
</div>
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
<label class="label">
@@ -77,9 +77,9 @@
@update:model-value="$emit('update:constructeur-links', $event)"
@remove="$emit('remove-constructeur-link', $event)"
/>
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
<span class="text-base-content/50">Non défini</span>
</div>
<p v-else-if="!isEditMode" class="text-sm font-medium text-base-content/50 py-1">
Non défini
</p>
</div>
</div>
@@ -152,9 +152,9 @@
</div>
</template>
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
<p class="text-sm font-medium text-base-content py-1">
{{ formatValueForDisplay(field) }}
</div>
</p>
</template>
</div>
</div>

View File

@@ -2,7 +2,10 @@
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2>
<h2 class="card-title">
Pièces de la machine
<span v-if="pieces.length" class="badge badge-outline badge-sm ml-1">{{ pieces.length }}</span>
</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"

View File

@@ -29,7 +29,16 @@
>
<div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
{{ product.name }}
<NuxtLink
v-if="!isEditMode && !product.pendingEntity && product.id"
:to="machineId
? { path: `/product/${product.id}`, query: { from: 'machine', machineId } }
: `/product/${product.id}`"
class="hover:underline hover:text-primary transition-colors"
>
{{ product.name }}
</NuxtLink>
<span v-else>{{ product.name }}</span>
</p>
<div class="flex items-center gap-2">
<button
@@ -133,7 +142,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
@@ -142,6 +151,9 @@ import {
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
defineProps<{
products: Array<{
id?: string | null

View File

@@ -2,12 +2,14 @@
<main
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
>
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
<p class="text-base text-base-content/70">
{{ descriptionText }}
</p>
</header>
<template v-if="!hideHeading">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
<p class="text-base text-base-content/70">
{{ descriptionText }}
</p>
</header>
</template>
<nav
v-if="allowCategorySwitch"
@@ -144,9 +146,11 @@ const props = withDefaults(
heading: string
description?: string
allowCategorySwitch?: boolean
hideHeading?: boolean
}>(),
{
allowCategorySwitch: false,
hideHeading: false,
},
)

View File

@@ -31,16 +31,28 @@
:key="entry.id"
class="px-2 py-1"
>
<button
type="button"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="onOpenEdit(entry)"
<div
class="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-2 hover:bg-base-200"
>
<span class="font-medium text-base-content">{{ entry.name }}</span>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</button>
<div class="flex min-w-0 flex-col gap-0.5">
<NuxtLink
:to="itemDetailPath(entry)"
class="font-medium hover:underline hover:text-primary transition-colors"
@click="emit('close')"
>
{{ entry.name }}
</NuxtLink>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</div>
<div class="shrink-0">
<span v-if="entry.machineCount > 0" class="badge badge-ghost badge-sm">
{{ entry.machineCount }} machine{{ entry.machineCount > 1 ? 's' : '' }}
</span>
<span v-else class="text-xs text-base-content/30">Aucune machine</span>
</div>
</div>
</li>
</ul>
</div>
@@ -57,14 +69,13 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { humanizeError } from '~/shared/utils/errorMessages'
import type { ModelCategory, ModelType } from '~/services/modelTypes'
type RelatedEntry = {
id: string
name: string
reference?: string | null
machineCount: number
}
const props = defineProps<{
@@ -104,73 +115,37 @@ const modalSubtitle = computed(() => {
return `${count} ${labels.plural} liés.`
})
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
return { endpoint: '/products', filterKey: 'typeProduct' }
}
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== 'object') return null
const record = item as Record<string, unknown>
if (typeof record.id !== 'string') return null
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
const reference
= typeof record.reference === 'string' && record.reference.trim()
? record.reference
: typeof record.code === 'string' && record.code.trim()
? record.code
: null
return { id: record.id, name, reference }
const itemDetailPath = (item: RelatedEntry) => {
if (!props.modelType) return '#'
const category = props.modelType.category
if (category === 'COMPONENT') return `/component/${item.id}`
if (category === 'PIECE') return `/piece/${item.id}`
return `/product/${item.id}`
}
const loadRelatedItems = async (modelType: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
params.set(filterKey, `/api/model_types/${modelType.id}`)
params.set('order[name]', 'asc')
loading.value = true
error.value = null
items.value = []
try {
const result = await get(`${endpoint}?${params.toString()}`)
const result = await get(`/model_types/${modelType.id}/related-items`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger les éléments liés.'
return
}
const collection = extractCollection(result.data)
items.value = collection
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry))
}
catch (err) {
let raw: string | null = null
if (err && typeof err === 'object') {
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
if (e.data) {
const data = e.data
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
else if (typeof data.detail === 'string') raw = data.detail
else if (typeof data.message === 'string') raw = data.message
else if (typeof data.error === 'string') raw = data.error
}
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
if (!raw && typeof e.message === 'string') raw = e.message
if (Array.isArray(result.data)) {
items.value = result.data as RelatedEntry[]
}
error.value = humanizeError(raw)
}
catch {
error.value = 'Impossible de charger les éléments liés.'
}
finally {
loading.value = false
}
}
const onOpenEdit = (entry: RelatedEntry) => {
emit('open-edit', entry)
}
watch(
() => props.open,
(isOpen) => {

View File

@@ -11,13 +11,14 @@
<h3 class="card-title text-lg text-base-content">
{{ site.name }}
</h3>
<div
class="badge font-bold"
<NuxtLink
:to="`/machines?sites=${site.id}`"
class="badge font-bold hover:opacity-80 transition-opacity"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ machineCount }} machines
</div>
</NuxtLink>
</div>
<div class="space-y-3 text-sm">
@@ -39,10 +40,10 @@
</span>
</div>
<div class="flex items-center gap-2 text-base-content/60">
<NuxtLink :to="`/machines?sites=${site.id}`" class="flex items-center gap-2 text-base-content/60 hover:text-primary transition-colors">
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span>{{ machineCount }} machine(s)</span>
</div>
</NuxtLink>
</div>
<div class="card-actions justify-end mt-4">

View File

@@ -12,7 +12,7 @@ import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useComponentHistory } from '~/composables/useComponentHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
@@ -65,7 +65,7 @@ export function useComponentEdit(componentId: string) {
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
} = useEntityHistory('composant')
const component = ref<any | null>(null)
const loading = ref(true)

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ComponentHistoryActor = EntityHistoryActor
export type ComponentHistoryEntry = EntityHistoryEntry
export function useComponentHistory() {
return useEntityHistory('composant')
}

View File

@@ -4,7 +4,7 @@ import { useApi } from './useApi'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Composant {
id: string
@@ -51,17 +51,6 @@ const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useComposants() {
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Document {
id: string
@@ -58,13 +58,6 @@ const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function useDocuments() {
const { get, patch, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast()

View File

@@ -7,7 +7,7 @@ import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { usePieceHistory } from '~/composables/usePieceHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -47,7 +47,7 @@ export function usePieceEdit(pieceId: string) {
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
} = useEntityHistory('piece')
const piece = ref<any | null>(null)
const loading = ref(true)
@@ -83,8 +83,10 @@ export function usePieceEdit(pieceId: string) {
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const productSelections = ref<(string | null)[]>([])
// Declared early so useCustomFieldInputs can reference it.
// selectedType is defined later but is safely accessed inside a computed (lazy evaluation).
const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
pieceTypeDetails.value?.structure ?? null,
)
const {

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type PieceHistoryActor = EntityHistoryActor
export type PieceHistoryEntry = EntityHistoryEntry
export function usePieceHistory() {
return useEntityHistory('piece')
}

View File

@@ -4,7 +4,7 @@ import { useApi } from './useApi'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Piece {
id: string
@@ -53,17 +53,6 @@ const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function usePieces() {
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ProductHistoryActor = EntityHistoryActor
export type ProductHistoryEntry = EntityHistoryEntry
export function useProductHistory() {
return useEntityHistory('product')
}

View File

@@ -5,7 +5,7 @@ import { humanizeError } from '~/shared/utils/errorMessages'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Product {
id: string
@@ -66,17 +66,6 @@ const replaceInCache = (item: Product): boolean => {
return false
}
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useProducts() {
const { showError } = useToast()
const { get, post, patch, delete: del } = useApi()

View File

@@ -7,6 +7,7 @@ export interface Toast {
message: string
type: ToastType
visible: boolean
duration: number
}
const toasts = ref<Toast[]>([])
@@ -32,6 +33,7 @@ export function useToast() {
message,
type,
visible: true,
duration: type === 'error' ? 0 : duration,
}
if (toasts.value.length >= MAX_TOASTS) {
@@ -40,10 +42,12 @@ export function useToast() {
toasts.value.push(toast)
// Auto-remove after duration
setTimeout(() => {
removeToast(id)
}, duration)
// Only auto-dismiss non-error toasts
if (type !== 'error' && duration > 0) {
setTimeout(() => {
removeToast(id)
}, duration)
}
return id
}
@@ -52,8 +56,8 @@ export function useToast() {
return showToast(message, 'success', duration)
}
const showError = (message: string, duration = 5000): number => {
return showToast(message, 'error', duration)
const showError = (message: string): number => {
return showToast(message, 'error', 0)
}
const showWarning = (message: string, duration = 6000): number => {

View File

@@ -0,0 +1,32 @@
import type { Ref } from 'vue'
export function useUnsavedGuard(isDirty: Ref<boolean>) {
const { confirm } = useConfirm()
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isDirty.value) {
e.preventDefault()
e.returnValue = ''
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (!isDirty.value) return true
const ok = await confirm({
title: 'Modifications non sauvegardées',
message: 'Vous avez des modifications en cours. Voulez-vous quitter sans sauvegarder ?',
confirmText: 'Quitter sans sauver',
cancelText: 'Rester',
dangerous: true,
})
return ok
})
}

View File

@@ -0,0 +1,52 @@
import type { Ref } from 'vue'
interface UsedInMachine {
id: string
name: string
site?: { id: string; name: string } | null
}
interface UsedInEntity {
id: string
name: string
}
interface UsedInData {
machines: UsedInMachine[]
composants: UsedInEntity[]
pieces: UsedInEntity[]
}
export function useUsedIn(entityType: Ref<'composants' | 'pieces' | 'products'>, entityId: Ref<string | null>) {
const data = ref<UsedInData>({ machines: [], composants: [], pieces: [] })
const loading = ref(false)
const api = useApi()
const load = async () => {
if (!entityId.value) return
loading.value = true
try {
const result = await api.get(`/${entityType.value}/${entityId.value}/used-in`)
if (result.success && result.data) {
data.value = {
machines: result.data.machines || [],
composants: result.data.composants || [],
pieces: result.data.pieces || [],
}
}
} finally {
loading.value = false
}
}
const totalCount = computed(() =>
data.value.machines.length + data.value.composants.length + data.value.pieces.length
)
watch(entityId, (val) => {
if (val) load()
}, { immediate: true })
return { data, loading, totalCount, load }
}

View File

@@ -0,0 +1,24 @@
export default defineNuxtRouteMiddleware((to) => {
const redirects: Record<string, string> = {
'/component-catalog': '/catalogues/composants',
'/pieces-catalog': '/catalogues/pieces',
'/product-catalog': '/catalogues/produits',
}
// Exact path match redirects
const redirect = redirects[to.path]
if (redirect) {
return navigateTo({ path: redirect, query: to.query }, { redirectCode: 301 })
}
// Category index redirects (add tab=categories query param)
if (to.path === '/component-category') {
return navigateTo({ path: '/catalogues/composants', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
}
if (to.path === '/piece-category') {
return navigateTo({ path: '/catalogues/pieces', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
}
if (to.path === '/product-category') {
return navigateTo({ path: '/catalogues/produits', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
}
})

View File

@@ -0,0 +1,239 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
</div>
<NuxtLink v-if="canEdit" to="/component/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un composant
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Composants">
<template #tab-catalogue>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-1">
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
<p class="text-sm text-base-content/50">
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
</p>
</header>
<DataTable
:columns="columns"
:rows="componentRows"
:loading="loadingComposants"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun composant n'a encore été créé."
no-results-message="Aucun composant ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.component)"
:alt="resolvePreviewAlt(row.component)"
/>
</template>
<template #cell-name="{ row }">
{{ row.component.name || 'Composant sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.component.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.component.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.component.description" class="group relative">
<span class="block cursor-help truncate">{{ row.component.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
</div>
</div>
<span v-else></span>
</template>
<template #cell-typeComposant="{ row }">
<NuxtLink
v-if="row.component.typeComposant?.id"
:to="`/component-category/${row.component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(row.component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(row.component) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>
Supprimer
</button>
<NuxtLink
:to="`/component/${row.component.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</template>
<template #tab-categories>
<ManagementView
category="COMPONENT"
heading="Catégories de composant"
:hide-heading="true"
/>
</template>
</EntityTabs>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import ManagementView from '~/components/model-types/ManagementView.vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const route = useRoute()
const { canEdit } = usePermissions()
const activeTab = ref((route.query.tab as string) || 'catalogue')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const pageTabs = computed(() => {
const t: Array<{ key: string; label: string }> = [
{ key: 'catalogue', label: 'Catalogue' },
]
if (canEdit.value) {
t.push({ key: 'categories', label: 'Catégories' })
}
return t
})
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const table = useDataTable(
{ fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const composantsOnPage = computed(() => componentRows.value.length)
const paginationState = table.pagination(total, composantsOnPage)
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
})
})
const componentRows = computed(() =>
composantsList.value.map(component => ({
id: component.id,
component,
})),
)
async function fetchComposants() {
await loadComposants({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeComposant || undefined,
force: true,
})
}
const resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const { confirm } = useConfirm()
const handleDeleteComponent = async (component: Record<string, any>) => {
const componentName = component?.name || 'ce composant'
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
if (!confirmed) return
await deleteComposant(component.id)
fetchComposants()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()])
})
</script>

View File

@@ -0,0 +1,267 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
</div>
<NuxtLink v-if="canEdit" to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter une pièce
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Pièces">
<template #tab-catalogue>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-2">
<h2 class="text-xl font-semibold text-base-content">Pièces créées</h2>
<p class="text-sm text-base-content/70">
Liste globale des pièces enregistrées, quel que soit leur squelette d'origine.
</p>
</header>
<DataTable
:columns="columns"
:rows="pieceRows"
:loading="loadingPieces"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucune pièce n'a encore été créée."
no-results-message="Aucune pièce ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</template>
<template #cell-name="{ row }">
{{ row.piece.name || 'Pièce sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.piece.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.piece.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm 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>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else>—</span>
</template>
<template #cell-typePiece="{ row }">
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
<NuxtLink
:to="`/piece/${row.piece.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</template>
<template #tab-categories>
<ManagementView
category="PIECE"
heading="Catégories de pièce"
:hide-heading="true"
/>
</template>
</EntityTabs>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import ManagementView from '~/components/model-types/ManagementView.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const route = useRoute()
const { canEdit } = usePermissions()
const activeTab = ref((route.query.tab as string) || 'catalogue')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const pageTabs = computed(() => {
const t: Array<{ key: string; label: string }> = [
{ key: 'catalogue', label: 'Catalogue' },
]
if (canEdit.value) {
t.push({ key: 'categories', label: 'Catégories' })
}
return t
})
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const table = useDataTable(
{ fetchData: fetchPieces },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const piecesOnPage = computed(() => pieceRows.value.length)
const paginationState = table.pagination(total, piecesOnPage)
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return { ...piece, typePiece: typePiece || piece.typePiece || null }
})
})
const pieceRows = computed(() =>
piecesList.value.map(piece => ({
id: piece.id,
piece,
suppliers: buildPieceSuppliersDisplay(piece),
})),
)
async function fetchPieces() {
await loadPieces({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typePiece || undefined,
force: true,
})
}
const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
fetchPieces()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])
})
</script>

View File

@@ -0,0 +1,278 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
</div>
<NuxtLink v-if="canEdit" to="/product/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un produit
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Produits">
<template #tab-catalogue>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div
v-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
<span class="font-semibold">Impossible de charger les produits</span>
<span class="text-sm">{{ errorMessage }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
Réessayer
</button>
</div>
<DataTable
v-else
:columns="columns"
:rows="productRows"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun produit n'a encore été enregistré."
no-results-message="Aucun produit ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product, true)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.product.name }}</span>
</template>
<template #cell-reference="{ row }">
{{ row.product.reference || '—' }}
</template>
<template #cell-typeProduct="{ row }">
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '' }}</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else class="text-sm text-base-content/50"></span>
</template>
<template #cell-price="{ row }">
{{ formatPrice(row.product.supplierPrice) }}
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
<NuxtLink
:to="`/product/${row.product.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</template>
<template #tab-categories>
<ManagementView
category="PRODUCT"
heading="Catégories de produit"
:hide-heading="true"
/>
</template>
</EntityTabs>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useHead } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import ManagementView from '~/components/model-types/ManagementView.vue'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
const route = useRoute()
const { canEdit } = usePermissions()
const activeTab = ref((route.query.tab as string) || 'catalogue')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const pageTabs = computed(() => {
const t: Array<{ key: string; label: string }> = [
{ key: 'catalogue', label: 'Catalogue' },
]
if (canEdit.value) {
t.push({ key: 'categories', label: 'Catégories' })
}
return t
})
useHead(() => ({ title: 'Catalogue des produits' }))
const {
products,
total,
loading,
error,
loadProducts,
deleteProduct,
} = useProducts()
const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const table = useDataTable(
{ fetchData: fetchProducts },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
]
const productsOnPage = computed(() => productRows.value.length)
const paginationState = table.pagination(total, productsOnPage)
const normalizedProducts = computed(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
})
})
const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildProductSuppliersDisplay(product),
})),
)
async function fetchProducts() {
await loadProducts({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeProduct || undefined,
force: true,
})
}
const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') return '—'
const number = Number(value)
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()
const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => {
const productName = product?.name || 'ce produit'
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
if (!confirmed) return
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${productName}" supprimé`)
}
}
onMounted(async () => {
await Promise.all([fetchProducts(), loadProductTypes()])
})
</script>

View File

@@ -1,212 +0,0 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
<p class="text-sm text-base-content/50 mt-1">
Consultez et gérez tous les composants existants.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un composant
</NuxtLink>
<NuxtLink to="/component-category" class="btn btn-ghost btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-1">
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
<p class="text-sm text-base-content/50">
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
</p>
</header>
<DataTable
:columns="columns"
:rows="componentRows"
:loading="loadingComposants"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun composant n'a encore été créé."
no-results-message="Aucun composant ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.component)"
:alt="resolvePreviewAlt(row.component)"
/>
</template>
<template #cell-name="{ row }">
{{ row.component.name || 'Composant sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.component.reference || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.component.description" class="group relative">
<span class="block cursor-help truncate">{{ row.component.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
</div>
</div>
<span v-else></span>
</template>
<template #cell-typeComposant="{ row }">
<NuxtLink
v-if="row.component.typeComposant?.id"
:to="`/component-category/${row.component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(row.component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(row.component) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>
Supprimer
</button>
<NuxtLink
:to="`/component/${row.component.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const table = useDataTable(
{ fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'description', label: 'Description' },
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const composantsOnPage = computed(() => componentRows.value.length)
const paginationState = table.pagination(total, composantsOnPage)
// Enrich composants with full type data
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
})
})
const componentRows = computed(() =>
composantsList.value.map(component => ({
id: component.id,
component,
})),
)
async function fetchComposants() {
await loadComposants({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeComposant || undefined,
force: true,
})
}
const resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const { confirm } = useConfirm()
const handleDeleteComponent = async (component: Record<string, any>) => {
const componentName = component?.name || 'ce composant'
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
if (!confirmed) return
await deleteComposant(component.id)
fetchComposants()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()])
})
</script>

View File

@@ -1,452 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le composant</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du composant et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Sélections du squelette</h2>
<p class="text-xs text-base-content/70">
Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</div>
</div>
</div>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce composant.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchComponent()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="async () => { await submitEdition(); versionRefreshKey++ }">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
const route = useRoute()
const { updateDocument } = useDocuments()
const { getConstructeurById } = useConstructeurs()
const versionRefreshKey = ref(0)
const {
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
historyFieldLabels,
canEdit,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
submitEdition,
setSlotQuantity,
setPieceSlotSelection,
setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
fetchComponent,
} = useComponentEdit(String(route.params.id))
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
// Remove links whose ID was removed from the select
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script>

View File

@@ -18,19 +18,13 @@
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<EmptyState
v-else-if="!component"
title="Composant introuvable"
description="Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé."
action-label="Retour au catalogue"
action-to="/catalogues/composants"
/>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
@@ -39,383 +33,432 @@
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/component-catalog"
back-link="/catalogues/composants"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || component.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ component.description }}
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || component.reference || constructeurLinks.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || component.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.prix }}
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Skeleton slot selections -->
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ selectedType?.name || '' }}
</p>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ component.name }}
</p>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || component.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ component.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="component.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
<span class="font-mono font-semibold">{{ component.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</p>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || component.reference || constructeurLinks.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || component.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ component.reference }}
</p>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif (€)</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ component.prix }} €
</p>
</div>
</div>
<UsedInSection entity-type="composants" :entity-id="component?.id ?? null" />
</div>
</template>
<template #tab-structure>
<div class="space-y-6">
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Skeleton slot selections -->
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</template>
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>
{{ slot.selectedPieceName }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</template>
</div>
</div>
</div>
</template>
<div v-else class="input input-bordered input-sm md:input-md flex items-center gap-2" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>
{{ slot.selectedPieceName }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</template>
</div>
</div>
</div>
</div>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedProductName }}</template>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedProductName }}</template>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedComponentName }}</template>
</div>
</div>
</div>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedComponentName }}</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || componentDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
</p>
<template #tab-documents>
<!-- Documents -->
<div
v-if="isEditMode || componentDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
/>
</template>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
</template>
<template #tab-custom-fields>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<p class="text-sm font-medium text-base-content py-1">
{{ field.value }}
</p>
</div>
</div>
</template>
</div>
</template>
<template #tab-history>
<div class="space-y-6">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchComponent()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
/>
</template>
</div>
</EntityTabs>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Save buttons (edit mode only) -->
<!-- Save/Cancel buttons (outside tabs) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
@@ -425,16 +468,6 @@
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
@@ -456,6 +489,12 @@ const { getConstructeurById } = useConstructeurs()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const {
component,
@@ -504,6 +543,7 @@ const submitEdition = async () => {
if (!saving.value) {
await fetchComponent()
isEditMode.value = false
versionRefreshKey.value++
}
}
@@ -537,6 +577,14 @@ const visibleCustomFields = computed(() => {
)
})
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure', count: pieceSlotEntries.value.length + productSlotEntries.value.length + subcomponentSlotEntries.value.length },
{ key: 'documents', label: 'Documents', count: componentDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
{ key: 'history', label: 'Historique' },
])
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true

View File

@@ -1,212 +1,240 @@
<template>
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouvel composant</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis complétez les informations du composant.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="componentTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouveau composant"
subtitle="Sélectionnez la catégorie cible puis complétez les informations du composant."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/composants"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="componentTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div
v-if="structureHasRequirements"
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">
Sélection des éléments du squelette
</h2>
<p class="text-xs text-base-content/70">
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
<!-- Description -->
<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>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
</div>
</template>
<template #tab-structure>
<div class="space-y-6">
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<div
v-if="structureHasRequirements"
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">
Sélection des éléments du squelette
</h2>
<p class="text-xs text-base-content/70">
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
</p>
</div>
<span
class="badge"
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
>
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
</span>
</div>
<div
v-if="structureDataLoading"
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces, produits et composants
</div>
<ComponentStructureAssignmentNode
v-else-if="structureAssignments"
:assignment="structureAssignments"
:pieces="availablePieces"
:products="availableProducts"
:components="availableComponents"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
</p>
</div>
<EmptyState
v-if="!selectedType"
title="Aucune catégorie sélectionnée"
description="Sélectionnez une catégorie dans l'onglet Général pour voir la structure du squelette."
/>
</div>
</template>
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes) liés à ce composant.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<span
class="badge"
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
>
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
</span>
</div>
</template>
<div
v-if="structureDataLoading"
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces, produits et composants
</div>
<ComponentStructureAssignmentNode
v-else-if="structureAssignments"
:assignment="structureAssignments"
:pieces="availablePieces"
:products="availableProducts"
:components="availableComponents"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes) liés à ce composant.
</p>
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/composants" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -220,11 +248,14 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
const route = useRoute()
const { getConstructeurById } = useConstructeurs()
const activeTab = ref('general')
const {
selectedTypeId,
submitting,
@@ -261,6 +292,13 @@ const {
submitCreation,
} = useComponentCreate()
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure' },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,

View File

@@ -48,6 +48,39 @@
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</template>
<template #cell-composantCount="{ row }">
<NuxtLink
v-if="stats[row.id]?.composantCount"
:to="`/catalogues/composants?constructeur=${row.id}`"
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
>
{{ stats[row.id].composantCount }}
</NuxtLink>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-pieceCount="{ row }">
<NuxtLink
v-if="stats[row.id]?.pieceCount"
:to="`/catalogues/pieces?constructeur=${row.id}`"
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
>
{{ stats[row.id].pieceCount }}
</NuxtLink>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-machineCount="{ row }">
<NuxtLink
v-if="stats[row.id]?.machineCount"
:to="`/machines?constructeur=${row.id}`"
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
>
{{ stats[row.id].machineCount }}
</NuxtLink>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
@@ -91,7 +124,7 @@
</main>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
@@ -103,6 +136,7 @@ import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
const api = useApi()
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError } = useToast()
@@ -112,12 +146,16 @@ const columns = [
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'composantCount', label: 'Composants', align: 'center' },
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
{ key: 'machineCount', label: 'Machines', align: 'center' },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const searchTerm = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
const stats = ref({})
const currentSort = computed(() => ({
field: sortKey.value,
@@ -236,5 +274,15 @@ const confirmDelete = async (constructeur) => {
}
}
onMounted(() => loadConstructeurs())
const loadStats = async () => {
const result = await api.get('/constructeurs/stats')
if (result.success && result.data) {
stats.value = result.data
}
}
onMounted(() => {
loadConstructeurs()
loadStats()
})
</script>

View File

@@ -68,34 +68,22 @@
</div>
<!-- Empty State -->
<div v-else-if="filteredSites.length === 0" class="text-center py-16">
<div class="max-w-sm mx-auto">
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<IconLucideFactory
class="w-8 h-8 text-base-content/30"
aria-hidden="true"
/>
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">
Aucune machine trouvée
</h3>
<p class="text-sm text-base-content/50 mb-6">
Commencez par ajouter des sites et des machines.
</p>
<div class="flex gap-2 justify-center">
<button v-if="canEdit" class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button
v-if="canEdit"
class="btn btn-ghost btn-sm"
@click="showAddMachineModal = true"
>
Ajouter une machine
</button>
</div>
<EmptyState
v-else-if="filteredSites.length === 0"
:icon="IconLucideFactory"
title="Aucune machine trouvée"
description="Commencez par ajouter des sites et des machines."
class="py-16"
>
<div v-if="canEdit" class="flex gap-2 justify-center">
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
Ajouter une machine
</button>
</div>
</div>
</EmptyState>
<!-- Sites List -->
<div v-else class="space-y-5">
@@ -141,13 +129,14 @@
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="badge font-bold"
<NuxtLink
:to="`/machines?sites=${site.id}`"
class="badge font-bold hover:opacity-80 transition-opacity"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ site.machines?.length || 0 }}
</span>
{{ site.machines?.length || 0 }} machine{{ (site.machines?.length || 0) > 1 ? 's' : '' }}
</NuxtLink>
<button
class="btn btn-ghost btn-xs btn-circle"
@click="toggleSiteCollapse(site.id)"

View File

@@ -17,123 +17,129 @@
<!-- Header with actions -->
<MachineDetailHeader
:title="machineViewTitle"
:title="d.machine.value.name"
:description="d.machine.value.description"
:site-name="d.machine.value.site?.name"
:site-color="d.machine.value.site?.color"
:reference="d.machine.value.reference"
:is-edit-mode="d.isEditMode.value"
@toggle-edit="d.toggleEditMode"
@open-print="d.openPrintModal"
/>
<!-- Debug info -->
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
<p>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div
v-if="d.machine.value.site?.name"
class="badge badge-outline font-semibold"
:style="d.machine.value.site?.color ? { borderColor: d.machine.value.site.color + '60', backgroundColor: d.machine.value.site.color + '25', color: d.machine.value.site.color } : {}"
>
{{ d.machine.value.site?.name }}
<!-- Tabbed content -->
<EntityTabs v-model="activeTab" :tabs="machineTabs" aria-label="Sections machine">
<template #tab-general>
<div class="space-y-8">
<MachineInfoCard
ref="machineInfoCardRef"
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-site-id="d.machineSiteId.value"
:machine-site-name="d.machine.value?.site?.name ?? ''"
:sites="d.sites.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:constructeur-links="d.constructeurLinks.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId"
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue"
@custom-fields-saved="() => { if (!isSavingMachine) { d.loadMachineData(); refreshVersions() } }"
/>
<MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
:products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="confirmRemoveProduct"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
/>
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
{{ d.machine.value.reference }}
</template>
<template #tab-structure>
<div class="space-y-8">
<MachineComponentsCard
v-if="d.isEditMode.value || d.components.value.length > 0"
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.handleCustomFieldUpdate"
@add-component="openAddModal('component')"
@remove-component="confirmRemoveComponent"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
/>
<MachinePiecesCard
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value"
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.handleCustomFieldUpdate"
@add-piece="openAddModal('piece')"
@remove-piece="confirmRemovePiece"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
@toggle-collapse="d.toggleAllPieces"
/>
</div>
</div>
</PageHero>
</template>
<!-- Machine Info Card -->
<MachineInfoCard
ref="machineInfoCardRef"
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-site-id="d.machineSiteId.value"
:machine-site-name="d.machine.value?.site?.name ?? ''"
:sites="d.sites.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:constructeur-links="d.constructeurLinks.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId"
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue"
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
/>
<template #tab-documents>
<MachineDocumentsCard
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="confirmRemoveDocument"
/>
</template>
<!-- Documents -->
<MachineDocumentsCard
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="d.removeMachineDocument"
/>
<!-- Produits associés -->
<MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
:products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
/>
<!-- Components Section -->
<MachineComponentsCard
v-if="d.isEditMode.value || d.components.value.length > 0"
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.handleCustomFieldUpdate"
@add-component="openAddModal('component')"
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
/>
<!-- Machine Pieces Section -->
<MachinePiecesCard
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value"
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.handleCustomFieldUpdate"
@add-piece="openAddModal('piece')"
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
@toggle-collapse="d.toggleAllPieces"
/>
<template #tab-history>
<div class="space-y-8">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</template>
</EntityTabs>
<!-- Add Entity Modal -->
<AddEntityToMachineModal
@@ -164,50 +170,17 @@
Enregistrer les modifications
</button>
</div>
<!-- Historique -->
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Versions -->
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</div>
<!-- Error State -->
<div v-else class="text-center py-12">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<IconLucideAlertTriangle class="w-8 h-8 text-base-content/30" aria-hidden="true" />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
<button type="button" class="btn btn-primary" @click="$router.back()">
Retour aux machines
</button>
</div>
</div>
<EmptyState
v-else
:icon="IconLucideAlertTriangle"
title="Machine non trouvée"
:description="`La machine avec l'ID « ${machineId} » n'existe pas ou a été supprimée.`"
action-label="Retour aux machines"
@action="$router.back()"
/>
</main>
<MachinePrintSelectionModal
@@ -223,13 +196,12 @@
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useEntityHistory } from '~/composables/useEntityHistory'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
import MachineDetailHeader from '~/components/machine/MachineDetailHeader.vue'
import MachineInfoCard from '~/components/machine/MachineInfoCard.vue'
@@ -251,9 +223,24 @@ if (!machineId) {
}
const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref(null)
const machineInfoCardRef = ref<{ saveFieldDefinitions?: () => Promise<void> } | null>(null)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const isSavingMachine = ref(false)
const { confirm: confirmDialog } = useConfirm()
const versionListRef = ref<InstanceType<typeof EntityVersionList> | null>(null)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const machineTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure', count: d.components.value.length + d.machinePieces.value.length },
{ key: 'documents', label: 'Documents', count: d.machineDocumentsList.value.length },
{ key: 'history', label: 'Historique' },
])
const {
history,
@@ -321,25 +308,49 @@ const handleAddEntity = async (payload) => {
refreshVersions()
}
const handleFillEntity = (linkId, entityKind, modelTypeId) => {
const handleFillEntity = (linkId: string, entityKind: string, modelTypeId: string) => {
fillLinkId.value = linkId
fillTypeId.value = modelTypeId
addModalKind.value = entityKind
addModalOpen.value = true
}
const machineViewTitle = computed(() => {
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
})
const submitMachineEdition = async () => {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
isSavingMachine.value = true
try {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
}
await d.submitEdition()
refreshVersions()
} finally {
isSavingMachine.value = false
}
await d.submitEdition()
}
const confirmRemoveProduct = async (id: string) => {
if (!await confirmDialog({ title: 'Retirer ce produit ?', message: 'Le produit sera dissocié de la machine.', confirmText: 'Retirer', dangerous: true })) return
await d.removeProductLink(id)
refreshVersions()
}
const confirmRemoveComponent = async (id: string) => {
if (!await confirmDialog({ title: 'Retirer ce composant ?', message: 'Le composant sera dissocié de la machine.', confirmText: 'Retirer', dangerous: true })) return
await d.removeComponentLink(id)
refreshVersions()
}
const confirmRemovePiece = async (id: string) => {
if (!await confirmDialog({ title: 'Retirer cette pièce ?', message: 'La pièce sera dissociée de la machine.', confirmText: 'Retirer', dangerous: true })) return
await d.removePieceLink(id)
refreshVersions()
}
const confirmRemoveDocument = async (id: string) => {
if (!await confirmDialog({ title: 'Supprimer ce document ?', message: 'Le fichier sera supprimé définitivement.', confirmText: 'Supprimer', dangerous: true })) return
await d.removeMachineDocument(id)
}
onMounted(() => {
d.loadMachineData()
d.loadInitialData()

View File

@@ -53,20 +53,14 @@
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="filteredMachines.length === 0" class="text-center py-12">
<div class="max-w-md mx-auto">
<IconLucideFactory class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" />
<h3 class="text-lg font-medium text-gray-900 mb-2">
Aucune machine trouvée
</h3>
<p class="text-gray-500 mb-4">
Commencez par ajouter votre première machine.
</p>
<NuxtLink to="/machines/new" class="btn btn-primary">
Ajouter une machine
</NuxtLink>
</div>
</div>
<EmptyState
v-else-if="filteredMachines.length === 0"
:icon="IconLucideFactory"
title="Aucune machine trouvée"
description="Commencez par ajouter votre première machine."
action-label="Ajouter une machine"
action-to="/machines/new"
/>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div

View File

@@ -18,19 +18,13 @@
<p class="text-sm text-base-content/70">Chargement de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<EmptyState
v-else-if="!piece"
title="Pièce introuvable"
description="Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée."
action-label="Retour au catalogue"
action-to="/catalogues/pieces"
/>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
@@ -39,331 +33,382 @@
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/pieces-catalog"
back-link="/catalogues/pieces"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<template v-if="isEditMode">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || piece.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
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 v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ piece.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
<EntityTabs
v-model="activeTab"
:tabs="entityTabs"
aria-label="Sections de la pièce"
>
<div v-if="isEditMode || piece.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<template v-if="isEditMode">
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
<ConstructeurLinksTable
v-model="constructeurLinks"
class="mt-2"
@remove="handleConstructeurRemoved"
/>
</template>
<ConstructeurLinksTable
v-else
:model-value="constructeurLinks"
readonly
/>
</div>
</div>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.prix }}
</div>
</div>
</div>
<!-- Product requirements -->
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produits liés
</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
</label>
<div class="input input-bordered input-sm md:input-md flex items-center" :class="productSelectionLabels[index] ? 'bg-base-200' : 'border-error bg-error/10 text-error font-semibold'">
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
<template v-else>{{ productSelectionLabels[index] }}</template>
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/piece-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ selectedType?.name || '' }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && (selectedType || resolvedStructure)"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.name }}
</p>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || piece.description" class="form-control">
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
<span class="label-text">Description</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
<textarea
v-if="isEditMode"
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 v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ piece.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</p>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || piece.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.reference }}
</p>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<template v-if="isEditMode">
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
<ConstructeurLinksTable
v-model="constructeurLinks"
class="mt-2"
@remove="handleConstructeurRemoved"
/>
</template>
<ConstructeurLinksTable
v-else
:model-value="constructeurLinks"
readonly
/>
</div>
</div>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif (€)</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.prix }} €
</p>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && (selectedType || resolvedStructure)"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<UsedInSection entity-type="pieces" :entity-id="piece?.id ?? null" />
</div>
</template>
<template #tab-products>
<div class="space-y-6">
<!-- Product requirements -->
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produits liés
</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
</label>
<div class="text-sm font-medium py-1 px-2 rounded" :class="productSelectionLabels[index] ? 'text-base-content' : 'border border-error bg-error/10 text-error font-semibold'">
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
<template v-else>{{ productSelectionLabels[index] }}</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || pieceDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
</p>
<template #tab-documents>
<div class="space-y-6">
<!-- Documents -->
<div
v-if="isEditMode || pieceDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
/>
</template>
</div>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
</template>
<template #tab-custom-fields>
<div class="space-y-6">
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<p class="text-sm font-medium text-base-content py-1">
{{ field.value }}
</p>
</div>
</div>
</template>
</div>
</div>
</template>
<template #tab-history>
<div class="space-y-6">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
</EntityTabs>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
@@ -375,16 +420,6 @@
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
@@ -405,6 +440,11 @@ const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const {
piece,
loading,
@@ -441,6 +481,14 @@ const {
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
{ key: 'documents', label: 'Documents', count: pieceDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
{ key: 'history', label: 'Historique' },
])
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {

View File

@@ -1,245 +0,0 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des pièces</h1>
<p class="text-sm text-gray-500">
Consultez et gérez toutes les pièces existantes.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter une pièce
</NuxtLink>
<NuxtLink to="/piece-category" class="btn btn-outline btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-2">
<h2 class="text-xl font-semibold text-base-content">Pièces créées</h2>
<p class="text-sm text-base-content/70">
Liste globale des pièces enregistrées, quel que soit leur squelette d'origine.
</p>
</header>
<DataTable
:columns="columns"
:rows="pieceRows"
:loading="loadingPieces"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucune pièce n'a encore été créée."
no-results-message="Aucune pièce ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</template>
<template #cell-name="{ row }">
{{ row.piece.name || 'Pièce sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.piece.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.piece.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm 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>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else>—</span>
</template>
<template #cell-typePiece="{ row }">
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
<NuxtLink
:to="`/piece/${row.piece.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const table = useDataTable(
{ fetchData: fetchPieces },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const piecesOnPage = computed(() => pieceRows.value.length)
const paginationState = table.pagination(total, piecesOnPage)
// Enrich pieces with full type data
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return { ...piece, typePiece: typePiece || piece.typePiece || null }
})
})
const pieceRows = computed(() =>
piecesList.value.map(piece => ({
id: piece.id,
piece,
suppliers: buildPieceSuppliersDisplay(piece),
})),
)
async function fetchPieces() {
await loadPieces({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typePiece || undefined,
force: true,
})
}
const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
fetchPieces()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])
})
</script>

View File

@@ -1,357 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier la pièce</h1>
<p class="text-sm text-base-content/70">
Ajustez les informations de la pièce et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
<div v-if="piece?.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<input
:value="piece.referenceAuto"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
title="Générée automatiquement à partir du type et des champs personnalisés"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType || resolvedStructure"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à cette pièce.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
const route = useRoute()
const { updateDocument } = useDocuments()
const {
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
productSelections,
customFieldInputs,
canEdit,
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition,
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script>

View File

@@ -1,206 +1,235 @@
<template>
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouvelle pièce</h1>
<p class="text-sm text-base-content/70">
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="pieceTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouvelle pièce"
subtitle="Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/pieces"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections de la pièce">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="pieceTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<!-- Description -->
<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>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<!-- Skeleton preview -->
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedType.structure"
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
variant="piece"
/>
</div>
</div>
</div>
</template>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedType.structure"
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
variant="piece"
/>
<template #tab-products>
<div class="space-y-6">
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<EmptyState
v-if="!structureProducts.length"
title="Aucun produit requis"
:description="selectedType ? 'Cette catégorie ne requiert pas de produit lié.' : 'Sélectionnez une catégorie pour voir les produits requis.'"
/>
</div>
</template>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
</div>
</template>
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/pieces" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -260,6 +289,7 @@ const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const { canEdit } = usePermissions()
const activeTab = ref('general')
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
@@ -382,6 +412,13 @@ const canSubmit = computed(() =>
),
)
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
@@ -508,5 +545,4 @@ watch(
onMounted(async () => {
await loadPieceTypes()
})
</script>

View File

@@ -1,257 +0,0 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des produits</h1>
<p class="text-sm text-base-content/70">
Retrouvez l'ensemble des produits du catalogue, leurs informations fournisseurs et leurs catégories.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/product/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un produit
</NuxtLink>
<NuxtLink to="/product-category" class="btn btn-outline btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div
v-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
<span class="font-semibold">Impossible de charger les produits</span>
<span class="text-sm">{{ errorMessage }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
Réessayer
</button>
</div>
<DataTable
v-else
:columns="columns"
:rows="productRows"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun produit n'a encore été enregistré."
no-results-message="Aucun produit ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product, true)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.product.name }}</span>
</template>
<template #cell-reference="{ row }">
{{ row.product.reference || '—' }}
</template>
<template #cell-typeProduct="{ row }">
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else class="text-sm text-base-content/50">—</span>
</template>
<template #cell-price="{ row }">
{{ formatPrice(row.product.supplierPrice) }}
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
<NuxtLink
:to="`/product/${row.product.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useHead } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
const { canEdit } = usePermissions()
useHead(() => ({ title: 'Catalogue des produits' }))
const {
products,
total,
loading,
error,
loadProducts,
deleteProduct,
} = useProducts()
const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const table = useDataTable(
{ fetchData: fetchProducts },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
]
const productsOnPage = computed(() => productRows.value.length)
const paginationState = table.pagination(total, productsOnPage)
// Enrich products with full type data
const normalizedProducts = computed(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
})
})
const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildProductSuppliersDisplay(product),
})),
)
async function fetchProducts() {
await loadProducts({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeProduct || undefined,
force: true,
})
}
const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') return '—'
const number = Number(value)
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()
const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => {
const productName = product?.name || 'ce produit'
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
if (!confirmed) return
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${productName}" supprimé`)
}
}
onMounted(async () => {
await Promise.all([fetchProducts(), loadProductTypes()])
})
</script>

View File

@@ -1,563 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="productDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du produit</p>
</div>
<div v-else-if="!product" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Produit introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du produit et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
>
</div>
</div>
<div v-if="structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce produit.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="loadProduct()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
const { canEdit } = usePermissions()
const versionRefreshKey = ref(0)
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
updateDocument,
} = useDocuments()
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const cfDefinitions = ref<any[]>([])
const cfValues = ref<any[]>([])
const entityId = computed(() => product.value?.id ?? null)
const {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
} = useCustomFieldInputs({
definitions: cfDefinitions,
values: cfValues,
entityType: 'product' as CustomFieldEntityType,
entityId,
})
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
supplierPrice: 'Prix fournisseur',
typeProduct: 'Catégorie',
constructeurIds: 'Fournisseurs',
}
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
cfDefinitions.value = nextStructure?.customFields ?? []
cfValues.value = Array.isArray(nextValues) ? nextValues : []
}
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
const loadProduct = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
product.value = null
loading.value = false
return
}
const result = await getProduct(id)
if (result.success && result.data) {
product.value = result.data
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
await loadProductType()
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
hydrateForm()
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else {
product.value = null
}
loading.value = false
}
const refreshDocuments = async () => {
if (!product.value?.id) {
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
if (result.success) {
productDocuments.value = Array.isArray(result.data) ? result.data : []
}
} finally {
loadingDocuments.value = false
}
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteProductDocument(documentId, { updateStore: false })
if (result.success) {
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
toast.showSuccess('Document supprimé')
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !product.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadProductDocuments(
{
files,
context: { productId: product.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
toast.showSuccess('Document(s) ajouté(s)')
} else if (result.error) {
toast.showError(result.error)
}
} finally {
uploadingDocuments.value = false
}
}
const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
return
}
}
if (!product.value?.typeProductId) {
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
return
}
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
}
}
const hydrateForm = () => {
if (!product.value) {
return
}
editionForm.name = product.value.name || ''
editionForm.reference = product.value.reference || ''
// Load constructeur links
fetchLinks('product', String(route.params.id)).then((links) => {
constructeurLinks.value = links
originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
? String(product.value.supplierPrice)
: ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
}
watch(
() => product.value?.documents,
(docs) => {
if (Array.isArray(docs)) {
productDocuments.value = docs
}
},
{ immediate: true },
)
const submitEdition = async () => {
if (!product.value) {
return
}
const payload: Record<string, any> = {
name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null,
}
const rawPrice = typeof editionForm.supplierPrice === 'string'
? editionForm.supplierPrice.trim()
: editionForm.supplierPrice
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
? Number.isNaN(Number(rawPrice))
? null
: String(Number(rawPrice))
: null
saving.value = true
try {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return
}
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Produit mis à jour avec succès')
versionRefreshKey.value++
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
} finally {
saving.value = false
}
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
onMounted(async () => {
await loadProduct()
})
</script>

View File

@@ -39,239 +39,294 @@
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/product-catalog"
back-link="/catalogues/produits"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<template v-if="isEditMode">
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product?.typeProduct?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.name }}
</div>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
<EntityTabs
v-model="activeTab"
:tabs="entityTabs"
aria-label="Sections du produit"
>
<div v-if="isEditMode || product.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.reference }}
</div>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
<div v-else class="flex flex-wrap gap-2">
<span
v-for="id in editionForm.constructeurIds"
:key="id"
class="badge badge-outline"
>
{{ getConstructeurById(id)?.name || id }}
</span>
</div>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix fournisseur (if value or edit mode) -->
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.supplierPrice }}
</div>
</div>
</div>
<!-- Structure preview (edit mode only) -->
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200 flex-1"
disabled
>
<NuxtLink
v-if="product?.typeProduct?.id"
:to="`/product-category/${product.typeProduct.id}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product?.typeProduct?.name || '—' }}
</p>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product.name }}
</p>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || product.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product.reference }}
</p>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
<div v-else class="flex flex-wrap gap-2">
<span
v-for="id in editionForm.constructeurIds"
:key="id"
class="badge badge-outline"
>
{{ getConstructeurById(id)?.name || id }}
</span>
</div>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix fournisseur (if value or edit mode) -->
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product.supplierPrice }}
</p>
</div>
</div>
<!-- Structure preview (edit mode only) -->
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<UsedInSection entity-type="products" :entity-id="product?.id ?? null" />
</div>
</template>
<template #tab-documents>
<div class="space-y-6">
<!-- Documents -->
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
/>
</template>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || productDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
<template #tab-custom-fields>
<div class="space-y-6">
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<p class="text-sm font-medium text-base-content py-1">
{{ field.value }}
</p>
</div>
</div>
</template>
</div>
<p v-else class="text-sm text-base-content/60">
Aucun champ personnalisé n'est défini pour ce produit.
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<template #tab-history>
<div class="space-y-6">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="loadProduct()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div>
</template>
</EntityTabs>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
@@ -286,16 +341,6 @@
<p v-if="isEditMode && product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
@@ -304,7 +349,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import { navigateTo, useRoute } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
@@ -314,7 +359,7 @@ import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { usePermissions } from '~/composables/usePermissions'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
@@ -326,7 +371,6 @@ import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const {
@@ -342,9 +386,10 @@ const {
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
} = useEntityHistory('product')
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
@@ -416,6 +461,18 @@ const visibleCustomFields = computed(() => {
)
})
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'documents', label: 'Documents', count: productDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
{ key: 'history', label: 'Historique' },
])
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) return
previewDocument.value = doc
@@ -609,6 +666,7 @@ const submitEdition = async () => {
toast.showSuccess('Produit mis à jour avec succès')
await loadProduct()
isEditMode.value = false
versionRefreshKey.value++
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')

View File

@@ -1,158 +1,175 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouveau produit"
subtitle="Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/produits"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections du produit">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<!-- Skeleton preview -->
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
</div>
</template>
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
</template>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
</p>
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/produits" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -203,6 +220,7 @@ const { canEdit } = usePermissions()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const activeTab = ref('general')
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
@@ -240,6 +258,12 @@ const selectedType = computed(() => {
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
watch(
() => route.query.typeId,
(value) => {
@@ -276,8 +300,6 @@ watch(selectedType, (type) => {
cfDefinitions.value = normalized?.customFields ?? []
})
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value &&

View File

@@ -15,6 +15,8 @@ export interface DataTableColumn {
headerClass?: string
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
width?: string
/** Inline min-width style (e.g. '120px', '8rem'). Only effective with fixedLayout. */
minWidth?: string
/** Text alignment: 'left' (default), 'center', 'right' */
align?: 'left' | 'center' | 'right'
/** Hide on mobile (adds 'hidden sm:table-cell') */

View File

@@ -5,6 +5,13 @@
* copy of extractCollection (parsing hydra:member / member / data / array).
*/
export function 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 extractCollection<T = any>(payload: unknown): T[] {
if (Array.isArray(payload)) return payload as T[]
const p = payload as Record<string, unknown> | null

View File

@@ -285,12 +285,14 @@ export function fieldKey(field: CustomFieldInput, index: number): string {
/** Whether a field should be persisted (non-empty value) */
export function shouldPersist(field: CustomFieldInput): boolean {
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
if (typeof field.value === 'number') return !Number.isNaN(field.value)
return typeof field.value === 'string' && field.value.trim() !== ''
}
/** Format value for save (trim, boolean coercion) */
export function formatValueForSave(field: CustomFieldInput): string {
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
if (typeof field.value === 'number') return String(field.value)
return typeof field.value === 'string' ? field.value.trim() : ''
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Re-link orphaned CustomField records to their ModelType.
*
* Legacy data created one CustomField definition per composant/piece instead
* of sharing a single definition on the ModelType. This migration:
* 1. Finds orphaned CustomField used by composants → elects one canonical per (ModelType, name)
* 2. Same for pieces
* 3. Re-points CustomFieldValue to the canonical
* 4. Sets typecomposantid / typepieceid on the canonical
* 5. Deletes now-unused duplicate CustomField rows
*/
final class Version20260404_RelinkOrphanedCustomFields extends AbstractMigration
{
public function getDescription(): string
{
return 'Re-link orphaned custom_fields to their model_types via composants and pieces';
}
public function up(Schema $schema): void
{
// ── COMPOSANTS ──────────────────────────────────────────────────
// For each (model_type, field_name) group of orphaned fields used by composants,
// pick the one with the smallest id as canonical, set its typecomposantid,
// re-point all values to it, then delete the duplicates.
$this->addSql(<<<'SQL'
-- Step 1: Create temp table with canonical mapping for COMPOSANT orphans
CREATE TEMP TABLE _cf_composant_canonical AS
SELECT DISTINCT ON (c.typecomposantid, cf.name)
cf.id AS canonical_id,
c.typecomposantid AS mt_id,
cf.name AS cf_name
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN composants c ON c.id = cfv.composantid
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND c.typecomposantid IS NOT NULL
ORDER BY c.typecomposantid, cf.name, cf.id;
SQL);
$this->addSql(<<<'SQL'
-- Step 2: Create temp table listing ALL orphaned composant field ids with their canonical
CREATE TEMP TABLE _cf_composant_all AS
SELECT cf.id AS orphan_id, canon.canonical_id
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN composants c ON c.id = cfv.composantid
INNER JOIN _cf_composant_canonical canon
ON canon.mt_id = c.typecomposantid AND canon.cf_name = cf.name
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND c.typecomposantid IS NOT NULL
AND cf.id != canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 3: Re-point custom_field_values from duplicates to canonical
UPDATE custom_field_values cfv
SET customfieldid = dup.canonical_id
FROM _cf_composant_all dup
WHERE cfv.customfieldid = dup.orphan_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 4: Set typecomposantid on canonical fields
UPDATE custom_fields cf
SET typecomposantid = canon.mt_id
FROM _cf_composant_canonical canon
WHERE cf.id = canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 5: Delete duplicate (non-canonical) custom_fields for composants
DELETE FROM custom_fields
WHERE id IN (SELECT orphan_id FROM _cf_composant_all);
SQL);
$this->addSql('DROP TABLE IF EXISTS _cf_composant_all');
$this->addSql('DROP TABLE IF EXISTS _cf_composant_canonical');
// ── PIECES ───────────<E29480><E29480><EFBFBD>────────────────────────────<E29480><E29480><EFBFBD>─────────────
$this->addSql(<<<'SQL'
CREATE TEMP TABLE _cf_piece_canonical AS
SELECT DISTINCT ON (p.typepieceid, cf.name)
cf.id AS canonical_id,
p.typepieceid AS mt_id,
cf.name AS cf_name
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN pieces p ON p.id = cfv.pieceid
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND p.typepieceid IS NOT NULL
ORDER BY p.typepieceid, cf.name, cf.id;
SQL);
$this->addSql(<<<'SQL'
CREATE TEMP TABLE _cf_piece_all AS
SELECT cf.id AS orphan_id, canon.canonical_id
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN pieces p ON p.id = cfv.pieceid
INNER JOIN _cf_piece_canonical canon
ON canon.mt_id = p.typepieceid AND canon.cf_name = cf.name
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND p.typepieceid IS NOT NULL
AND cf.id != canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
UPDATE custom_field_values cfv
SET customfieldid = dup.canonical_id
FROM _cf_piece_all dup
WHERE cfv.customfieldid = dup.orphan_id;
SQL);
$this->addSql(<<<'SQL'
UPDATE custom_fields cf
SET typepieceid = canon.mt_id
FROM _cf_piece_canonical canon
WHERE cf.id = canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
DELETE FROM custom_fields
WHERE id IN (SELECT orphan_id FROM _cf_piece_all);
SQL);
$this->addSql('DROP TABLE IF EXISTS _cf_piece_all');
$this->addSql('DROP TABLE IF EXISTS _cf_piece_canonical');
}
public function down(Schema $schema): void
{
// Data migration — not reversible. The old per-entity duplicates are gone.
$this->addSql('SELECT 1');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Migrate constructeur associations from legacy ManyToMany join tables
* (_composantconstructeurs, _piececonstructeurs, _machineconstructeurs, _productconstructeurs)
* to the new ConstructeurLink entity tables.
*/
final class Version20260405_MigrateConstructeurLinks extends AbstractMigration
{
public function getDescription(): string
{
return 'Migrate constructeur links from legacy M2M tables to new link entity tables';
}
public function up(Schema $schema): void
{
// No-op: legacy M2M tables (_composantconstructeurs, _piececonstructeurs, etc.)
// were already dropped by Version20260331121257.
// The actual data restoration is handled by Version20260405_RestoreConstructeurLinksFromBackup.
}
public function down(Schema $schema): void
{
// No-op
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Fallback migration: if the legacy M2M tables (_composantconstructeurs, _piececonstructeurs)
* no longer exist in production, this migration inserts the links directly from the backup (3) data.
*
* Run AFTER Version20260405_MigrateConstructeurLinks (which handles the case where legacy tables exist).
* This migration only inserts links that don't already exist (ON CONFLICT DO NOTHING).
*
* Note: Some entity IDs from the backup may no longer exist (deleted composants/pieces).
* The migration disables FK checks temporarily to avoid failures, then cleans up orphans.
*/
final class Version20260405_RestoreConstructeurLinksFromBackup extends AbstractMigration
{
public function getDescription(): string
{
return 'Restore constructeur links from backup (3) data — fallback if legacy M2M tables are gone';
}
public function up(Schema $schema): void
{
// Temporarily disable FK checks
$this->addSql('SET session_replication_role = replica');
// === COMPOSANT-CONSTRUCTEUR LINKS (5 from backup 3) ===
$this->addSql("
INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
VALUES
('clbkp3_cc_001', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc', NULL, NOW(), NOW()),
('clbkp3_cc_002', 'cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm', NULL, NOW(), NOW()),
('clbkp3_cc_003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, NOW(), NOW()),
('clbkp3_cc_004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_cc_005', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW())
ON CONFLICT (id) DO NOTHING
");
// === PIECE-CONSTRUCTEUR LINKS (25 from backup 3) ===
$this->addSql("
INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat)
VALUES
('clbkp3_pc_001', 'cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k', NULL, NOW(), NOW()),
('clbkp3_pc_002', 'cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_003', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic', NULL, NOW(), NOW()),
('clbkp3_pc_004', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3', NULL, NOW(), NOW()),
('clbkp3_pc_005', 'cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh', NULL, NOW(), NOW()),
('clbkp3_pc_006', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql', NULL, NOW(), NOW()),
('clbkp3_pc_007', 'cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic', NULL, NOW(), NOW()),
('clbkp3_pc_008', 'cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_009', 'cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_010', 'clcff0f15790b2c7084f781df6', 'cl219849fbab8bbaf6163f5700', NULL, NOW(), NOW()),
('clbkp3_pc_011', 'cl4807538979ddd27099d77578', 'cl219849fbab8bbaf6163f5700', NULL, NOW(), NOW()),
('clbkp3_pc_012', 'cl960c6ffcfabd9eeb2b1452ab', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_013', 'cl5b928245d51ff4f037f6cc6d', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_014', 'cl92edc1a20a7fd0f1355fd476', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_015', 'cl811abd3d9d8ba63585424906', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_016', 'clfd2b5e2570b0be44f7196870', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_017', 'cl0ba5ceffb2e5496624087d85', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_018', 'cl50fe870a07e42759b37b511f', 'cl219849fbab8bbaf6163f5700', NULL, NOW(), NOW()),
('clbkp3_pc_019', 'cl9de983224260763d7ea6fe95', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_020', 'clbd07ebf2568ea14ac792ba49', 'cmhaac3vo003547v7s1wv6jhv', NULL, NOW(), NOW()),
('clbkp3_pc_021', 'cl754c25154e5546882a7d6706', 'cmhaac3vo003547v7s1wv6jhv', NULL, NOW(), NOW()),
('clbkp3_pc_022', 'clc8fa00057b54d782c06aebd0', 'cmhaac3vo003547v7s1wv6jhv', NULL, NOW(), NOW()),
('clbkp3_pc_023', 'cl6480b97f6516fba22ce86434', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_pc_024', 'cl9579b05774d92096117841b0', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_025', 'cl5b02c64fcc5ae8a3bfb6e5e6', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW())
ON CONFLICT (id) DO NOTHING
");
// Note: 6 additional Limatech piece links from backup (3) used old Limatech ID 'cla14aa4a50a799c2e54391be7'.
// Limatech was recreated with a new ID. These links use the current Limatech ID (cmizv4lm500071e2w6xymi2p6)
// for the last 2 entries above. The remaining 4 Limatech links:
$this->addSql("
INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat)
VALUES
('clbkp3_pc_026', 'cle788fea147886d499676b745', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_027', 'clfd3cb41a407ab5a3f9d5baae', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_028', 'cld08dae22796b5855152580d9', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_029', 'clf6012fca41994c1e81ce2dba', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW())
ON CONFLICT (id) DO NOTHING
");
// Re-enable FK checks
$this->addSql('SET session_replication_role = DEFAULT');
// Clean up orphaned rows (entities that no longer exist)
$this->addSql("
DELETE FROM composant_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND composantid NOT IN (SELECT id FROM composants)
");
$this->addSql("
DELETE FROM piece_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND pieceid NOT IN (SELECT id FROM pieces)
");
$this->addSql("
DELETE FROM composant_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND constructeurid NOT IN (SELECT id FROM constructeurs)
");
$this->addSql("
DELETE FROM piece_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND constructeurid NOT IN (SELECT id FROM constructeurs)
");
}
public function down(Schema $schema): void
{
$this->addSql("DELETE FROM composant_constructeur_links WHERE id LIKE 'clbkp3_%'");
$this->addSql("DELETE FROM piece_constructeur_links WHERE id LIKE 'clbkp3_%'");
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur;
use App\Entity\MachineConstructeurLink;
use App\Entity\PieceConstructeurLink;
use App\Entity\ProductConstructeurLink;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ConstructeurStatsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/constructeurs/{id}/stats', name: 'api_constructeur_stats', methods: ['GET'])]
public function stats(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$constructeur = $this->em->find(Constructeur::class, $id);
if (!$constructeur) {
return new JsonResponse(
['message' => 'Fournisseur introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$bulk = $this->fetchAllStats();
return new JsonResponse($bulk[$id] ?? [
'composantCount' => 0,
'pieceCount' => 0,
'machineCount' => 0,
'productCount' => 0,
]);
}
#[Route('/api/constructeurs/stats', name: 'api_constructeurs_stats_bulk', methods: ['GET'], priority: 1)]
public function bulkStats(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
return new JsonResponse($this->fetchAllStats());
}
/** @return array<string, array{composantCount: int, pieceCount: int, machineCount: int, productCount: int}> */
private function fetchAllStats(): array
{
$result = [];
// Initialize all constructeurs with zero counts
$allIds = $this->em->createQuery(
'SELECT c.id FROM App\Entity\Constructeur c',
)->getSingleColumnResult();
foreach ($allIds as $id) {
$result[$id] = [
'composantCount' => 0,
'pieceCount' => 0,
'machineCount' => 0,
'productCount' => 0,
];
}
// 4 bulk queries instead of 4N
$counts = [
'composantCount' => ComposantConstructeurLink::class,
'pieceCount' => PieceConstructeurLink::class,
'machineCount' => MachineConstructeurLink::class,
'productCount' => ProductConstructeurLink::class,
];
foreach ($counts as $key => $entityClass) {
$rows = $this->em->createQuery(
'SELECT IDENTITY(l.constructeur) AS cid, COUNT(l.id) AS cnt FROM '.$entityClass.' l GROUP BY l.constructeur',
)->getResult();
foreach ($rows as $row) {
if (isset($result[$row['cid']])) {
$result[$row['cid']][$key] = (int) $row['cnt'];
}
}
}
return $result;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\Piece;
use App\Entity\Product;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/model_types/{id}')]
final class ModelTypeRelatedItemsController extends AbstractController
{
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly EntityManagerInterface $em,
) {}
#[Route('/related-items', name: 'api_model_type_related_items', methods: ['GET'])]
public function relatedItems(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$items = match ($modelType->getCategory()) {
ModelCategory::COMPONENT => $this->fetchComposantsWithMachineCount($id),
ModelCategory::PIECE => $this->fetchPiecesWithMachineCount($id),
ModelCategory::PRODUCT => $this->fetchProductsWithMachineCount($id),
};
return new JsonResponse($items);
}
/**
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function fetchComposantsWithMachineCount(string $modelTypeId): array
{
$qb = $this->em->createQueryBuilder();
$qb->select(
'c.id',
'c.name',
'c.reference',
'COALESCE(c.referenceAuto, c.reference) as displayReference',
'COUNT(DISTINCT mcl.id) as machineCount',
)
->from(Composant::class, 'c')
->leftJoin(MachineComponentLink::class, 'mcl', 'WITH', 'mcl.composant = c')
->where('c.typeComposant = :modelTypeId')
->setParameter('modelTypeId', $modelTypeId)
->groupBy('c.id', 'c.name', 'c.reference', 'c.referenceAuto')
->orderBy('c.name', 'ASC')
;
return $this->formatResults($qb->getQuery()->getArrayResult());
}
/**
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function fetchPiecesWithMachineCount(string $modelTypeId): array
{
$qb = $this->em->createQueryBuilder();
$qb->select(
'p.id',
'p.name',
'p.reference',
'COALESCE(p.referenceAuto, p.reference) as displayReference',
'COUNT(DISTINCT mpl.id) as machineCount',
)
->from(Piece::class, 'p')
->leftJoin(MachinePieceLink::class, 'mpl', 'WITH', 'mpl.piece = p')
->where('p.typePiece = :modelTypeId')
->setParameter('modelTypeId', $modelTypeId)
->groupBy('p.id', 'p.name', 'p.reference', 'p.referenceAuto')
->orderBy('p.name', 'ASC')
;
return $this->formatResults($qb->getQuery()->getArrayResult());
}
/**
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function fetchProductsWithMachineCount(string $modelTypeId): array
{
$qb = $this->em->createQueryBuilder();
$qb->select(
'pr.id',
'pr.name',
'pr.reference',
'COUNT(DISTINCT mpl.id) as machineCount',
)
->from(Product::class, 'pr')
->leftJoin(MachineProductLink::class, 'mpl', 'WITH', 'mpl.product = pr')
->where('pr.typeProduct = :modelTypeId')
->setParameter('modelTypeId', $modelTypeId)
->groupBy('pr.id', 'pr.name', 'pr.reference')
->orderBy('pr.name', 'ASC')
;
return $this->formatResults($qb->getQuery()->getArrayResult());
}
/**
* @param array<int, array<string, mixed>> $rows
*
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function formatResults(array $rows): array
{
return array_values(array_map(
static fn (array $row): array => [
'id' => (string) $row['id'],
'name' => (string) ($row['name'] ?? ''),
'reference' => isset($row['displayReference']) && '' !== $row['displayReference']
? (string) $row['displayReference']
: (isset($row['reference']) && '' !== $row['reference'] ? (string) $row['reference'] : null),
'machineCount' => (int) $row['machineCount'],
],
$rows,
));
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\PieceProductSlot;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class UsedInController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/composants/{id}/used-in', name: 'api_composant_used_in', methods: ['GET'])]
public function composantUsedIn(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
return new JsonResponse([
'machines' => $this->findMachinesUsingComposant($id),
]);
}
#[Route('/api/pieces/{id}/used-in', name: 'api_piece_used_in', methods: ['GET'])]
public function pieceUsedIn(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
return new JsonResponse([
'machines' => $this->findMachinesUsingPiece($id),
'composants' => $this->findComposantsUsingPiece($id),
]);
}
#[Route('/api/products/{id}/used-in', name: 'api_product_used_in', methods: ['GET'])]
public function productUsedIn(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
return new JsonResponse([
'machines' => $this->findMachinesUsingProduct($id),
'composants' => $this->findComposantsUsingProduct($id),
'pieces' => $this->findPiecesUsingProduct($id),
]);
}
/**
* @return list<array{id: string, name: null|string, site: null|array{id: string, name: null|string}}>
*/
private function findMachinesUsingComposant(string $id): array
{
$qb = $this->em->createQueryBuilder();
$qb->select('m.id', 'm.name', 's.id as siteId', 's.name as siteName')
->from(MachineComponentLink::class, 'mcl')
->join('mcl.machine', 'm')
->leftJoin('m.site', 's')
->where('mcl.composant = :id')
->setParameter('id', $id)
->groupBy('m.id', 'm.name', 's.id', 's.name')
->orderBy('m.name', 'ASC')
;
return $this->formatMachineResults($qb->getQuery()->getArrayResult());
}
/**
* @return list<array{id: string, name: null|string, site: null|array{id: string, name: null|string}}>
*/
private function findMachinesUsingPiece(string $id): array
{
$qb = $this->em->createQueryBuilder();
$qb->select('m.id', 'm.name', 's.id as siteId', 's.name as siteName')
->from(MachinePieceLink::class, 'mpl')
->join('mpl.machine', 'm')
->leftJoin('m.site', 's')
->where('mpl.piece = :id')
->setParameter('id', $id)
->groupBy('m.id', 'm.name', 's.id', 's.name')
->orderBy('m.name', 'ASC')
;
return $this->formatMachineResults($qb->getQuery()->getArrayResult());
}
/**
* @return list<array{id: string, name: null|string, site: null|array{id: string, name: null|string}}>
*/
private function findMachinesUsingProduct(string $id): array
{
$qb = $this->em->createQueryBuilder();
$qb->select('m.id', 'm.name', 's.id as siteId', 's.name as siteName')
->from(MachineProductLink::class, 'mpl')
->join('mpl.machine', 'm')
->leftJoin('m.site', 's')
->where('mpl.product = :id')
->setParameter('id', $id)
->groupBy('m.id', 'm.name', 's.id', 's.name')
->orderBy('m.name', 'ASC')
;
return $this->formatMachineResults($qb->getQuery()->getArrayResult());
}
/**
* @return list<array{id: string, name: null|string}>
*/
private function findComposantsUsingPiece(string $id): array
{
$qb = $this->em->createQueryBuilder();
$qb->select('c.id', 'c.name')
->from(ComposantPieceSlot::class, 'cps')
->join('cps.composant', 'c')
->where('cps.selectedPiece = :id')
->setParameter('id', $id)
->groupBy('c.id', 'c.name')
->orderBy('c.name', 'ASC')
;
return array_values(array_map(
static fn (array $row): array => [
'id' => $row['id'],
'name' => $row['name'],
],
$qb->getQuery()->getArrayResult(),
));
}
/**
* @return list<array{id: string, name: null|string}>
*/
private function findComposantsUsingProduct(string $id): array
{
$qb = $this->em->createQueryBuilder();
$qb->select('c.id', 'c.name')
->from(ComposantProductSlot::class, 'cps')
->join('cps.composant', 'c')
->where('cps.selectedProduct = :id')
->setParameter('id', $id)
->groupBy('c.id', 'c.name')
->orderBy('c.name', 'ASC')
;
return array_values(array_map(
static fn (array $row): array => [
'id' => $row['id'],
'name' => $row['name'],
],
$qb->getQuery()->getArrayResult(),
));
}
/**
* @return list<array{id: string, name: null|string}>
*/
private function findPiecesUsingProduct(string $id): array
{
$qb = $this->em->createQueryBuilder();
$qb->select('p.id', 'p.name')
->from(PieceProductSlot::class, 'pps')
->join('pps.piece', 'p')
->where('pps.selectedProduct = :id')
->setParameter('id', $id)
->groupBy('p.id', 'p.name')
->orderBy('p.name', 'ASC')
;
return array_values(array_map(
static fn (array $row): array => [
'id' => $row['id'],
'name' => $row['name'],
],
$qb->getQuery()->getArrayResult(),
));
}
/**
* @param array<int, array<string, mixed>> $rows
*
* @return list<array{id: string, name: null|string, site: null|array{id: string, name: null|string}}>
*/
private function formatMachineResults(array $rows): array
{
return array_values(array_map(
static fn (array $row): array => [
'id' => $row['id'],
'name' => $row['name'],
'site' => $row['siteId']
? ['id' => $row['siteId'], 'name' => $row['siteName']]
: null,
],
$rows,
));
}
}