feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
This commit is contained in:
254
app/pages/product-catalog.vue
Normal file
254
app/pages/product-catalog.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<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>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="product in filteredProducts" :key="product.id">
|
||||
<td class="font-medium">{{ product.name }}</td>
|
||||
<td>{{ product.reference || '—' }}</td>
|
||||
<td>{{ product.typeProduct?.name || '—' }}</td>
|
||||
<td>
|
||||
<span v-if="product.constructeurs?.length" class="text-sm">
|
||||
{{ formatConstructeurs(product.constructeurs) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-base-content/50">—</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ formatPrice(product.supplierPrice) }}
|
||||
</td>
|
||||
<td class="text-right space-x-2">
|
||||
<NuxtLink
|
||||
:to="`/product/${product.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="confirmDelete(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'
|
||||
|
||||
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 formatConstructeurs = (constructeurs: Array<Record<string, any>>) =>
|
||||
constructeurs
|
||||
.map((constructeur) => constructeur?.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.join(', ')
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user