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