Files
pdf_oralia_vibe/frontend/src/components/JsonViewer.vue
Bertrand Benjamin 02a3459291 refactor(ui): bascule le flux extraction/edition en theme sombre
Tout ce flux (visionneuse de donnees, fiches locataire et operation,
tagging, autocompletion, panneau de differences) etait en theme clair,
au milieu d'une application sombre : le contraste sautait a chaque
passage d'une page a l'edition d'un document.

Les surfaces, bordures, textes et etats colores suivent desormais la
palette commune. Les champs natifs (date, nombre, cases a cocher)
recoivent color-scheme: dark pour que le calendrier et les widgets
systeme s'affichent en sombre eux aussi.

Les fiches de metadonnees se repartissent sur 2 puis 3 colonnes selon la
largeur disponible, au lieu d'une seule colonne.

Le bouton « Annuler » du bandeau de tagging devient « Retour a
l'edition », qui decrit son action reelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:50:02 +02:00

481 lines
17 KiB
Vue

<template>
<div class="h-full flex flex-col bg-gray-950 overflow-hidden">
<!-- Header -->
<div class="flex-shrink-0 flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700">
<span class="card-title">Donnees extraites</span>
<div class="flex items-center gap-2">
<button
v-if="data"
@click="expandAll"
class="btn btn-sm btn-ghost"
>
Tout deplier
</button>
<button
v-if="data"
@click="collapseAll"
class="btn btn-sm btn-ghost"
>
Tout replier
</button>
<button
v-if="data"
@click="copyJson"
class="btn btn-sm btn-ghost"
>
<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-500">
<svg class="animate-spin h-10 w-10 mb-3 text-blue-400" 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-500 p-8">
<svg class="w-16 h-16 mb-3 text-gray-700" 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-300">{{ data.source_file }}</span>
</div>
<!-- Metadata Section -->
<DataSection
title="Metadata"
:expanded="expandedSections.metadata"
@toggle="toggleSection('metadata')"
>
<!-- Les fiches metadata se repartissent sur la largeur disponible -->
<div class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
<!-- Editeur -->
<DataCard title="Editeur" icon="building">
<DataRow label="Nom" :value="data.data?.metadata?.editeur?.nom" @update:value="updateMetadata('editeur.nom', $event)" />
<DataRow label="Adresse" :value="data.data?.metadata?.editeur?.adresse" @update:value="updateMetadata('editeur.adresse', $event)" />
<DataRow label="Telephone" :value="data.data?.metadata?.editeur?.telephone" @update:value="updateMetadata('editeur.telephone', $event)" />
<DataRow label="SIRET" :value="data.data?.metadata?.editeur?.siret" @update:value="updateMetadata('editeur.siret', $event)" />
</DataCard>
<!-- Destinataire -->
<DataCard title="Destinataire" icon="user">
<DataRow label="Nom" :value="data.data?.metadata?.destinataire?.nom" @update:value="updateMetadata('destinataire.nom', $event)" />
<DataRow label="Adresse" :value="data.data?.metadata?.destinataire?.adresse" @update:value="updateMetadata('destinataire.adresse', $event)" />
</DataCard>
<!-- Document -->
<DataCard title="Document" icon="document">
<DataRow label="Reference" :value="data.data?.metadata?.document?.reference" @update:value="updateMetadata('document.reference', $event)" />
<DataRow label="Date" :value="data.data?.metadata?.document?.date" type="date" @update:value="updateMetadata('document.date', $event)" />
<DataRow label="Type" :value="data.data?.metadata?.document?.type" @update:value="updateMetadata('document.type', $event)" />
</DataCard>
<!-- Immeuble -->
<DataCard title="Immeuble" icon="home">
<DataRow label="Code" :value="data.data?.metadata?.immeuble?.code" @update:value="updateMetadata('immeuble.code', $event)" />
<DataRow label="Adresse" :value="data.data?.metadata?.immeuble?.adresse" @update:value="updateMetadata('immeuble.adresse', $event)" />
<DataRow label="Ville" :value="data.data?.metadata?.immeuble?.ville" @update:value="updateMetadata('immeuble.ville', $event)" />
<DataRow label="Code postal" :value="data.data?.metadata?.immeuble?.code_postal" @update:value="updateMetadata('immeuble.code_postal', $event)" />
</DataCard>
<!-- Solde -->
<DataCard title="Solde" icon="currency">
<DataRow label="Montant" :value="data.data?.metadata?.solde?.montant" type="currency" highlight @update:value="updateMetadata('solde.montant', $event)" />
<DataRow label="Type" :value="data.data?.metadata?.solde?.type" @update:value="updateMetadata('solde.type', $event)" />
<DataRow label="Date arrete" :value="data.data?.metadata?.solde?.date_arrete" type="date" @update:value="updateMetadata('solde.date_arrete', $event)" />
</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"
:highlighted="highlightedLocataireIndex === idx"
:changed="changedLocataireSet.has(idx)"
@update:locataire="updateLocataire(idx, $event)"
@remove="removeLocataire(idx)"
/>
<!-- Bouton ajouter locataire -->
<button
@click="addLocataire"
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-400 hover:bg-blue-500/10 border border-dashed border-blue-500/40 rounded-lg transition-colors"
>
<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>
Ajouter un locataire
</button>
</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="(group, idx) in operationsGroupedByCategory"
:key="idx"
:categorie="group.categorie"
:operations="group.operations"
:highlightIndex="group.categorie === highlightOperationCategorie ? highlightOperationIndex : -1"
:changedIndices="changedOpByCategorie[group.categorie] || []"
@update:operations="updateOperationsGroup(group.categorie, $event)"
/>
<!-- Bouton ajouter operation -->
<button
@click="addOperation"
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-400 hover:bg-blue-500/10 border border-dashed border-blue-500/40 rounded-lg transition-colors"
>
<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>
Ajouter une operation
</button>
</div>
</DataSection>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } 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
},
highlight: {
type: Object,
default: null
},
diff: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:data'])
// Grouper les operations par categorie pour l'affichage
const operationsGroupedByCategory = computed(() => {
const operations = props.data?.data?.recapitulatif_operations
if (!operations || !Array.isArray(operations)) return []
// Grouper par categorie en preservant l'ordre d'apparition
const groups = []
const categoryIndex = {}
for (const op of operations) {
const cat = op.categorie || 'AUTRES'
if (!(cat in categoryIndex)) {
categoryIndex[cat] = groups.length
groups.push({ categorie: cat, operations: [] })
}
groups[categoryIndex[cat]].operations.push(op)
}
return groups
})
// Indices de locataires modifiés par la re-extraction (anneau « modifié »).
const changedLocataireSet = computed(
() => new Set(props.diff?.changedLocataireIndices || [])
)
// Indices d'opérations modifiées, ramenés au repère (catégorie, index dans le groupe).
const changedOpByCategorie = computed(() => {
const result = {}
const operations = props.data?.data?.recapitulatif_operations
const changed = new Set(props.diff?.changedOperationIndices || [])
if (!operations || !changed.size) return result
const counters = {}
operations.forEach((op, absIdx) => {
const cat = op.categorie || 'AUTRES'
if (!(cat in counters)) counters[cat] = 0
if (changed.has(absIdx)) {
if (!result[cat]) result[cat] = []
result[cat].push(counters[cat])
}
counters[cat]++
})
return result
})
const copyLabel = ref('Copier')
const expandedSections = reactive({
metadata: true,
locataires: true,
operations: false
})
// Highlight state
const highlightedLocataireIndex = ref(-1)
const highlightOperationCategorie = ref(null)
const highlightOperationIndex = ref(-1)
watch(
() => [props.data, props.highlight],
([data, highlight]) => {
if (!data || !highlight) return
if (highlight.type === 'locataire') {
const locataires = data.data?.situation_locataires
if (!locataires) return
const idx = locataires.findIndex(
loc => loc.lot?.numero === highlight.lot_numero && loc.locataire?.nom === highlight.locataire_nom
)
if (idx >= 0) {
expandedSections.locataires = true
highlightedLocataireIndex.value = idx
}
}
if (highlight.type === 'operation') {
const operations = data.data?.recapitulatif_operations
if (!operations) return
// Find the matching operation and determine its category + index within that category group
let targetCategorie = null
let indexInGroup = -1
const categoryCounters = {}
for (const op of operations) {
const cat = op.categorie || 'AUTRES'
if (!(cat in categoryCounters)) categoryCounters[cat] = 0
if (op.fournisseur === highlight.fournisseur && op.description === highlight.description) {
targetCategorie = cat
indexInGroup = categoryCounters[cat]
break
}
categoryCounters[cat]++
}
if (targetCategorie !== null) {
expandedSections.operations = true
highlightOperationCategorie.value = targetCategorie
highlightOperationIndex.value = indexInGroup
}
}
},
{ immediate: true }
)
// Déplier automatiquement les sections qui contiennent des différences.
watch(
() => props.diff,
(diff) => {
if (!diff) return
if (diff.summary?.metadata) expandedSections.metadata = true
if (diff.summary?.locataires) expandedSections.locataires = true
if (diff.summary?.operations) expandedSections.operations = true
}
)
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
}
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)
}
}
// ===== FONCTIONS DE MISE A JOUR =====
// Helper pour creer une copie profonde des donnees
function cloneData() {
return JSON.parse(JSON.stringify(props.data))
}
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour les metadonnees
function updateMetadata(path, value) {
const updated = cloneData()
if (!updated.data) updated.data = {}
if (!updated.data.metadata) updated.data.metadata = {}
setNestedValue(updated.data.metadata, path, value)
emit('update:data', updated)
}
// Mettre a jour un locataire
function updateLocataire(index, locataire) {
const updated = cloneData()
if (!updated.data) updated.data = {}
if (!updated.data.situation_locataires) updated.data.situation_locataires = []
updated.data.situation_locataires[index] = locataire
emit('update:data', updated)
}
// Ajouter un locataire
function addLocataire() {
const updated = cloneData()
if (!updated.data) updated.data = {}
if (!updated.data.situation_locataires) updated.data.situation_locataires = []
updated.data.situation_locataires.push({
lot: {
numero: '',
type: ''
},
locataire: {
nom: 'Nouveau locataire'
},
lignes: [],
totaux: {
solde_anterieur: 0,
loyers: 0,
taxes: 0,
provisions: 0,
divers: 0,
total: 0,
regles: 0,
impayes: 0
}
})
emit('update:data', updated)
}
// Supprimer un locataire
function removeLocataire(index) {
const updated = cloneData()
updated.data.situation_locataires.splice(index, 1)
emit('update:data', updated)
}
// Mettre a jour un groupe d'operations (par categorie)
function updateOperationsGroup(categorie, updatedGroupOperations) {
const updated = cloneData()
if (!updated.data) updated.data = {}
if (!updated.data.recapitulatif_operations) updated.data.recapitulatif_operations = []
// Reconstruire la liste en preservant l'ordre original
const newOperations = []
let updatedIndex = 0
// Parcourir les operations originales et remplacer celles de la categorie modifiee
for (const op of updated.data.recapitulatif_operations) {
if (op.categorie === categorie) {
// Remplacer par les operations mises a jour (dans l'ordre)
if (updatedIndex < updatedGroupOperations.length) {
newOperations.push(updatedGroupOperations[updatedIndex])
updatedIndex++
}
// Si on a supprime des operations, on ne les ajoute pas
} else {
// Garder les operations des autres categories telles quelles
newOperations.push(op)
}
}
// Ajouter les nouvelles operations si on en a ajoute
while (updatedIndex < updatedGroupOperations.length) {
newOperations.push(updatedGroupOperations[updatedIndex])
updatedIndex++
}
updated.data.recapitulatif_operations = newOperations
emit('update:data', updated)
}
// Ajouter une operation
function addOperation() {
const updated = cloneData()
if (!updated.data) updated.data = {}
if (!updated.data.recapitulatif_operations) updated.data.recapitulatif_operations = []
updated.data.recapitulatif_operations.push({
categorie: 'AUTRES',
sous_categorie: null,
fournisseur: 'Nouveau fournisseur',
description: null,
lot_concerne: null,
lot_numero: null,
montants: {
debit: 0,
credit: 0,
tva: 0,
locatif: 0,
deductible: 0
}
})
emit('update:data', updated)
}
</script>