- 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>
314 lines
11 KiB
Vue
314 lines
11 KiB
Vue
<template>
|
|
<main class="container mx-auto px-6 py-8 space-y-8">
|
|
<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-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>
|
|
|
|
<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>
|
|
|
|
<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" />
|
|
|
|
<div v-if="loading" class="flex flex-col items-center justify-center py-16 text-sm text-gray-500">
|
|
<span class="loading loading-spinner loading-lg mb-3" />
|
|
Chargement des documents...
|
|
</div>
|
|
|
|
<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 n'a encore été ajouté.
|
|
</div>
|
|
|
|
<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>
|
|
</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 lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useDocuments } from '~/composables/useDocuments'
|
|
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, total, loading, loadDocuments } = useDocuments()
|
|
|
|
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<any>(null)
|
|
const previewVisible = ref(false)
|
|
|
|
const documentsTotal = computed(() => total.value)
|
|
const documentsOnPage = computed(() => documents.value.length)
|
|
const totalPages = computed(() => Math.ceil(documentsTotal.value / itemsPerPage.value) || 1)
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// 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: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
|
|
|
const downloadDocument = (doc: any) => {
|
|
if (doc?.downloadUrl) {
|
|
window.open(doc.downloadUrl, '_blank')
|
|
}
|
|
}
|
|
|
|
const openPreview = (doc: any) => {
|
|
if (!canPreviewDocument(doc)) return
|
|
previewDocument.value = doc
|
|
previewVisible.value = true
|
|
}
|
|
|
|
const closePreview = () => {
|
|
previewVisible.value = false
|
|
previewDocument.value = null
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchDocuments()
|
|
})
|
|
</script>
|