462 lines
14 KiB
Vue
462 lines
14 KiB
Vue
<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>
|
||
|
||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||
<label class="w-full sm:w-72">
|
||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||
<input
|
||
v-model="searchTerm"
|
||
type="text"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="Nom ou référence…"
|
||
@input="debouncedSearch"
|
||
/>
|
||
</label>
|
||
<div class="flex items-center gap-2">
|
||
<label
|
||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||
for="piece-catalog-sort"
|
||
>
|
||
Trier par
|
||
</label>
|
||
<select
|
||
id="piece-catalog-sort"
|
||
v-model="sortField"
|
||
class="select select-bordered select-sm"
|
||
@change="handleSortChange"
|
||
>
|
||
<option value="name">Nom</option>
|
||
<option value="createdAt">Date de création</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<label
|
||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||
for="piece-catalog-dir"
|
||
>
|
||
Ordre
|
||
</label>
|
||
<select
|
||
id="piece-catalog-dir"
|
||
v-model="sortDirection"
|
||
class="select select-bordered select-sm"
|
||
@change="handleSortChange"
|
||
>
|
||
<option value="asc">Ascendant</option>
|
||
<option value="desc">Descendant</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<label
|
||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||
for="piece-catalog-per-page"
|
||
>
|
||
Par page
|
||
</label>
|
||
<select
|
||
id="piece-catalog-per-page"
|
||
v-model.number="itemsPerPage"
|
||
class="select select-bordered select-sm"
|
||
@change="handlePerPageChange"
|
||
>
|
||
<option :value="20">20</option>
|
||
<option :value="50">50</option>
|
||
<option :value="100">100</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-base-content/50 lg:text-right">
|
||
{{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="loadingPieces" class="flex justify-center py-8">
|
||
<span class="loading loading-spinner" aria-hidden="true" />
|
||
</div>
|
||
|
||
<p v-else-if="!piecesTotal" class="text-sm text-base-content/70">
|
||
Aucune pièce n'a encore été créée.
|
||
</p>
|
||
|
||
<p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
|
||
Aucune pièce ne correspond à votre recherche.
|
||
</p>
|
||
|
||
<template v-else>
|
||
<div class="overflow-x-auto">
|
||
<table class="table table-sm md:table-md">
|
||
<thead>
|
||
<tr>
|
||
<th class="w-24">Aperçu</th>
|
||
<th>Nom</th>
|
||
<th>Référence</th>
|
||
<th>Fournisseurs</th>
|
||
<th>Type de pièce</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in pieceRows" :key="row.piece.id">
|
||
<td class="align-middle">
|
||
<DocumentThumbnail
|
||
:document="resolvePrimaryDocument(row.piece)"
|
||
:alt="resolvePreviewAlt(row.piece)"
|
||
/>
|
||
</td>
|
||
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
|
||
<td>{{ row.piece.reference || '—' }}</td>
|
||
<td>
|
||
<div
|
||
v-if="row.suppliers.visible.length"
|
||
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||
:title="row.suppliers.tooltip"
|
||
>
|
||
<span
|
||
v-for="supplier in row.suppliers.visible"
|
||
:key="supplier"
|
||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||
>
|
||
{{ supplier }}
|
||
</span>
|
||
<span
|
||
v-if="row.suppliers.overflow"
|
||
class="badge badge-outline badge-sm"
|
||
>
|
||
+{{ row.suppliers.overflow }}
|
||
</span>
|
||
</div>
|
||
<span v-else>—</span>
|
||
</td>
|
||
<td>{{ resolvePieceType(row.piece) }}</td>
|
||
<td>
|
||
<div class="flex items-center gap-2">
|
||
<NuxtLink
|
||
:to="`/pieces/${row.piece.id}/edit`"
|
||
class="btn btn-ghost btn-xs"
|
||
>
|
||
Modifier
|
||
</NuxtLink>
|
||
<button
|
||
type="button"
|
||
class="btn btn-error btn-xs"
|
||
:disabled="loadingPieces"
|
||
@click="handleDeletePiece(row.piece)"
|
||
>
|
||
Supprimer
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<Pagination
|
||
:current-page="currentPage"
|
||
:total-pages="totalPages"
|
||
@update:current-page="handlePageChange"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { usePieces } from '~/composables/usePieces'
|
||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||
import { useToast } from '~/composables/useToast'
|
||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||
import Pagination from '~/components/common/Pagination.vue'
|
||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||
|
||
const { showError } = useToast()
|
||
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||
|
||
// Pagination state
|
||
const currentPage = ref(1)
|
||
const itemsPerPage = ref(30)
|
||
const piecesTotal = computed(() => total.value)
|
||
const piecesOnPage = computed(() => pieces.value.length)
|
||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||
|
||
// Search state with debounce
|
||
const searchTerm = ref('')
|
||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const debouncedSearch = () => {
|
||
if (searchTimeout) {
|
||
clearTimeout(searchTimeout)
|
||
}
|
||
searchTimeout = setTimeout(() => {
|
||
currentPage.value = 1
|
||
fetchPieces()
|
||
}, 300)
|
||
}
|
||
|
||
// Sort state
|
||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||
'pieces-catalog',
|
||
{ field: 'name', direction: 'asc' },
|
||
)
|
||
|
||
// Enrichir les pièces avec les types de pièces complets
|
||
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 fetchPieces = async () => {
|
||
await loadPieces({
|
||
search: searchTerm.value,
|
||
page: currentPage.value,
|
||
itemsPerPage: itemsPerPage.value,
|
||
orderBy: sortField.value,
|
||
orderDir: sortDirection.value
|
||
})
|
||
}
|
||
|
||
const handlePageChange = (page: number) => {
|
||
currentPage.value = page
|
||
fetchPieces()
|
||
}
|
||
|
||
const handleSortChange = () => {
|
||
currentPage.value = 1
|
||
fetchPieces()
|
||
}
|
||
|
||
const handlePerPageChange = () => {
|
||
currentPage.value = 1
|
||
fetchPieces()
|
||
}
|
||
|
||
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
||
if (!documents.length) {
|
||
return null
|
||
}
|
||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||
const withPath = normalized.filter((doc) => doc?.path)
|
||
|
||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||
if (pdf) {
|
||
return pdf
|
||
}
|
||
|
||
const image = withPath.find((doc) => 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)
|
||
if (parts.length) {
|
||
return `Aperçu du document de ${parts.join(' – ')}`
|
||
}
|
||
return 'Aperçu du document'
|
||
}
|
||
|
||
const resolvePieceType = (piece: Record<string, any>) => {
|
||
const type = piece?.typePiece
|
||
if (type?.name) {
|
||
return type.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 pieceRows = computed(() =>
|
||
piecesList.value.map((piece) => ({
|
||
piece,
|
||
suppliers: buildPieceSuppliersDisplay(piece),
|
||
})),
|
||
)
|
||
|
||
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 confirmed = window.confirm(confirmLines.join('\n\n'))
|
||
if (!confirmed) {
|
||
return
|
||
}
|
||
|
||
await deletePiece(piece.id)
|
||
// Reload current page after deletion
|
||
fetchPieces()
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([
|
||
fetchPieces(),
|
||
loadPieceTypes()
|
||
])
|
||
})
|
||
</script>
|