Files
Inventory_frontend/app/pages/product-catalog.vue
2025-12-03 11:29:11 +01:00

389 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 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 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…"
/>
</label>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
<select
id="product-sort"
v-model="sortField"
class="select select-bordered select-sm"
>
<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="product-dir">Ordre</label>
<select
id="product-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/60 lg:text-right">
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-10">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<div
v-else-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>
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
Chargement du catalogue…
</p>
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70">
Aucun produit n'a encore été enregistré.
</p>
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
Aucun produit ne correspond à votre recherche.
</p>
<div v-else class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-16">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Type de produit</th>
<th>Fournisseurs</th>
<th class="text-right">Prix indicatif</th>
<th class="w-32 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="row in productRows" :key="row.product.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product)"
:alt="resolvePreviewAlt(row.product)"
/>
</td>
<td class="font-medium">{{ row.product.name }}</td>
<td>{{ row.product.reference || '—' }}</td>
<td>{{ row.product.typeProduct?.name || '—' }}</td>
<td>
<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>
</td>
<td class="text-right">
{{ formatPrice(row.product.supplierPrice) }}
</td>
<td class="text-right space-x-2">
<NuxtLink
:to="`/product/${row.product.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead } from '#imports'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
useHead(() => ({
title: 'Catalogue des produits',
}))
const {
products,
total,
loading,
loaded,
error,
loadProducts,
deleteProduct,
} = useProducts()
const toast = useToast()
const searchTerm = ref('')
const sortField = ref<'name' | 'createdAt'>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
const normalizedProducts = computed(() => (Array.isArray(products.value) ? products.value : []))
const hasLoaded = computed(() => loaded.value)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const filteredProducts = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const items = normalizedProducts.value.slice()
const filtered = term
? items.filter((product) => {
const name = (product?.name || '').toLowerCase()
const reference = (product?.reference || '').toLowerCase()
const typeName = (product?.typeProduct?.name || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term) ||
typeName.includes(term)
)
})
: items
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
) * direction
}
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
return (dateA - dateB) * direction
})
})
const filteredCount = computed(() => filteredProducts.value.length)
const totalCount = computed(() => {
const reported = Number(total.value)
if (!Number.isFinite(reported) || reported < 0) {
return normalizedProducts.value.length
}
return reported
})
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)
if (Number.isNaN(number)) {
return '—'
}
return priceFormatter.format(number)
}
const MAX_VISIBLE_SUPPLIERS = 3
const resolveProductSuppliers = (product: 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(product?.constructeurs)
collectConstructeurs(product?.constructeur)
collectFromLabel(product?.constructeursLabel)
collectFromLabel(product?.supplierLabel)
collectFromLabel(product?.suppliers)
return names
}
const buildSuppliersDisplay = (product: Record<string, any>) => {
const suppliers = resolveProductSuppliers(product)
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 productRows = computed(() =>
filteredProducts.value.map((product) => ({
product,
suppliers: buildSuppliersDisplay(product),
})),
)
const resolvePrimaryDocument = (product: Record<string, any>) => {
const documents = Array.isArray(product?.documents) ? product.documents : []
if (!documents.length) {
return null
}
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path)
if (!withPath.length) {
return normalized[0] ?? null
}
const images = withPath.filter((doc) => isImageDocument(doc))
if (images.length) {
return images[0]
}
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
return withPath[0]
}
const resolvePreviewAlt = (product: Record<string, any>) => {
const parts = [product?.name, product?.reference].filter(Boolean)
if (parts.length) {
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
}
const reload = async () => {
await loadProducts({ force: true })
}
const confirmDelete = async (product: Record<string, any>) => {
const confirmed = window.confirm(
`Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
)
if (!confirmed) {
return
}
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${product.name}" supprimé`)
}
}
onMounted(async () => {
await loadProducts()
})
</script>