feat(ui) : create unified catalog+category pages under /catalogues/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
<main
|
||||
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
|
||||
>
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
||||
<p class="text-base text-base-content/70">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</header>
|
||||
<template v-if="!hideHeading">
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
||||
<p class="text-base text-base-content/70">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<nav
|
||||
v-if="allowCategorySwitch"
|
||||
@@ -144,9 +146,11 @@ const props = withDefaults(
|
||||
heading: string
|
||||
description?: string
|
||||
allowCategorySwitch?: boolean
|
||||
hideHeading?: boolean
|
||||
}>(),
|
||||
{
|
||||
allowCategorySwitch: false,
|
||||
hideHeading: false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
229
frontend/app/pages/catalogues/composants.vue
Normal file
229
frontend/app/pages/catalogues/composants.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
|
||||
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
|
||||
</div>
|
||||
|
||||
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Composants">
|
||||
<template #tab-catalogue>
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
|
||||
<p class="text-sm text-base-content/50">
|
||||
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="componentRows"
|
||||
:loading="loadingComposants"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun composant n'a encore été créé."
|
||||
no-results-message="Aucun composant 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.component)"
|
||||
:alt="resolvePreviewAlt(row.component)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
{{ row.component.name || 'Composant sans nom' }}
|
||||
</template>
|
||||
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.component.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.component.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.component.description }}</span>
|
||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-typeComposant="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.component.typeComposant?.id"
|
||||
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolveComponentType(row.component) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
||||
</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(`/component/${row.component.id}?edit=true`)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(row.component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="`/component/${row.component.id}`"
|
||||
class="btn btn-primary btn-xs"
|
||||
>
|
||||
Détails
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template #tab-categories>
|
||||
<ManagementView
|
||||
category="COMPONENT"
|
||||
heading="Catégories de composant"
|
||||
:hide-heading="true"
|
||||
/>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
const route = useRoute()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'catalogue')
|
||||
watch(activeTab, (val) => {
|
||||
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
})
|
||||
|
||||
const pageTabs = computed(() => {
|
||||
const t: Array<{ key: string; label: string }> = [
|
||||
{ key: 'catalogue', label: 'Catalogue' },
|
||||
]
|
||||
if (canEdit.value) {
|
||||
t.push({ key: 'categories', label: 'Catégories' })
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchComposants },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
const composantsOnPage = computed(() => componentRows.value.length)
|
||||
const paginationState = table.pagination(total, composantsOnPage)
|
||||
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||||
})
|
||||
})
|
||||
|
||||
const componentRows = computed(() =>
|
||||
composantsList.value.map(component => ({
|
||||
id: component.id,
|
||||
component,
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchComposants() {
|
||||
await loadComposants({
|
||||
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.typeComposant || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const resolveComponentType = (component: Record<string, any>) => {
|
||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||
return '—'
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
const componentName = component?.name || 'ce composant'
|
||||
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
|
||||
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
|
||||
if (!confirmed) return
|
||||
await deleteComposant(component.id)
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||
})
|
||||
</script>
|
||||
262
frontend/app/pages/catalogues/pieces.vue
Normal file
262
frontend/app/pages/catalogues/pieces.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
|
||||
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
|
||||
</div>
|
||||
|
||||
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Pièces">
|
||||
<template #tab-catalogue>
|
||||
<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>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="pieceRows"
|
||||
:loading="loadingPieces"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucune pièce n'a encore été créée."
|
||||
no-results-message="Aucune pièce 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.piece)"
|
||||
:alt="resolvePreviewAlt(row.piece)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
{{ row.piece.name || 'Pièce sans nom' }}
|
||||
</template>
|
||||
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.piece.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-referenceAuto="{ row }">
|
||||
{{ row.piece.referenceAuto || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.piece.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm group-hover:pointer-events-auto group-hover:visible">
|
||||
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-suppliers="{ row }">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<template #cell-typePiece="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.piece.typePiece?.id"
|
||||
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolvePieceType(row.piece) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
|
||||
</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(`/piece/${row.piece.id}?edit=true`)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="loadingPieces"
|
||||
@click="handleDeletePiece(row.piece)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="`/piece/${row.piece.id}`"
|
||||
class="btn btn-primary btn-xs"
|
||||
>
|
||||
Détails
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template #tab-categories>
|
||||
<ManagementView
|
||||
category="PIECE"
|
||||
heading="Catégories de pièce"
|
||||
:hide-heading="true"
|
||||
/>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
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'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
const route = useRoute()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'catalogue')
|
||||
watch(activeTab, (val) => {
|
||||
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
})
|
||||
|
||||
const pageTabs = computed(() => {
|
||||
const t: Array<{ key: string; label: string }> = [
|
||||
{ key: 'catalogue', label: 'Catalogue' },
|
||||
]
|
||||
if (canEdit.value) {
|
||||
t.push({ key: 'categories', label: 'Catégories' })
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchPieces },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'referenceAuto', label: 'Réf. auto' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
const piecesOnPage = computed(() => pieceRows.value.length)
|
||||
const paginationState = table.pagination(total, piecesOnPage)
|
||||
|
||||
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 pieceRows = computed(() =>
|
||||
piecesList.value.map(piece => ({
|
||||
id: piece.id,
|
||||
piece,
|
||||
suppliers: buildPieceSuppliersDisplay(piece),
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchPieces() {
|
||||
await loadPieces({
|
||||
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.typePiece || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const resolvePieceType = (piece: Record<string, any>) => {
|
||||
if (piece?.typePiece?.name) return piece.typePiece.name
|
||||
if (piece?.typePieceLabel) return piece.typePieceLabel
|
||||
return '—'
|
||||
}
|
||||
|
||||
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
||||
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||
const pieceName = piece?.name || 'cette pièce'
|
||||
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
|
||||
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
||||
if (!confirmed) return
|
||||
await deletePiece(piece.id)
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchPieces(), loadPieceTypes()])
|
||||
})
|
||||
</script>
|
||||
273
frontend/app/pages/catalogues/produits.vue
Normal file
273
frontend/app/pages/catalogues/produits.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
|
||||
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
|
||||
</div>
|
||||
|
||||
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Produits">
|
||||
<template #tab-catalogue>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<template #tab-categories>
|
||||
<ManagementView
|
||||
category="PRODUCT"
|
||||
heading="Catégories de produit"
|
||||
:hide-heading="true"
|
||||
/>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ManagementView from '~/components/model-types/ManagementView.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 route = useRoute()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'catalogue')
|
||||
watch(activeTab, (val) => {
|
||||
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
})
|
||||
|
||||
const pageTabs = computed(() => {
|
||||
const t: Array<{ key: string; label: string }> = [
|
||||
{ key: 'catalogue', label: 'Catalogue' },
|
||||
]
|
||||
if (canEdit.value) {
|
||||
t.push({ key: 'categories', label: 'Catégories' })
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user