Files
Inventory/app/pages/pieces-catalog.vue

462 lines
14 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>
<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>