53 KiB
Detail Views for Piece, Composant, Product — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add consultation (read-only detail) pages for Piece, Composant, and Product with edit/view toggle, matching the existing Machine detail pattern.
Architecture: Each entity gets a new page (/piece/[id].vue, /component/[id].vue, /product/[id].vue) that reuses the existing edit composables but adds a isEditMode toggle. In view mode: text display only, empty fields hidden, documents section hidden if empty. In edit mode: existing form UI. Catalogs and cross-links updated to point to detail pages instead of edit pages.
Tech Stack: Nuxt 4, Vue 3 Composition API, DaisyUI 5, TailwindCSS 4
File Map
| Action | File | Responsibility |
|---|---|---|
| Create | app/pages/piece/[id].vue |
Piece detail page with edit/view toggle |
| Create | app/pages/component/[id].vue |
Component detail page with edit/view toggle |
| Create | app/pages/product/[id].vue |
Product detail page with edit/view toggle |
| Create | app/components/DetailHeader.vue |
Shared header with title + edit/view toggle button (reusable across all 3 entities) |
| Modify | app/pages/pieces-catalog.vue |
Link to /piece/{id} instead of /pieces/{id}/edit + add "Détails" button |
| Modify | app/pages/component-catalog.vue |
Link to /component/{id} instead of /component/{id}/edit + add "Détails" button |
| Modify | app/pages/product-catalog.vue |
Link to /product/{id} instead of /product/{id}/edit + add "Détails" button |
| Modify | app/pages/comments.vue |
Update entity URL map to use detail pages |
| Modify | app/components/PieceItem.vue:183 |
Update "Ouvrir la fiche produit" link |
| Modify | app/components/ComponentItem.vue:130 |
Update "Voir le produit" link |
| Keep | app/pages/pieces/[id]/edit.vue |
Keep existing edit page (still works, redirects optional later) |
| Keep | app/pages/component/[id]/edit.vue |
Keep existing edit page |
| Keep | app/pages/product/[id]/edit.vue |
Keep existing edit page |
Patterns to Follow (from Machine detail)
View/Edit toggle:
<!-- Input in edit mode, text in view mode -->
<input v-if="isEditMode" v-model="form.name" class="input input-bordered" />
<div v-else class="text-base-content">{{ entity.name }}</div>
Hide empty fields in view mode:
<div v-if="isEditMode || entity.reference" class="form-control">
<!-- field content -->
</div>
Hide documents section if empty:
<div v-if="isEditMode || documents.length > 0">
<!-- documents section -->
</div>
Query param for direct edit:
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
Catalog buttons pattern (like machines/index.vue):
<button v-if="canEdit" class="btn btn-ghost btn-xs" @click="navigateTo(`/piece/${id}?edit=true`)">
Modifier
</button>
<NuxtLink :to="`/piece/${id}`" class="btn btn-primary btn-xs">
Détails
</NuxtLink>
Task 1: Create shared DetailHeader component
Files:
-
Create:
app/components/DetailHeader.vue -
Step 1: Create DetailHeader.vue
Based on app/components/machine/MachineDetailHeader.vue pattern. Shared across piece/composant/product.
<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>
<p v-if="subtitle" class="text-sm text-base-content/70">{{ subtitle }}</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="canEdit"
class="btn btn-primary"
:class="{ 'btn-outline': isEditMode }"
@click="$emit('toggle-edit')"
>
<IconLucideSquarePen 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>
<NuxtLink :to="backLink" class="btn btn-ghost btn-sm md:btn-md">
Retour au catalogue
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
defineProps<{
title: string
subtitle?: string
isEditMode: boolean
canEdit: boolean
backLink: string
}>()
defineEmits<{
'toggle-edit': []
}>()
</script>
- Step 2: Commit
git add app/components/DetailHeader.vue
git commit -m "feat(detail) : add shared DetailHeader component for entity detail pages"
Task 2: Create Piece detail page
Files:
-
Create:
app/pages/piece/[id].vue -
Reference:
app/pages/pieces/[id]/edit.vue(existing edit page) -
Reference:
app/composables/usePieceEdit.ts(reuse) -
Step 1: Create
/piece/[id].vue
This page reuses usePieceEdit composable and adds isEditMode toggle. In view mode, fields are displayed as text; empty fields are hidden. Documents section hidden if empty. Edit mode shows the full form.
Key sections in view mode (only shown if value exists):
- Catégorie — always shown (always has a value)
- Nom — always shown
- Description —
v-if="isEditMode || piece?.description" - Référence —
v-if="isEditMode || editionForm.reference" - Fournisseurs —
v-if="isEditMode || editionForm.constructeurIds.length" - Prix —
v-if="isEditMode || editionForm.prix" - Produits requis (skeleton) —
v-if="isEditMode || structureProducts.length" - Skeleton preview —
v-if="isEditMode || resolvedStructure" - Champs personnalisés —
v-if="isEditMode || customFieldInputs.length", inside: each field hidden if no value in view mode - Documents —
v-if="isEditMode || pieceDocuments.length > 0" - Historique — always shown
- Commentaires — always shown
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
v-if="isEditMode"
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<!-- Loading -->
<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>
<!-- Not found -->
<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>
<!-- Content -->
<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">
<DetailHeader
:title="isEditMode ? 'Modifier la pièce' : 'Détails de la pièce'"
: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"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always visible, read-only) -->
<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="text-base-content font-medium">{{ selectedType?.name || piece?.typePiece?.name || '—' }}</div>
</div>
</div>
<!-- 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-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="text-base-content font-medium">{{ piece.name }}</div>
</div>
</div>
<!-- Description -->
<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="text-base-content/80 whitespace-pre-line">{{ piece.description }}</div>
</div>
<!-- Référence + Fournisseurs -->
<div v-if="isEditMode || editionForm.reference || editionForm.constructeurIds.length" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-if="isEditMode || editionForm.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="text-base-content">{{ editionForm.reference }}</div>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.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="piece?.constructeurs || []" />
<div v-else class="text-base-content">
<span v-for="(c, i) in (piece?.constructeurs || [])" :key="c.id">{{ c.name }}<span v-if="i < (piece?.constructeurs || []).length - 1">, </span></span>
</div>
</div>
</div>
<!-- Prix -->
<div v-if="isEditMode || editionForm.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="text-base-content">{{ editionForm.prix }} €</div>
</div>
</div>
<!-- Produits requis par le squelette -->
<div v-if="isEditMode && 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="`req-${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>
<!-- Skeleton preview -->
<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" />
<!-- Champs personnalisés -->
<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>
<CustomFieldInputGrid v-if="isEditMode" :fields="customFieldInputs" :disabled="!canEdit || saving" />
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div v-for="field in visibleCustomFields" :key="field.name" class="form-control">
<label class="label"><span class="label-text text-xs font-medium">{{ field.label }}</span></label>
<div class="text-base-content">{{ field.value }}</div>
</div>
</div>
</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 v-if="isEditMode" class="text-xs text-base-content/70">Gérez les 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' : '' }}
</span>
</header>
<div v-if="isEditMode" :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="isEditMode && canEdit"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<!-- Historique -->
<EntityHistorySection :entries="history" :loading="historyLoading" :error="historyError" :field-labels="historyFieldLabels" />
<!-- Save buttons (edit mode only) -->
<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
</button>
<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 { computed, ref, onMounted } from 'vue'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
const route = useRoute()
const { canEdit } = usePermissions()
const isEditMode = ref(false)
const { updateDocument } = useDocuments()
const {
piece, loading, saving, selectedFiles, uploadingDocuments, loadingDocuments,
pieceDocuments, previewDocument, previewVisible, selectedTypeId, editionForm,
productSelections, customFieldInputs, canSubmit, pieceTypeList, selectedType,
resolvedStructure, structureProducts, productRequirementDescriptions,
productRequirementEntries, historyFieldLabels, history, historyLoading,
historyError, openPreview, closePreview, removeDocument, handleFilesAdded,
setProductSelection, submitEdition, formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
// Custom fields: in view mode, only show fields that have a value
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(f => f.value !== null && f.value !== undefined && f.value !== '')
})
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
}
onMounted(() => {
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>
- Step 2: Commit
git add app/pages/piece/\[id\].vue
git commit -m "feat(piece) : add piece detail page with edit/view toggle"
Task 3: Create Component detail page
Files:
-
Create:
app/pages/component/[id].vue -
Reference:
app/pages/component/[id]/edit.vue(existing edit page) -
Reference:
app/composables/useComponentEdit.ts(reuse) -
Step 1: Create
/component/[id].vue
Same pattern as piece. The component has additional slot sections (pieces, products, subcomponents). In view mode, show selected slot values as text; hide empty slots.
IMPORTANT: This file path will conflict with /component/[id]/edit.vue in Nuxt routing. Nuxt 4 handles [id].vue and [id]/edit.vue correctly as long as they're at the same level — [id].vue is the index page, [id]/edit.vue is a sub-route. The new page should be at app/pages/component/[id]/index.vue to coexist with edit.vue.
Wait — looking at the current structure: app/pages/component/[id]/edit.vue exists. We need to create app/pages/component/[id]/index.vue for the detail view to work alongside it.
Similarly for product: app/pages/product/[id]/edit.vue exists, so we need app/pages/product/[id]/index.vue.
For piece: app/pages/pieces/[id]/edit.vue exists but the detail page route is /piece/[id] (singular). This is a different route prefix so no conflict — app/pages/piece/[id].vue works.
Updated file paths:
- Piece:
app/pages/piece/[id].vue(new route/piece/:id, no conflict with/pieces/:id/edit) - Component:
app/pages/component/[id]/index.vue(route/component/:id, coexists with/component/:id/edit) - Product:
app/pages/product/[id]/index.vue(route/product/:id, coexists with/product/:id/edit)
The component detail page follows the same pattern as piece but with additional skeleton slot sections.
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<DocumentEditModal
v-if="isEditMode"
: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">
<DetailHeader
:title="isEditMode ? 'Modifier le composant' : 'Détails du composant'"
:subtitle="isEditMode ? 'Mettez à jour les informations du composant et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/component-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- 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>
<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="text-base-content font-medium">{{ selectedType?.name || component?.typeComposant?.name || '—' }}</div>
</div>
</div>
<!-- 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-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="text-base-content font-medium">{{ component.name }}</div>
</div>
</div>
<!-- Description -->
<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="text-base-content/80 whitespace-pre-line">{{ component.description }}</div>
</div>
<!-- Référence + Fournisseurs -->
<div v-if="isEditMode || editionForm.reference || editionForm.constructeurIds.length" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-if="isEditMode || editionForm.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="text-base-content">{{ editionForm.reference }}</div>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.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 v-else class="text-base-content">
<span v-for="(c, i) in (component?.constructeurs || [])" :key="c.id">{{ c.name }}<span v-if="i < (component?.constructeurs || []).length - 1">, </span></span>
</div>
</div>
</div>
<!-- Prix -->
<div v-if="isEditMode || editionForm.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="text-base-content">{{ editionForm.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="isEditMode && (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">{{ 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">{{ 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">{{ 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>
<!-- Champs personnalisés -->
<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>
<CustomFieldInputGrid v-if="isEditMode" :fields="customFieldInputs" :disabled="!canEdit || saving" />
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div v-for="field in visibleCustomFields" :key="field.name" class="form-control">
<label class="label"><span class="label-text text-xs font-medium">{{ field.label }}</span></label>
<div class="text-base-content">{{ field.value }}</div>
</div>
</div>
</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 v-if="isEditMode" class="text-xs text-base-content/70">Gérez les 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' : '' }}
</span>
</header>
<div v-if="isEditMode" :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="isEditMode && canEdit"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<!-- Historique -->
<EntityHistorySection :entries="history" :loading="historyLoading" :error="historyError" :field-labels="historyFieldLabels" />
<!-- Save buttons (edit mode only) -->
<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</button>
<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="composant" :entity-id="String(route.params.id)" :entity-name="component?.name" show-resolved />
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
const route = useRoute()
const { canEdit } = usePermissions()
const isEditMode = ref(false)
const { updateDocument } = useDocuments()
const {
component, loading, saving, selectedFiles, uploadingDocuments, loadingDocuments,
componentDocuments, previewDocument, previewVisible, selectedTypeId, editionForm,
customFieldInputs, historyFieldLabels, canSubmit, componentTypeList, selectedType,
selectedTypeStructure, pieceSlotEntries, productSlotEntries, subcomponentSlotEntries,
history, historyLoading, historyError, openPreview, closePreview, removeDocument,
handleFilesAdded, submitEdition, setSlotQuantity, setPieceSlotSelection,
setProductSlotSelection, setSubcomponentSlotSelection, resolvePieceLabel,
resolveProductLabel, resolveSubcomponentLabel, formatStructurePreview,
} = useComponentEdit(String(route.params.id))
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(f => f.value !== null && f.value !== undefined && f.value !== '')
})
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
}
onMounted(() => {
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>
- Step 2: Commit
git add app/pages/component/\[id\]/index.vue
git commit -m "feat(composant) : add component detail page with edit/view toggle"
Task 4: Create Product detail page
Files:
-
Create:
app/pages/product/[id]/index.vue -
Reference:
app/pages/product/[id]/edit.vue(existing — all logic inline, no composable) -
Step 1: Create
/product/[id]/index.vue
Product has no composable — logic is inline in the edit page. We need to extract the data loading parts. To keep things simple, we'll duplicate the necessary data loading (loadProduct, hydrate) in this page as well, keeping it self-contained.
The product page is simpler than component — no skeleton slots, just: category, name, reference, suppliers, supplier price, structure preview, custom fields, documents, history, comments.
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="productDocuments"
@close="closePreview"
/>
<DocumentEditModal
v-if="isEditMode"
: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">
<DetailHeader
:title="isEditMode ? 'Modifier le produit' : 'Détails du produit'"
:subtitle="isEditMode ? 'Mettez à jour les informations du produit et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/product-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- 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>
<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="text-base-content font-medium">{{ product?.typeProduct?.name || '—' }}</div>
</div>
</div>
<!-- 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-if="isEditMode" v-model="editionForm.name" type="text" class="input input-bordered input-sm md:input-md" :disabled="!canEdit || saving" required>
<div v-else class="text-base-content font-medium">{{ product.name }}</div>
</div>
</div>
<!-- Référence + Fournisseurs -->
<div v-if="isEditMode || editionForm.reference || editionForm.constructeurIds.length" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-if="isEditMode || editionForm.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">
<div v-else class="text-base-content">{{ editionForm.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="text-base-content">
<span v-for="(c, i) in (product?.constructeurs || [])" :key="c.id">{{ c.name }}<span v-if="i < (product?.constructeurs || []).length - 1">, </span></span>
</div>
</div>
</div>
<!-- Prix fournisseur -->
<div v-if="isEditMode || editionForm.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">
<div v-else class="text-base-content">{{ editionForm.supplierPrice }} €</div>
</div>
</div>
<!-- Structure preview (edit 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>
<!-- Champs personnalisés -->
<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>
<CustomFieldInputGrid v-if="isEditMode" :fields="customFieldInputs" :disabled="!canEdit || saving" />
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div v-for="field in visibleCustomFields" :key="field.name" class="form-control">
<label class="label"><span class="label-text text-xs font-medium">{{ field.label }}</span></label>
<div class="text-base-content">{{ field.value }}</div>
</div>
</div>
</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 v-if="isEditMode" class="text-xs text-base-content/70">Gérez les documents associés à ce produit.</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="isEditMode" :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="isEditMode && canEdit"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<!-- Historique -->
<EntityHistorySection :entries="history" :loading="historyLoading" :error="historyError" :field-labels="historyFieldLabels" />
<!-- Save buttons (edit mode only) -->
<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</button>
<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="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>
</div>
</template>
<script setup lang="ts">
// Script is identical to product/[id]/edit.vue but with isEditMode toggle added
// and visibleCustomFields computed for view mode filtering
// Copy the full script from edit.vue, add:
// const isEditMode = ref(false)
// const visibleCustomFields = computed(...)
// onMounted: check route.query.edit === 'true'
</script>
NOTE: The script section copies the inline logic from product/[id]/edit.vue since there's no composable. The key additions are:
-
const isEditMode = ref(false) -
const visibleCustomFields = computed(() => isEditMode.value ? customFieldInputs.value : customFieldInputs.value.filter(f => f.value !== null && f.value !== undefined && f.value !== '')) -
In
onMounted:if (route.query.edit === 'true' && canEdit.value) isEditMode.value = true -
Step 2: Commit
git add app/pages/product/\[id\]/index.vue
git commit -m "feat(product) : add product detail page with edit/view toggle"
Task 5: Update catalog pages and cross-links
Files:
-
Modify:
app/pages/pieces-catalog.vue:120-138 -
Modify:
app/pages/component-catalog.vue:97-115 -
Modify:
app/pages/product-catalog.vue:117-134 -
Modify:
app/pages/comments.vue:245-248 -
Modify:
app/components/PieceItem.vue:183 -
Modify:
app/components/ComponentItem.vue:130 -
Modify:
app/composables/useComponentCreate.ts:347 -
Modify:
app/pages/pieces/create.vue:469 -
Modify:
app/pages/product/create.vue:333,355 -
Step 1: Update pieces-catalog.vue
Change the actions cell to have "Détails" (primary) + "Modifier" (ghost, with ?edit=true) + "Supprimer":
<!-- Replace existing cell-actions 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>
- Step 2: Update component-catalog.vue
Same pattern, using /component/${id} route:
<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>
- Step 3: Update product-catalog.vue
Same pattern, using /product/${id} route:
<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>
- Step 4: Update comments.vue entity URL map
// Change lines 245-248
machine: (id: string) => `/machine/${id}`,
piece: (id: string) => `/piece/${id}`,
composant: (id: string) => `/component/${id}`,
product: (id: string) => `/product/${id}`,
- Step 5: Update PieceItem.vue cross-link
<!-- Line 183: change /product/${id}/edit to /product/${id} -->
:to="`/product/${selectedProduct.id}`"
- Step 6: Update ComponentItem.vue cross-link
<!-- Line 130: change /product/${id}/edit to /product/${id} -->
:to="`/product/${component.product.id}`"
- Step 7: Update create page redirects
After creating an entity, redirect to the detail page with ?edit=true:
// useComponentCreate.ts:347 — change to:
await router.replace(`/component/${createdComponent.id}?edit=true`)
// pieces/create.vue:469 — change to:
await router.replace(`/piece/${createdPiece.id}?edit=true`)
// product/create.vue:333 — change to:
await router.replace(`/product/${result.data.id}?edit=true`)
// product/create.vue:355 — change to:
await router.replace(`/product/${productId}?edit=true`)
- Step 8: Commit
git add -A
git commit -m "feat(detail) : update catalogs and cross-links to use detail pages"
Task 6: Lint and typecheck
- Step 1: Run lint
cd frontend && npm run lint:fix
- Step 2: Run typecheck
cd frontend && npx nuxi typecheck
Fix any errors found.
- Step 3: Commit fixes if any
git add -A
git commit -m "fix(detail) : lint and typecheck fixes"
Task 7: Manual verification
- Step 1: Verify routes
Open browser and verify:
-
/piece/{id}— shows consultation view -
/piece/{id}?edit=true— shows edit mode -
/component/{id}— shows consultation view -
/component/{id}?edit=true— shows edit mode -
/product/{id}— shows consultation view -
/product/{id}?edit=true— shows edit mode -
Step 2: Verify catalogs
Check that catalogs have "Détails" (primary) and "Modifier" buttons:
-
/pieces-catalog -
/component-catalog -
/product-catalog -
Step 3: Verify empty field hiding
In consultation mode, fields with no value should not appear.
- Step 4: Verify documents section
In consultation mode, documents section should be hidden when no documents exist.