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:
2026-01-18 05:36:27 +01:00
commit 6bad5fbaf9
34 changed files with 5692 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<template>
<div class="bg-gray-50 rounded-lg p-3">
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<!-- Icons -->
<svg v-if="icon === 'building'" class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<svg v-else-if="icon === 'user'" class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<svg v-else-if="icon === 'document'" class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<svg v-else-if="icon === 'home'" class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<svg v-else-if="icon === 'currency'" class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ title }}
</h4>
<div class="space-y-1">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
icon: {
type: String,
default: null
}
})
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="flex items-baseline text-sm">
<span class="text-gray-500 w-28 flex-shrink-0">{{ label }}</span>
<span
class="font-medium flex-1"
:class="highlight ? 'text-blue-600 text-base' : 'text-gray-800'"
>
{{ displayValue }}
</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: {
type: String,
required: true
},
value: {
type: [String, Number],
default: null
},
highlight: {
type: Boolean,
default: false
}
})
const displayValue = computed(() => {
if (props.value === null || props.value === undefined || props.value === '') {
return '-'
}
return props.value
})
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<!-- Header -->
<button
@click="$emit('toggle')"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center gap-2">
<svg
class="w-4 h-4 text-gray-500 transition-transform"
:class="{ 'rotate-90': expanded }"
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>
<span class="font-semibold text-gray-800">{{ title }}</span>
<span v-if="count !== undefined" class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">
{{ count }}
</span>
</div>
</button>
<!-- Content -->
<div v-show="expanded" class="p-4 bg-white">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
count: {
type: Number,
default: undefined
},
expanded: {
type: Boolean,
default: true
}
})
defineEmits(['toggle'])
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="font-mono text-sm">
<!-- Collapsible node (object or array) -->
<div v-if="isCollapsible" class="group">
<div
@click="toggle"
class="flex items-center gap-1 cursor-pointer hover:bg-gray-100 rounded px-1 -mx-1"
>
<!-- Expand/collapse icon -->
<svg
class="w-3 h-3 text-gray-400 transition-transform"
:class="{ 'rotate-90': isExpanded }"
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>
<!-- Key name -->
<span v-if="!isRoot" class="text-purple-600">{{ name }}</span>
<span v-if="!isRoot" class="text-gray-400">: </span>
<!-- Preview when collapsed -->
<span class="text-gray-400">
{{ isArray ? '[' : '{' }}
<span v-if="!isExpanded" class="text-gray-500">
{{ itemCount }} {{ isArray ? 'elements' : 'cles' }}
</span>
<span v-if="!isExpanded">{{ isArray ? ']' : '}' }}</span>
</span>
</div>
<!-- Children -->
<div v-if="isExpanded" class="ml-4 border-l border-gray-200 pl-2">
<JsonNode
v-for="(value, key) in data"
:key="key"
:data="value"
:name="String(key)"
:is-root="false"
/>
<span class="text-gray-400">{{ isArray ? ']' : '}' }}</span>
</div>
</div>
<!-- Primitive value -->
<div v-else class="flex items-start gap-1 py-0.5">
<span class="text-purple-600">{{ name }}</span>
<span class="text-gray-400">: </span>
<span :class="valueClass">{{ formattedValue }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
data: {
type: [Object, Array, String, Number, Boolean, null],
required: true
},
name: {
type: String,
default: ''
},
isRoot: {
type: Boolean,
default: false
}
})
const isExpanded = ref(props.isRoot)
const isArray = computed(() => Array.isArray(props.data))
const isObject = computed(() => props.data !== null && typeof props.data === 'object')
const isCollapsible = computed(() => isObject.value)
const itemCount = computed(() => {
if (isArray.value) return props.data.length
if (isObject.value) return Object.keys(props.data).length
return 0
})
const valueClass = computed(() => {
if (props.data === null) return 'text-gray-400 italic'
if (typeof props.data === 'string') return 'text-green-600'
if (typeof props.data === 'number') return 'text-blue-600'
if (typeof props.data === 'boolean') return 'text-orange-600'
return 'text-gray-700'
})
const formattedValue = computed(() => {
if (props.data === null) return 'null'
if (typeof props.data === 'string') return `"${props.data}"`
return String(props.data)
})
function toggle() {
isExpanded.value = !isExpanded.value
}
</script>

View File

