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:
202
frontend/src/components/JsonViewer.vue
Normal file
202
frontend/src/components/JsonViewer.vue
Normal 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>
|
||||
Reference in New Issue
Block a user