Files
Inventory_frontend/app/pages/pieces-catalog.vue
Matthieu 6f1bac381d refacto(tables) : composant DataTable global + migration de toutes les tables
- Nouveau composant DataTable réutilisable avec tri par en-têtes, pagination, filtres colonnes
- Nouveau composable useDataTable (sort/page/search/perPage/columnFilters + persistance URL)
- Migration des 9 tables : constructeurs, comments, admin, pieces-catalog, component-catalog, product-catalog, documents, activity-log, ManagementView (catégories)
- Filtres "Type de" server-side (ipartial) pour pièces, composants, produits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:05:00 +01:00

322 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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-description="{ row }">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
</div>
</div>
<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 gap-2">
<NuxtLink
:to="`/pieces/${row.piece.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
const { showError } = useToast()
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value)
const table = useDataTable(
{ fetchData: fetchPieces },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
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 resolvePrimaryDocument = (piece: Record<string, any>) => {
const documents = Array.isArray(piece?.documents) ? piece.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (piece: Record<string, any>) => {
const parts = [piece?.name, piece?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const MAX_VISIBLE_SUPPLIERS = 3
const resolvePieceSuppliers = (piece: Record<string, any>) => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(piece?.constructeurs)
collectConstructeurs(piece?.constructeur)
collectConstructeurs(piece?.product?.constructeurs)
collectConstructeurs(piece?.product?.constructeur)
collectFromLabel(piece?.constructeursLabel)
collectFromLabel(piece?.supplierLabel)
collectFromLabel(piece?.product?.constructeursLabel)
collectFromLabel(piece?.product?.supplierLabel)
return names
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
const suppliers = resolvePieceSuppliers(piece)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}
const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks) ? piece.machineLinks.length : piece?.machineLinksCount ?? 0
const documents = Array.isArray(piece?.documents) ? piece.documents.length : piece?.documentsCount ?? 0
const customFields = Array.isArray(piece?.customFieldValues) ? piece.customFieldValues.length : piece?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const handleDeletePiece = async (piece: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
if (blockingReasons.length) {
showError(`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const pieceName = piece?.name || 'cette pièce'
const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`]
if (hasCustomFields) {
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) return
await deletePiece(piece.id)
fetchPieces()
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
}
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])
})
</script>