Merges the full git history of Inventory_frontend into the monorepo under frontend/. Removes the submodule in favor of a unified repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
258 lines
9.0 KiB
Vue
258 lines
9.0 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 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
|
|
v-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>
|
|
|
|
<DataTable
|
|
v-else
|
|
:columns="columns"
|
|
:rows="productRows"
|
|
:loading="loading"
|
|
:sort="table.sort.value"
|
|
:pagination="paginationState"
|
|
:column-filters="table.columnFilters.value"
|
|
:show-per-page="true"
|
|
empty-message="Aucun produit n'a encore été enregistré."
|
|
no-results-message="Aucun produit 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.product, true)"
|
|
:alt="resolvePreviewAlt(row.product)"
|
|
/>
|
|
</template>
|
|
|
|
<template #cell-name="{ row }">
|
|
<span class="font-medium">{{ row.product.name }}</span>
|
|
</template>
|
|
|
|
<template #cell-reference="{ row }">
|
|
{{ row.product.reference || '—' }}
|
|
</template>
|
|
|
|
<template #cell-typeProduct="{ row }">
|
|
<NuxtLink
|
|
v-if="row.product.typeProduct?.id"
|
|
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
|
class="link link-hover link-primary"
|
|
>
|
|
{{ row.product.typeProduct.name }}
|
|
</NuxtLink>
|
|
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
|
</template>
|
|
|
|
<template #cell-suppliers="{ row }">
|
|
<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>
|
|
</template>
|
|
|
|
<template #cell-price="{ row }">
|
|
{{ formatPrice(row.product.supplierPrice) }}
|
|
</template>
|
|
|
|
<template #cell-actions="{ row }">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<button
|
|
v-if="canEdit"
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
|
|
>
|
|
Modifier
|
|
</button>
|
|
<button
|
|
v-if="canEdit"
|
|
type="button"
|
|
class="btn btn-ghost btn-xs text-error"
|
|
@click="confirmDelete(row.product)"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
<NuxtLink
|
|
:to="`/product/${row.product.id}`"
|
|
class="btn btn-primary btn-xs"
|
|
>
|
|
Détails
|
|
</NuxtLink>
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted } from 'vue'
|
|
import { useHead } from '#imports'
|
|
import DataTable from '~/components/common/DataTable.vue'
|
|
import { useProducts } from '~/composables/useProducts'
|
|
import { useProductTypes } from '~/composables/useProductTypes'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useDataTable } from '~/composables/useDataTable'
|
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
|
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
|
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
|
|
|
const { canEdit } = usePermissions()
|
|
|
|
useHead(() => ({ title: 'Catalogue des produits' }))
|
|
|
|
const {
|
|
products,
|
|
total,
|
|
loading,
|
|
error,
|
|
loadProducts,
|
|
deleteProduct,
|
|
} = useProducts()
|
|
const { productTypes, loadProductTypes } = useProductTypes()
|
|
const toast = useToast()
|
|
|
|
const table = useDataTable(
|
|
{ fetchData: fetchProducts },
|
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
|
|
)
|
|
|
|
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
|
|
|
const columns = [
|
|
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
|
|
{ key: 'name', label: 'Nom', sortable: true },
|
|
{ key: 'reference', label: 'Référence' },
|
|
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
|
|
{ key: 'suppliers', label: 'Fournisseurs' },
|
|
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
|
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
|
|
]
|
|
|
|
const productsOnPage = computed(() => productRows.value.length)
|
|
const paginationState = table.pagination(total, productsOnPage)
|
|
|
|
// Enrich products with full type data
|
|
const normalizedProducts = computed(() => {
|
|
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
|
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
|
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
|
|
})
|
|
})
|
|
|
|
const productRows = computed(() =>
|
|
normalizedProducts.value.map(product => ({
|
|
id: product.id,
|
|
product,
|
|
suppliers: buildProductSuppliersDisplay(product),
|
|
})),
|
|
)
|
|
|
|
async function fetchProducts() {
|
|
await loadProducts({
|
|
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.typeProduct || undefined,
|
|
force: true,
|
|
})
|
|
}
|
|
|
|
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)
|
|
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
|
}
|
|
|
|
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
|
|
buildSuppliersDisplay(resolveSupplierNames(product))
|
|
|
|
const reload = () => fetchProducts()
|
|
|
|
const { confirm } = useConfirm()
|
|
|
|
const confirmDelete = async (product: Record<string, any>) => {
|
|
const productName = product?.name || 'ce produit'
|
|
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
|
|
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
|
|
if (!confirmed) return
|
|
const result = await deleteProduct(product.id)
|
|
if (result.success) {
|
|
toast.showSuccess(`Produit "${productName}" supprimé`)
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([fetchProducts(), loadProductTypes()])
|
|
})
|
|
</script>
|