@@ -0,0 +1,202 @@
<template>
<div class="h-full flex flex-col bg-white overflow-hidden">
<!-- Header -->
<div class="flex-shrink-0 flex items-center justify-between px-4 py-2 bg-gray-100 border-b border-gray-200">
<span class="text-sm font-semibold text-gray-700">Donnees extraites</span>
<div class="flex items-center gap-2">
<button
v-if="data"
@click="expandAll"
class="px-2 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded transition-colors"
>
Tout deplier
</button>
<button
v-if="data"
@click="collapseAll"
class="px-2 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded transition-colors"
>
Tout replier
</button>
<button
v-if="data"
@click="copyJson"
class="flex items-center gap-1 px-2 py-1 text-xs text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{{ copyLabel }}
</button>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto custom-scrollbar">
<!-- Loading state -->
<div v-if="isLoading" class="flex flex-col items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-10 w-10 mb-3 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>
<p class="text-sm font-medium">Extraction en cours...</p>
</div>
<!-- Empty state -->
<div v-else-if="!data" class="flex flex-col items-center justify-center h-full text-gray-400 p-8">
<svg class="w-16 h-16 mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">En attente de l'extraction...</p>
</div>
<!-- Structured data view -->
<div v-else class="p-4 space-y-3">
<!-- Source file -->
<div class="text-xs text-gray-500 mb-4">
Fichier: <span class="font-medium text-gray-700">{{ data.source_file }}</span>
</div>
<!-- Metadata Section -->
<DataSection
title="Metadata"
:expanded="expandedSections.metadata"
@toggle="toggleSection('metadata')"
>
<div class="grid grid-cols-1 gap-3">
<!-- Editeur -->
<DataCard title="Editeur" icon="building">
<DataRow label="Nom" :value="data.data?.metadata?.editeur?.nom" />
<DataRow label="Adresse" :value="data.data?.metadata?.editeur?.adresse" />
<DataRow label="Telephone" :value="data.data?.metadata?.editeur?.telephone" />
<DataRow label="SIRET" :value="data.data?.metadata?.editeur?.siret" />
</DataCard>
<!-- Destinataire -->
<DataCard title="Destinataire" icon="user">
<DataRow label="Nom" :value="data.data?.metadata?.destinataire?.nom" />
<DataRow label="Adresse" :value="data.data?.metadata?.destinataire?.adresse" />
</DataCard>
<!-- Document -->
<DataCard title="Document" icon="document">
<DataRow label="Reference" :value="data.data?.metadata?.document?.reference" />
<DataRow label="Date" :value="data.data?.metadata?.document?.date" />
<DataRow label="Type" :value="data.data?.metadata?.document?.type" />
</DataCard>
<!-- Immeuble -->
<DataCard title="Immeuble" icon="home">
<DataRow label="Code" :value="data.data?.metadata?.immeuble?.code" />
<DataRow label="Adresse" :value="data.data?.metadata?.immeuble?.adresse" />
<DataRow label="Ville" :value="data.data?.metadata?.immeuble?.ville" />
<DataRow label="Code postal" :value="data.data?.metadata?.immeuble?.code_postal" />
</DataCard>
<!-- Solde -->
<DataCard title="Solde" icon="currency">
<DataRow label="Montant" :value="formatCurrency(data.data?.metadata?.solde?.montant)" highlight />
<DataRow label="Type" :value="data.data?.metadata?.solde?.type" />
<DataRow label="Date arrete" :value="data.data?.metadata?.solde?.date_arrete" />
</DataCard>
</div>
</DataSection>
<!-- Situation Locataires Section -->
<DataSection
title="Situation des locataires"
:count="data.data?.situation_locataires?.length"
:expanded="expandedSections.locataires"
@toggle="toggleSection('locataires')"
>
<div class="space-y-2">
<LocataireCard
v-for="(loc, idx) in data.data?.situation_locataires"
:key="idx"
:locataire="loc"
:index="idx"
/>
</div>
</DataSection>
<!-- Recapitulatif Operations Section -->
<DataSection
title="Recapitulatif des operations"
:count="data.data?.recapitulatif_operations?.length"
:expanded="expandedSections.operations"
@toggle="toggleSection('operations')"
>
<div class="space-y-2">
<OperationCard
v-for="(cat, idx) in data.data?.recapitulatif_operations"
:key="idx"
:category="cat"
/>
</div>
</DataSection>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import DataSection from './DataSection.vue'
import DataCard from './DataCard.vue'
import DataRow from './DataRow.vue'
import LocataireCard from './LocataireCard.vue'
import OperationCard from './OperationCard.vue'
const props = defineProps({
data: {
type: Object,
default: null
},
isLoading: {
type: Boolean,
default: false
}
})
const copyLabel = ref('Copier')
const expandedSections = reactive({
metadata: true,
locataires: true,
operations: false
})
function toggleSection(section) {
expandedSections[section] = !expandedSections[section]
}
function expandAll() {
expandedSections.metadata = true
expandedSections.locataires = true
expandedSections.operations = true
}
function collapseAll() {
expandedSections.metadata = false
expandedSections.locataires = false
expandedSections.operations = false
}
function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
}
async function copyJson() {
if (!props.data) return
try {
await navigator.clipboard.writeText(JSON.stringify(props.data, null, 2))
copyLabel.value = 'Copie!'
setTimeout(() => {
copyLabel.value = 'Copier'
}, 2000)
} catch (error) {
console.error('Failed to copy:', error)
}
}
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<!-- Header -->
<button
@click="expanded = !expanded"
class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors text-left"
>
<div class="flex items-center gap-3">
<svg
class="w-3 h-3 text-gray-400 transition-transform flex-shrink-0"
:class="{ 'rotate-90': expanded }"
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>
<div>
<span class="text-xs text-gray-500">Lot {{ locataire.lot?.numero }}</span>
<span class="mx-1 text-gray-300">|</span>
<span class="text-xs text-gray-500">{{ locataire.lot?.type }}</span>
</div>
<span class="font-medium text-gray-800 text-sm">{{ locataire.locataire?.nom || 'Sans nom' }}</span>
</div>
<div class="text-right">
<div class="text-sm font-semibold" :class="totalClass">
{{ formatCurrency(locataire.totaux?.total) }}
</div>
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-500">
Impayes: {{ formatCurrency(locataire.totaux?.impayes) }}
</div>
</div>
</button>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50 p-3">
<!-- Totaux -->
<div class="grid grid-cols-4 gap-2 text-xs mb-3">
<div class="text-center p-2 bg-white rounded">
<div class="text-gray-500">Loyers</div>
<div class="font-semibold text-gray-800">{{ formatCurrency(locataire.totaux?.loyers) }}</div>
</div>
<div class="text-center p-2 bg-white rounded">
<div class="text-gray-500">Taxes</div>
<div class="font-semibold text-gray-800">{{ formatCurrency(locataire.totaux?.taxes) }}</div>
</div>
<div class="text-center p-2 bg-white rounded">
<div class="text-gray-500">Provisions</div>
<div class="font-semibold text-gray-800">{{ formatCurrency(locataire.totaux?.provisions) }}</div>
</div>
<div class="text-center p-2 bg-white rounded">
<div class="text-gray-500">Regles</div>
<div class="font-semibold text-green-600">{{ formatCurrency(locataire.totaux?.regles) }}</div>
</div>
</div>
<!-- Lignes detail -->
<div v-if="locataire.lignes?.length" class="space-y-1">
<div class="text-xs text-gray-500 font-medium mb-1">Detail des lignes</div>
<div
v-for="(ligne, idx) in locataire.lignes"
:key="idx"
class="text-xs bg-white rounded p-2 flex items-center justify-between"
>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded text-xs" :class="ligneTypeClass(ligne.type)">
{{ ligne.type }}
</span>
<span v-if="ligne.periode?.debut" class="text-gray-500">
{{ ligne.periode.debut }} - {{ ligne.periode.fin }}
</span>
</div>
<div class="font-medium text-gray-800">
{{ formatCurrency(ligne.total || ligne.loyers) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
locataire: {
type: Object,
required: true
},
index: {
type: Number,
default: 0
}
})
const expanded = ref(false)
const totalClass = computed(() => {
const total = props.locataire.totaux?.total || 0
if (total > 0) return 'text-green-600'
if (total < 0) return 'text-red-600'
return 'text-gray-600'
})
function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
}
function ligneTypeClass(type) {
const classes = {
'loyer': 'bg-blue-100 text-blue-700',
'solde_anterieur': 'bg-orange-100 text-orange-700',
'rappel_loyer': 'bg-purple-100 text-purple-700',
'divers': 'bg-gray-100 text-gray-700'
}
return classes[type] || 'bg-gray-100 text-gray-700'
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<!-- Header -->
<button
@click="expanded = !expanded"
class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors text-left"
>
<div class="flex items-center gap-2">
<svg
class="w-3 h-3 text-gray-400 transition-transform flex-shrink-0"
:class="{ 'rotate-90': expanded }"
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>
<span class="font-medium text-gray-800 text-sm">{{ category.categorie }}</span>
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{{ category.operations?.length || 0 }} operations
</span>
</div>
<div class="text-sm font-semibold text-gray-700">
{{ formatCurrency(totalDebit) }}
</div>
</button>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50">
<div class="divide-y divide-gray-100">
<div
v-for="(op, idx) in category.operations"
:key="idx"
class="px-3 py-2 bg-white text-xs"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div v-if="op.fournisseur" class="font-medium text-gray-800 truncate">
{{ op.fournisseur }}
</div>
<div class="text-gray-500 truncate">{{ op.description || op.type_operation || '-' }}</div>
<div v-if="op.lot_concerne" class="text-gray-400 text-xs mt-0.5">
Lot: {{ op.lot_concerne }}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="font-semibold text-gray-800">{{ formatCurrency(op.montants?.debit) }}</div>
<div v-if="op.montants?.tva" class="text-gray-400">
TVA: {{ formatCurrency(op.montants.tva) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
category: {
type: Object,
required: true
}
})
const expanded = ref(false)
const totalDebit = computed(() => {
if (!props.category.operations) return 0
return props.category.operations.reduce((sum, op) => sum + (op.montants?.debit || 0), 0)
})
function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
}
</script>

