Files
Inventory_frontend/app/pages/component-catalog.vue
Matthieu e88ed5b8f2 feat(documents): migrate storage to filesystem, add server-side pagination
- Replace Base64 data URIs with file-based storage served via dedicated endpoints
- Add DocumentPreviewModal navigation, DocumentThumbnail fileUrl support
- Refactor documents page with server-side pagination, search, sort and filters
- Update all components to use fileUrl/downloadUrl instead of raw path
- Add pagination composable support (total, page, itemsPerPage, attachmentFilter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:17:59 +01:00

377 lines
13 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 composants</h1>
<p class="text-sm text-gray-500">
Consultez et gérez tous les composants existants.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un composant
</NuxtLink>
<NuxtLink to="/component-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">Composants créés</h2>
<p class="text-sm text-base-content/70">
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
</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="component-catalog-sort"
>
Trier par
</label>
<select
id="component-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="component-catalog-dir"
>
Ordre
</label>
<select
id="component-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="component-catalog-per-page"
>
Par page
</label>
<select
id="component-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">
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loadingComposants" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!composantsTotal" class="text-sm text-base-content/70">
Aucun composant n'a encore été créé.
</p>
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
Aucun composant 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>Description</th>
<th>Type de composant</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="component in composantsList" :key="component.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(component)"
:alt="resolvePreviewAlt(component)"
/>
</td>
<td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td>
<td class="max-w-xs">
<div v-if="component.description" class="group relative">
<span class="block cursor-help truncate">{{ 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">{{ component.description }}</p>
</div>
</div>
<span v-else></span>
</td>
<td>
<NuxtLink
v-if="component.typeComposant?.id"
:to="`/component-category/${component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(component) }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/component/${component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingComposants"
@click="handleDeleteComponent(component)"
>
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 } from 'vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
const { showError } = useToast()
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value)
// State synced with URL query params (preserved on back/forward navigation)
const {
page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchComposants(),
})
const composantsTotal = computed(() => total.value)
const composantsOnPage = computed(() => composants.value.length)
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
// Search debounce for API calls
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchComposants()
}, 300)
}
// Enrichir les composants avec les types de composants complets
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 fetchComposants = async () => {
await loadComposants({
search: searchTerm.value,
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value as 'asc' | 'desc',
force: true
})
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchComposants()
}
const handleSortChange = () => {
currentPage.value = 1
fetchComposants()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchComposants()
}
const resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : []
if (!documents.length) {
return null
}
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.fileUrl || 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 = (component: Record<string, any>) => {
const parts = [component?.name, component?.reference].filter(Boolean)
if (parts.length) {
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
}
const resolveComponentType = (component: Record<string, any>) => {
const type = component?.typeComposant
if (type?.name) {
return type.name
}
if (component?.typeComposantLabel) {
return component.typeComposantLabel
}
return '—'
}
const resolveDeleteGuard = (component: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks)
? component.machineLinks.length
: component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents)
? component.documents.length
: component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues)
? component.customFieldValues.length
: component?.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 handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
if (blockingReasons.length) {
showError(
`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(
', ',
)}. Supprimez ou détachez ces éléments avant de réessayer.`
)
return
}
const componentName = component?.name || 'ce composant'
const confirmLines = [
`Voulez-vous vraiment supprimer ${componentName} ?`,
]
if (hasCustomFields) {
confirmLines.push(
'Les valeurs de champs personnalisés associées seront également supprimées.'
)
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) {
return
}
await deleteComposant(component.id)
// Reload current page after deletion
fetchComposants()
}
onMounted(async () => {
await Promise.all([
fetchComposants(),
loadComponentTypes()
])
})
</script>