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>
481 lines
17 KiB
Vue
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>
|