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>
This commit is contained in:
@@ -279,7 +279,7 @@ const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.path)
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
return pdf
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
@@ -386,8 +387,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -3,46 +3,107 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="documents"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<section class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div class="w-full md:w-2/3">
|
||||
<label class="label">
|
||||
<span class="label-text">Recherche</span>
|
||||
<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 du document..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="search"
|
||||
placeholder="Nom du document, type, site, machine..."
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-filter"
|
||||
>
|
||||
Rattachement
|
||||
</label>
|
||||
<select
|
||||
id="doc-filter"
|
||||
v-model="attachmentFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
<option value="site">Sites</option>
|
||||
<option value="machine">Machines</option>
|
||||
<option value="composant">Composants</option>
|
||||
<option value="piece">Pièces</option>
|
||||
<option value="product">Produits</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-sort"
|
||||
>
|
||||
Trier par
|
||||
</label>
|
||||
<select
|
||||
id="doc-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="createdAt">Date</option>
|
||||
<option value="name">Nom</option>
|
||||
<option value="size">Taille</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-dir"
|
||||
>
|
||||
Ordre
|
||||
</label>
|
||||
<select
|
||||
id="doc-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="doc-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="doc-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>
|
||||
|
||||
<div class="w-full md:w-1/3">
|
||||
<label class="label">
|
||||
<span class="label-text">Filtrer par rattachement</span>
|
||||
</label>
|
||||
<select v-model="attachmentFilter" class="select select-bordered w-full">
|
||||
<option value="all">
|
||||
Tous
|
||||
</option>
|
||||
<option value="site">
|
||||
Sites
|
||||
</option>
|
||||
<option value="machine">
|
||||
Machines
|
||||
</option>
|
||||
<option value="composant">
|
||||
Composants
|
||||
</option>
|
||||
<option value="piece">
|
||||
Pièces
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ documentsOnPage }} / {{ documentsTotal }} résultat{{ documentsTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0" />
|
||||
@@ -52,181 +113,191 @@
|
||||
Chargement des documents...
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredDocuments.length === 0" class="text-center py-16 text-sm text-gray-500">
|
||||
<div v-else-if="!documentsTotal" class="text-center py-16 text-sm text-gray-500">
|
||||
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
|
||||
Aucun document ne correspond à votre recherche pour l'instant.
|
||||
Aucun document n'a encore été ajouté.
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase">
|
||||
<th>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Taille</th>
|
||||
<th>Rattaché à</th>
|
||||
<th>Date</th>
|
||||
<th class="text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="document in filteredDocuments" :key="document.id" class="text-sm">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" :class="documentIcon(document).colorClass">
|
||||
<component
|
||||
:is="documentIcon(document).component"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ document.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ document.filename }}
|
||||
<div v-else-if="!documents.length" class="text-center py-16 text-sm text-gray-500">
|
||||
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
|
||||
Aucun document ne correspond à votre recherche.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase">
|
||||
<th>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Taille</th>
|
||||
<th>Rattaché à</th>
|
||||
<th>Date</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="doc in documents" :key="doc.id" class="text-sm">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" :class="documentIcon(doc).colorClass">
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-semibold">{{ doc.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ doc.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ document.mimeType || 'Inconnu' }}</td>
|
||||
<td>{{ formatSize(document.size) }}</td>
|
||||
<td>
|
||||
<div class="flex flex-col text-xs">
|
||||
<span v-if="document.site">Site · {{ document.site.name }}</span>
|
||||
<span v-else-if="document.machine">Machine · {{ document.machine.name }}</span>
|
||||
<span v-else-if="document.composant">Composant · {{ document.composant.name }}</span>
|
||||
<span v-else-if="document.piece">Pièce · {{ document.piece.name }}</span>
|
||||
<span v-else class="text-gray-400">Non défini</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatFrenchDate(document.createdAt) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="openPreview(document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(document)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ doc.mimeType || 'Inconnu' }}</td>
|
||||
<td>{{ formatSize(doc.size) }}</td>
|
||||
<td>
|
||||
<div class="flex flex-col text-xs">
|
||||
<span v-if="doc.site">Site · {{ doc.site.name }}</span>
|
||||
<span v-else-if="doc.machine">Machine · {{ doc.machine.name }}</span>
|
||||
<span v-else-if="doc.composant">Composant · {{ doc.composant.name }}</span>
|
||||
<span v-else-if="doc.piece">Pièce · {{ doc.piece.name }}</span>
|
||||
<span v-else-if="doc.product">Produit · {{ doc.product.name }}</span>
|
||||
<span v-else class="text-gray-400">Non défini</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatFrenchDate(doc.createdAt) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
|
||||
@click="openPreview(doc)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(doc)">
|
||||
Télécharger
|
||||
</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>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||
|
||||
const { documents, loading, loadDocuments } = useDocuments()
|
||||
const { get } = useApi()
|
||||
const { documents, total, loading, loadDocuments } = useDocuments()
|
||||
|
||||
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
||||
const {
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
q: searchTerm,
|
||||
filter: attachmentFilter,
|
||||
sort: sortField,
|
||||
dir: sortDirection,
|
||||
} = useUrlState({
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: 30, type: 'number' },
|
||||
q: { default: '', debounce: 300 },
|
||||
filter: { default: 'all' },
|
||||
sort: { default: 'createdAt' },
|
||||
dir: { default: 'desc' },
|
||||
}, {
|
||||
onRestore: () => fetchDocuments(),
|
||||
})
|
||||
const previewDocument = ref(null)
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments({ itemsPerPage: 200 })
|
||||
})
|
||||
const documentsTotal = computed(() => total.value)
|
||||
const documentsOnPage = computed(() => documents.value.length)
|
||||
const totalPages = computed(() => Math.ceil(documentsTotal.value / itemsPerPage.value) || 1)
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const filter = attachmentFilter.value
|
||||
|
||||
return documents.value.filter((document) => {
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
(filter === 'site' && document.site) ||
|
||||
(filter === 'machine' && document.machine) ||
|
||||
(filter === 'composant' && document.composant) ||
|
||||
(filter === 'piece' && document.piece)
|
||||
|
||||
if (!matchesFilter) { return false }
|
||||
|
||||
if (!term) { return true }
|
||||
|
||||
const searchable = [
|
||||
document.name,
|
||||
document.filename,
|
||||
document.mimeType,
|
||||
document.site?.name,
|
||||
document.machine?.name,
|
||||
document.composant?.name,
|
||||
document.piece?.name
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(value => value.toLowerCase())
|
||||
|
||||
return searchable.some(value => value.includes(term))
|
||||
const fetchDocuments = async () => {
|
||||
await loadDocuments({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
attachmentFilter: attachmentFilter.value,
|
||||
force: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) { return '—' }
|
||||
if (size === 0) { return '0 B' }
|
||||
// Search debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const formatSize = (size: number | undefined | null) => {
|
||||
if (size === undefined || size === null) return '\u2014'
|
||||
if (size === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
|
||||
/** Fetch the full document (with path) from the API on demand. */
|
||||
const fetchDocumentPath = async (doc) => {
|
||||
if (doc?.path) { return doc.path }
|
||||
if (!doc?.id) { return null }
|
||||
const result = await get(`/documents/${doc.id}`)
|
||||
if (result.success && result.data?.path) {
|
||||
doc.path = result.data.path
|
||||
return result.data.path
|
||||
const downloadDocument = (doc: any) => {
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadDocument = async (doc) => {
|
||||
const path = await fetchDocumentPath(doc)
|
||||
if (!path) { return }
|
||||
|
||||
if (path.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = path
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(path, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = async (doc) => {
|
||||
if (!canPreviewDocument(doc)) { return }
|
||||
await fetchDocumentPath(doc)
|
||||
const openPreview = (doc: any) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
@@ -235,4 +306,8 @@ const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDocuments()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="d.previewDocument.value"
|
||||
:visible="d.previewVisible.value"
|
||||
:documents="d.machineDocumentsList.value"
|
||||
@close="d.closePreview"
|
||||
/>
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.path)
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
@@ -333,8 +334,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -367,7 +367,7 @@ const resolvePrimaryDocument = (product: Record<string, any>) => {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.path)
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) {
|
||||
return normalized[0] ?? null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="productDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
@@ -244,8 +245,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="siteDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user