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:
39
frontend/src/components/DataCard.vue
Normal file
39
frontend/src/components/DataCard.vue
Normal 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>
|
||||
37
frontend/src/components/DataRow.vue
Normal file
37
frontend/src/components/DataRow.vue
Normal 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>
|
||||
49
frontend/src/components/DataSection.vue
Normal file
49
frontend/src/components/DataSection.vue
Normal 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>
|
||||
103
frontend/src/components/JsonNode.vue
Normal file
103
frontend/src/components/JsonNode.vue
Normal 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>
|
||||
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>
|
||||
119
frontend/src/components/LocataireCard.vue
Normal file
119
frontend/src/components/LocataireCard.vue
Normal 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>
|
||||
80
frontend/src/components/OperationCard.vue
Normal file
80
frontend/src/components/OperationCard.vue
Normal 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>
|
||||
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>
|
||||
168
frontend/src/components/PdfUploader.vue
Normal file
168
frontend/src/components/PdfUploader.vue
Normal 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>
|
||||
Reference in New Issue
Block a user