Nouvelle page /re-extraction, accessible depuis la liste des documents : elle
rejoue l'extraction de chaque PDF stocke, document par document, avec une
progression et une estimation du temps restant (une extraction prend plusieurs
secondes) et un bouton d'arret. Rien n'est ecrit en base pendant le balayage.
Les documents dont l'extraction change sont listes avec le nombre de
differences par section, deja coches ; les inchanges sont replies et les
documents sans PDF ou en erreur isoles. Le detail depliable reutilise
l'affichage de diff de la re-extraction unitaire (extrait dans
ExtractionDiffDetails). Un seul bouton enregistre toute la selection.
Les tags de depenses sont reportes par signature d'operation et non par index :
une operation ajoutee ou retiree par le nouveau parser ne decale plus les
categories. Ce qui n'est pas reapparie recoit une prediction, modifiable dans le
detail avant enregistrement.
L'edition d'un document et l'application du balayage passent par
PUT /api/documents/{id}, qui vise le document par son ID.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
10 KiB
Vue
301 lines
10 KiB
Vue
<template>
|
||
<div class="page-fixed">
|
||
<!-- Header avec breadcrumb et actions -->
|
||
<div class="flex-shrink-0 px-6 py-4 bg-gray-900 border-b border-gray-700">
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="flex items-center gap-2 text-sm text-gray-400 mb-1">
|
||
<router-link to="/documents" class="hover:text-white transition-colors">Documents</router-link>
|
||
<span>›</span>
|
||
<span class="text-white">{{ documentData?.reference || 'Chargement...' }}</span>
|
||
</div>
|
||
<h1 class="page-title">
|
||
Édition du document {{ documentData?.reference }}
|
||
</h1>
|
||
<p v-if="documentData" class="page-subtitle">
|
||
{{ documentData.immeuble?.adresse || '-' }} - {{ formatDate(documentData.date) }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<!-- Masque pendant le tagging : celui du bandeau de tagging fait foi. -->
|
||
<button
|
||
v-if="!showTagging"
|
||
@click="cancel"
|
||
:disabled="isSaving"
|
||
class="btn btn-ghost"
|
||
>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
v-if="!showTagging && extractedData"
|
||
@click="reExtract"
|
||
:disabled="isSaving || isReExtracting || !documentData?.has_pdf"
|
||
:title="documentData?.has_pdf ? 'Relancer l\'extraction depuis le PDF stocké' : 'PDF non disponible pour ce document'"
|
||
class="btn btn-warning"
|
||
>
|
||
<svg
|
||
class="w-4 h-4"
|
||
:class="{ 'animate-spin': isReExtracting }"
|
||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
{{ isReExtracting ? 'Extraction…' : "Relancer l'extraction" }}
|
||
</button>
|
||
<button
|
||
v-if="!showTagging && extractedData"
|
||
@click="goToTagging"
|
||
:disabled="isSaving"
|
||
class="btn btn-primary"
|
||
>
|
||
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||
</svg>
|
||
Continuer vers le tagging
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading state -->
|
||
<div v-if="isLoading" class="flex-1 flex items-center justify-center bg-gray-950">
|
||
<div class="text-center">
|
||
<div class="spinner h-12 w-12 mb-4"></div>
|
||
<p class="text-gray-400">Chargement du document...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Corps : Split view PDF + Données -->
|
||
<div v-else class="flex-1 flex overflow-hidden">
|
||
<!-- Gauche : PDF Preview -->
|
||
<div class="w-2/5 border-r border-gray-700 flex flex-col">
|
||
<div class="flex-shrink-0 px-4 py-2 bg-gray-900 border-b border-gray-700">
|
||
<span class="card-title">Aperçu PDF</span>
|
||
</div>
|
||
<div class="flex-1 overflow-hidden">
|
||
<PdfPreview
|
||
v-if="documentData?.has_pdf"
|
||
:url="`/api/documents/${documentId}/pdf`"
|
||
:file-name="documentData?.source_file || 'document.pdf'"
|
||
class="h-full"
|
||
/>
|
||
<div v-else class="h-full flex items-center justify-center bg-gray-950 text-gray-500">
|
||
<div class="text-center">
|
||
<svg class="mx-auto h-16 w-16 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<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-lg font-medium">PDF non disponible</p>
|
||
<p class="text-sm text-gray-600 mt-1">Ce document a été importé sans stockage du PDF</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Droite : Édition ou Tagging -->
|
||
<div class="w-3/5 flex flex-col bg-gray-950">
|
||
<!-- Bandeau erreur de re-extraction -->
|
||
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-400">
|
||
{{ reExtractError }}
|
||
</div>
|
||
|
||
<!-- Panneau des différences après re-extraction -->
|
||
<ReExtractionDiff
|
||
v-if="diff && !showTagging"
|
||
:diff="diff"
|
||
@revert="revertReExtract"
|
||
@close="diff = null"
|
||
/>
|
||
|
||
<!-- Vue 1 : JsonViewer (mode édition) -->
|
||
<JsonViewer
|
||
v-if="!showTagging && extractedData"
|
||
:data="extractedData"
|
||
@update:data="extractedData = $event"
|
||
:is-loading="false"
|
||
:highlight="highlightInfo"
|
||
:diff="diff"
|
||
class="flex-1"
|
||
/>
|
||
|
||
<!-- Vue 2 : TaggingStep -->
|
||
<TaggingStep
|
||
v-if="showTagging && extractedData"
|
||
:depenses="extractedData.data.recapitulatif_operations"
|
||
:is-duplicate="true"
|
||
@save="handleSave"
|
||
@cancel="showTagging = false"
|
||
class="flex-1"
|
||
/>
|
||
|
||
<!-- Error message -->
|
||
<div v-if="saveError" class="flex-shrink-0 px-4 py-3 bg-red-500/10 border-t border-red-500/30">
|
||
<div class="flex items-center gap-2 text-sm text-red-400">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span>{{ saveError }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import PdfPreview from '../components/PdfPreview.vue'
|
||
import JsonViewer from '../components/JsonViewer.vue'
|
||
import TaggingStep from '../components/TaggingStep.vue'
|
||
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
|
||
import { computeExtractionDiff } from '../utils/diffExtraction.js'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const documentId = route.params.id
|
||
|
||
const documentData = ref(null)
|
||
const extractedData = ref(null)
|
||
const isLoading = ref(true)
|
||
const showTagging = ref(false)
|
||
const isSaving = ref(false)
|
||
const saveError = ref(null)
|
||
const isReExtracting = ref(false)
|
||
const reExtractError = ref(null)
|
||
const diff = ref(null)
|
||
const previousData = ref(null)
|
||
|
||
const highlightInfo = computed(() => {
|
||
const q = route.query
|
||
if (q.highlight === 'locataire') return { type: 'locataire', lot_numero: q.lot_numero, locataire_nom: q.locataire_nom }
|
||
if (q.highlight === 'operation') return { type: 'operation', fournisseur: q.fournisseur, description: q.description }
|
||
return null
|
||
})
|
||
|
||
onMounted(async () => {
|
||
await loadDocument()
|
||
})
|
||
|
||
async function loadDocument() {
|
||
isLoading.value = true
|
||
try {
|
||
const response = await fetch(`/api/documents/${documentId}`)
|
||
if (!response.ok) {
|
||
throw new Error('Document non trouvé')
|
||
}
|
||
|
||
documentData.value = await response.json()
|
||
|
||
// Formatter les données pour JsonViewer (même structure que ExtractPage)
|
||
extractedData.value = {
|
||
source_file: documentData.value.source_file,
|
||
data: documentData.value.json_data
|
||
}
|
||
} catch (err) {
|
||
console.error('Error loading document:', err)
|
||
alert('Erreur lors du chargement du document: ' + err.message)
|
||
router.push('/documents')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
const [year, month, day] = dateStr.split('-')
|
||
return `${day}/${month}/${year}`
|
||
}
|
||
|
||
function cancel() {
|
||
const hasChanges = extractedData.value !== null
|
||
if (hasChanges) {
|
||
const confirmed = confirm('Abandonner les modifications ?')
|
||
if (!confirmed) return
|
||
}
|
||
router.push('/documents')
|
||
}
|
||
|
||
function goToTagging() {
|
||
showTagging.value = true
|
||
saveError.value = null
|
||
}
|
||
|
||
async function reExtract() {
|
||
isReExtracting.value = true
|
||
reExtractError.value = null
|
||
try {
|
||
const response = await fetch(`/api/documents/${documentId}/re-extract`, {
|
||
method: 'POST',
|
||
})
|
||
const result = await response.json()
|
||
if (!response.ok) {
|
||
throw new Error(result.detail || 'Échec de la re-extraction')
|
||
}
|
||
|
||
// Sauvegarder l'état actuel pour pouvoir revenir en arrière.
|
||
previousData.value = JSON.parse(JSON.stringify(extractedData.value.data))
|
||
|
||
const newData = result.re_extracted_data
|
||
diff.value = computeExtractionDiff(previousData.value, newData)
|
||
|
||
// Remplacer les données de travail par la nouvelle extraction.
|
||
extractedData.value = { ...extractedData.value, data: newData }
|
||
} catch (err) {
|
||
console.error('Re-extraction error:', err)
|
||
reExtractError.value = err.message || 'Une erreur est survenue lors de la re-extraction'
|
||
} finally {
|
||
isReExtracting.value = false
|
||
}
|
||
}
|
||
|
||
function revertReExtract() {
|
||
if (!previousData.value) return
|
||
extractedData.value = { ...extractedData.value, data: previousData.value }
|
||
previousData.value = null
|
||
diff.value = null
|
||
}
|
||
|
||
async function handleSave(depensesTags, shouldOverwrite) {
|
||
isSaving.value = true
|
||
saveError.value = null
|
||
|
||
try {
|
||
// Mise à jour ciblée par ID : si la nouvelle extraction corrige la référence
|
||
// ou la date, c'est bien ce document qui est mis à jour, pas un doublon.
|
||
const response = await fetch(`/api/documents/${documentId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
source_file: extractedData.value.source_file,
|
||
data: extractedData.value.data,
|
||
depenses_tags: depensesTags
|
||
})
|
||
})
|
||
|
||
const result = await response.json()
|
||
|
||
if (!response.ok) {
|
||
throw new Error(result.detail || 'Erreur lors de la sauvegarde')
|
||
}
|
||
|
||
if (result.success) {
|
||
// Succès - redirection vers la liste des documents
|
||
router.push('/documents')
|
||
} else {
|
||
throw new Error(result.message || 'Échec de la sauvegarde')
|
||
}
|
||
} catch (err) {
|
||
console.error('Save error:', err)
|
||
saveError.value = err.message || 'Une erreur est survenue lors de la sauvegarde'
|
||
// Scroll to top to show error
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
} finally {
|
||
isSaving.value = false
|
||
}
|
||
}
|
||
</script>
|