View 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>

View File

@@ -0,0 +1,168 @@
<template>
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="{
'border-blue-500 bg-blue-50': isDragging,
'border-gray-300 hover:border-gray-400': !isDragging && !file,
'border-green-500 bg-green-50': file && !isLoading
}"
@dragenter.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@dragover.prevent
@drop.prevent="onDrop"
>
<!-- Loading state -->
<div v-if="isLoading" class="flex flex-col items-center gap-4">
<svg class="animate-spin h-12 w-12 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>
<p class="text-gray-600">Extraction en cours...</p>
</div>
<!-- File selected state -->
<div v-else-if="file" class="flex flex-col items-center gap-4">
<svg class="h-12 w-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-gray-700 font-medium">{{ file.name }}</p>
<p class="text-sm text-gray-500">{{ formatFileSize(file.size) }}</p>
<div class="flex gap-3">
<button
@click="extractPdf"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Extraire les donnees
</button>
<button
@click="clearFile"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Annuler
</button>
</div>
</div>
<!-- Default upload state -->
<div v-else class="flex flex-col items-center gap-4">
<svg class="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div>
<p class="text-gray-600 mb-1">Glissez un PDF ici ou</p>
<label class="cursor-pointer text-blue-600 hover:text-blue-700 font-medium">
cliquez pour selectionner
<input
type="file"
accept="application/pdf,.pdf"
class="hidden"
@change="onFileSelect"
/>
</label>
</div>
<p class="text-sm text-gray-400">Fichiers PDF uniquement</p>
</div>
<!-- Error message -->
<div v-if="error" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-600 text-sm">{{ error }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['file-selected', 'extraction-complete', 'extraction-error'])
const file = ref(null)
const isDragging = ref(false)
const isLoading = ref(false)
const error = ref(null)
let dragCounter = 0
function onDragEnter() {
dragCounter++
isDragging.value = true
}
function onDragLeave() {
dragCounter--
if (dragCounter === 0) {
isDragging.value = false
}
}
function onDrop(e) {
dragCounter = 0
isDragging.value = false
const droppedFile = e.dataTransfer.files[0]
if (droppedFile) {
handleFile(droppedFile)
}
}
function onFileSelect(e) {
const selectedFile = e.target.files[0]
if (selectedFile) {
handleFile(selectedFile)
}
}
function handleFile(f) {
error.value = null
// Validate file type
if (!f.name.toLowerCase().endsWith('.pdf')) {
error.value = 'Veuillez selectionner un fichier PDF'
return
}
file.value = f
emit('file-selected', f)
}
function clearFile() {
file.value = null
error.value = null
emit('file-selected', null)
}
async function extractPdf() {
if (!file.value) return
isLoading.value = true
error.value = null
try {
const formData = new FormData()
formData.append('file', file.value)
const response = await fetch('/api/extract', {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || 'Erreur lors de l\'extraction')
}
const data = await response.json()
emit('extraction-complete', data)
} catch (err) {
error.value = err.message
emit('extraction-error', err.message)
} finally {
isLoading.value = false
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>