feat: init plesna-gerance - extracteur de comptes rendus de gerance
- Backend Python (uv + click + FastAPI) - CLI: plesna-gerance extract <pdf> pour extraire les donnees - CLI: plesna-gerance serve pour lancer le serveur API - API REST: POST /api/extract pour upload et extraction de PDF - Parsers modulaires: metadata, locataires, operations - Utilise pdftotext (poppler-utils) pour l'extraction de texte - Frontend Vue.js + Tailwind CSS - Interface split-screen: PDF a gauche, donnees a droite - Preview PDF avec zoom et navigation pages (pdf.js) - Visualisation structuree des donnees extraites - Sections depliables: metadata, locataires, operations - Drag & drop pour upload de PDF - Extraction automatique a la selection du fichier
This commit is contained in:
288
frontend/src/components/PdfPreview.vue
Normal file
288
frontend/src/components/PdfPreview.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-gray-800 overflow-hidden relative">
|
||||
<!-- Header with navigation and zoom -->
|
||||
<div class="flex-shrink-0 flex items-center justify-between px-3 py-2 bg-gray-900 text-white">
|
||||
<!-- File name -->
|
||||
<span class="text-sm font-medium truncate max-w-[150px]">{{ fileName }}</span>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="zoomOut"
|
||||
:disabled="zoom <= 0.5"
|
||||
class="p-1 rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Zoom arriere"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs w-12 text-center">{{ Math.round(zoom * 100) }}%</span>
|
||||
<button
|
||||
@click="zoomIn"
|
||||
:disabled="zoom >= 3"
|
||||
class="p-1 rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Zoom avant"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="fitToWidth"
|
||||
class="p-1 rounded hover:bg-gray-700 text-xs ml-1"
|
||||
title="Ajuster a la largeur"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Page navigation -->
|
||||
<div v-if="totalPages > 0" class="flex items-center gap-1">
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="currentPage <= 1"
|
||||
class="p-1 rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs w-16 text-center">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="p-1 rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF Canvas Container -->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="flex-1 overflow-auto bg-gray-700"
|
||||
@wheel.ctrl.prevent="onWheel"
|
||||
>
|
||||
<div class="inline-block min-w-full min-h-full p-4">
|
||||
<div class="flex justify-center">
|
||||
<canvas ref="canvasRef" class="shadow-xl bg-white block"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center bg-gray-800/90">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="animate-spin h-10 w-10 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-white text-sm">Chargement du PDF...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="absolute inset-0 flex items-center justify-center bg-gray-800/95">
|
||||
<div class="text-center text-white p-4 max-w-md">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
|
||||
// Configure PDF.js worker
|
||||
const PDFJS_VERSION = pdfjsLib.version
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${PDFJS_VERSION}/build/pdf.worker.min.mjs`
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: File,
|
||||
default: null
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
default: 'document.pdf'
|
||||
}
|
||||
})
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const zoom = ref(1)
|
||||
const baseScale = ref(1)
|
||||
|
||||
let pdfDoc = null
|
||||
let renderTask = null
|
||||
|
||||
async function loadPdf(file) {
|
||||
if (!file) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer })
|
||||
pdfDoc = await loadingTask.promise
|
||||
|
||||
totalPages.value = pdfDoc.numPages
|
||||
currentPage.value = 1
|
||||
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Calculate base scale to fit width
|
||||
await calculateBaseScale()
|
||||
zoom.value = 1
|
||||
|
||||
await renderPage(1)
|
||||
} catch (err) {
|
||||
console.error('Error loading PDF:', err)
|
||||
error.value = `Erreur lors du chargement: ${err.message}`
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function calculateBaseScale() {
|
||||
if (!pdfDoc || !containerRef.value) return
|
||||
|
||||
const page = await pdfDoc.getPage(1)
|
||||
const viewport = page.getViewport({ scale: 1 })
|
||||
const containerWidth = containerRef.value.clientWidth - 32
|
||||
baseScale.value = containerWidth / viewport.width
|
||||
}
|
||||
|
||||
async function renderPage(pageNum) {
|
||||
if (!pdfDoc || !canvasRef.value || !containerRef.value) return
|
||||
|
||||
if (renderTask) {
|
||||
try { renderTask.cancel() } catch (e) {}
|
||||
renderTask = null
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await pdfDoc.getPage(pageNum)
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const scale = baseScale.value * zoom.value
|
||||
const viewport = page.getViewport({ scale })
|
||||
|
||||
// High DPI support
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = viewport.width * dpr
|
||||
canvas.height = viewport.height * dpr
|
||||
canvas.style.width = viewport.width + 'px'
|
||||
canvas.style.height = viewport.height + 'px'
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
renderTask = page.render({
|
||||
canvasContext: ctx,
|
||||
viewport: viewport
|
||||
})
|
||||
|
||||
await renderTask.promise
|
||||
renderTask = null
|
||||
} catch (err) {
|
||||
if (err.name !== 'RenderingCancelledException') {
|
||||
console.error('Error rendering page:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
renderPage(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
renderPage(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (zoom.value < 3) {
|
||||
zoom.value = Math.min(3, zoom.value + 0.25)
|
||||
renderPage(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (zoom.value > 0.5) {
|
||||
zoom.value = Math.max(0.5, zoom.value - 0.25)
|
||||
renderPage(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function fitToWidth() {
|
||||
zoom.value = 1
|
||||
renderPage(currentPage.value)
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
if (e.deltaY < 0) {
|
||||
zoomIn()
|
||||
} else {
|
||||
zoomOut()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.file, async (newFile) => {
|
||||
if (newFile) {
|
||||
await loadPdf(newFile)
|
||||
} else {
|
||||
pdfDoc = null
|
||||
totalPages.value = 0
|
||||
currentPage.value = 1
|
||||
error.value = null
|
||||
zoom.value = 1
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
let resizeTimeout = null
|
||||
function handleResize() {
|
||||
clearTimeout(resizeTimeout)
|
||||
resizeTimeout = setTimeout(async () => {
|
||||
if (pdfDoc) {
|
||||
await calculateBaseScale()
|
||||
renderPage(currentPage.value)
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
clearTimeout(resizeTimeout)
|
||||
if (renderTask) {
|
||||
try { renderTask.cancel() } catch (e) {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user