feat: add inline editing for extracted data and autocomplete for tagging

- Add EditableField component for inline editing of any field
- Update DataRow, LocataireCard, OperationCard to support inline editing
- Add CRUD operations for locataires and operations (add/remove)
- Update JsonViewer to propagate changes via v-model
- Add TagAutocomplete component with search/filter functionality
- Replace select dropdown with autocomplete in TaggingStep
This commit is contained in:
2026-01-19 22:02:28 +01:00
parent aac4d194e3
commit cae8d5b963
8 changed files with 1041 additions and 129 deletions

View File

@@ -1,17 +1,19 @@
<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>
<EditableField
:modelValue="value"
@update:modelValue="onUpdate"
:type="type"
:placeholder="'-'"
:displayClass="highlight ? 'text-blue-600 text-base font-medium' : 'text-gray-800 font-medium'"
:fullWidth="false"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import EditableField from './EditableField.vue'
const props = defineProps({
label: {
@@ -22,16 +24,19 @@ const props = defineProps({
type: [String, Number],
default: null
},
type: {
type: String,
default: 'text'
},
highlight: {
type: Boolean,
default: false
}
})
const displayValue = computed(() => {
if (props.value === null || props.value === undefined || props.value === '') {
return '-'
}
return props.value
})
const emit = defineEmits(['update:value'])
function onUpdate(newValue) {
emit('update:value', newValue)
}
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div
class="editable-field inline-flex items-center gap-1 group"
:class="{ 'w-full': fullWidth }"
>
<!-- Mode lecture -->
<span
v-if="!isEditing"
@click="startEditing"
class="cursor-pointer border-b border-dashed border-transparent hover:border-gray-400 hover:bg-yellow-50 px-1 py-0.5 rounded transition-colors min-w-[2rem]"
:class="[
displayClass,
{ 'text-gray-400 italic': isEmpty }
]"
:title="'Cliquer pour modifier'"
>
{{ displayValue }}
</span>
<!-- Mode edition -->
<input
v-else
ref="inputRef"
v-model="localValue"
:type="inputType"
:step="type === 'currency' || type === 'number' ? '0.01' : undefined"
:placeholder="placeholder"
@blur="saveAndClose"
@keydown.enter="saveAndClose"
@keydown.escape="cancelEditing"
class="px-1 py-0.5 border border-blue-400 rounded bg-blue-50 focus:outline-none focus:ring-1 focus:ring-blue-500 text-sm"
:class="[
inputClass,
{ 'w-full': fullWidth, 'w-24': !fullWidth && (type === 'currency' || type === 'number'), 'w-32': !fullWidth && type === 'date' }
]"
/>
<!-- Indicateur d'erreur -->
<span
v-if="error"
class="text-red-500 text-xs"
:title="error"
>
!
</span>
</div>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
default: null
},
type: {
type: String,
default: 'text',
validator: (v) => ['text', 'number', 'date', 'currency'].includes(v)
},
placeholder: {
type: String,
default: '-'
},
fullWidth: {
type: Boolean,
default: false
},
displayClass: {
type: String,
default: ''
},
inputClass: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const isEditing = ref(false)
const localValue = ref('')
const inputRef = ref(null)
const error = ref(null)
// Computed pour l'affichage
const isEmpty = computed(() => {
return props.modelValue === null || props.modelValue === undefined || props.modelValue === ''
})
const displayValue = computed(() => {
if (isEmpty.value) return props.placeholder
if (props.type === 'currency') {
const num = parseFloat(props.modelValue)
if (isNaN(num)) return props.modelValue
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(num)
}
return props.modelValue
})
const inputType = computed(() => {
if (props.type === 'currency' || props.type === 'number') return 'number'
if (props.type === 'date') return 'date'
return 'text'
})
// Demarrer l'edition
function startEditing() {
// Convertir la valeur pour l'input
if (props.type === 'currency' || props.type === 'number') {
localValue.value = props.modelValue !== null && props.modelValue !== undefined ? props.modelValue : ''
} else {
localValue.value = props.modelValue || ''
}
isEditing.value = true
error.value = null
nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}
// Valider la valeur
function validate(value) {
if (props.type === 'number' || props.type === 'currency') {
if (value === '' || value === null) return { valid: true, value: null }
const num = parseFloat(value)
if (isNaN(num)) return { valid: false, error: 'Nombre invalide' }
return { valid: true, value: num }
}
if (props.type === 'date') {
if (value === '' || value === null) return { valid: true, value: null }
// Format YYYY-MM-DD
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
if (!dateRegex.test(value)) return { valid: false, error: 'Format: YYYY-MM-DD' }
return { valid: true, value }
}
return { valid: true, value: value || null }
}
// Sauvegarder et fermer
function saveAndClose() {
const validation = validate(localValue.value)
if (!validation.valid) {
error.value = validation.error
return
}
isEditing.value = false
error.value = null
// Emettre seulement si la valeur a change
if (validation.value !== props.modelValue) {
emit('update:modelValue', validation.value)
}
}
// Annuler l'edition
function cancelEditing() {
isEditing.value = false
error.value = null
localValue.value = props.modelValue || ''
}
</script>
<style scoped>
.editable-field input[type="number"]::-webkit-inner-spin-button,
.editable-field input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.editable-field input[type="number"] {
-moz-appearance: textfield;
}
</style>

View File

@@ -66,38 +66,38 @@
<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" />
<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" />
<DataRow label="Adresse" :value="data.data?.metadata?.destinataire?.adresse" />
<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" />
<DataRow label="Date" :value="data.data?.metadata?.document?.date" />
<DataRow label="Type" :value="data.data?.metadata?.document?.type" />
<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" />
<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" />
<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="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" />
<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>
@@ -115,7 +115,20 @@
:key="idx"
:locataire="loc"
:index="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-600 hover:bg-blue-50 border border-dashed border-blue-300 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>
@@ -132,7 +145,19 @@
:key="idx"
:categorie="group.categorie"
:operations="group.operations"
@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-600 hover:bg-blue-50 border border-dashed border-blue-300 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>
@@ -159,6 +184,8 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:data'])
// Grouper les operations par categorie pour l'affichage
const operationsGroupedByCategory = computed(() => {
const operations = props.data?.data?.recapitulatif_operations
@@ -203,11 +230,6 @@ function collapseAll() {
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
@@ -221,4 +243,122 @@ async function copyJson() {
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 complete des operations
// 1. Filtrer toutes les operations de cette categorie
const otherOperations = updated.data.recapitulatif_operations.filter(op => op.categorie !== categorie)
// 2. Combiner avec les operations mises a jour
updated.data.recapitulatif_operations = [...otherOperations, ...updatedGroupOperations]
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>

View File

@@ -1,11 +1,11 @@
<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">
<div class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors">
<button
@click="expanded = !expanded"
class="flex items-center gap-3 flex-1 text-left"
>
<svg
class="w-3 h-3 text-gray-400 transition-transform flex-shrink-0"
:class="{ 'rotate-90': expanded }"
@@ -15,72 +15,193 @@
>
<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>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Lot</span>
<EditableField
:modelValue="locataire.lot?.numero"
@update:modelValue="updateField('lot.numero', $event)"
type="text"
displayClass="text-xs text-gray-700 font-medium"
@click.stop
/>
<span class="mx-1 text-gray-300">|</span>
<span class="text-xs text-gray-500">{{ locataire.lot?.type }}</span>
<EditableField
:modelValue="locataire.lot?.type"
@update:modelValue="updateField('lot.type', $event)"
type="text"
displayClass="text-xs text-gray-500"
@click.stop
/>
</div>
<span class="font-medium text-gray-800 text-sm">{{ locataire.locataire?.nom || 'Sans nom' }}</span>
<EditableField
:modelValue="locataire.locataire?.nom"
@update:modelValue="updateField('locataire.nom', $event)"
type="text"
displayClass="font-medium text-gray-800 text-sm"
placeholder="Sans nom"
@click.stop
/>
</button>
<div class="flex items-center gap-2">
<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>
<!-- Bouton supprimer -->
<button
@click.stop="$emit('remove')"
class="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="Supprimer ce locataire"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</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>
</div>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50 p-3">
<!-- Totaux -->
<!-- Totaux editables -->
<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 class="text-gray-500 mb-1">Loyers</div>
<EditableField
:modelValue="locataire.totaux?.loyers"
@update:modelValue="updateField('totaux.loyers', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
/>
</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 class="text-gray-500 mb-1">Taxes</div>
<EditableField
:modelValue="locataire.totaux?.taxes"
@update:modelValue="updateField('totaux.taxes', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
/>
</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 class="text-gray-500 mb-1">Provisions</div>
<EditableField
:modelValue="locataire.totaux?.provisions"
@update:modelValue="updateField('totaux.provisions', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
/>
</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 class="text-gray-500 mb-1">Regles</div>
<EditableField
:modelValue="locataire.totaux?.regles"
@update:modelValue="updateField('totaux.regles', $event)"
type="currency"
displayClass="font-semibold text-green-600"
/>
</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 class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
<button
@click="addLigne"
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
<svg class="w-3 h-3" 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
</button>
</div>
<div
v-for="(ligne, idx) in locataire.lignes"
:key="idx"
class="text-xs bg-white rounded p-2 flex items-center justify-between"
class="text-xs bg-white rounded p-2"
>
<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 class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 flex-1">
<!-- Type de ligne -->
<select
:value="ligne.type"
@change="updateLigneField(idx, 'type', $event.target.value)"
class="px-1.5 py-0.5 rounded text-xs border border-gray-200 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="loyer">loyer</option>
<option value="solde_anterieur">solde_anterieur</option>
<option value="rappel_loyer">rappel_loyer</option>
<option value="divers">divers</option>
</select>
<!-- Periode -->
<div class="flex items-center gap-1 text-gray-500">
<EditableField
:modelValue="ligne.periode?.debut"
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
type="date"
placeholder="debut"
displayClass="text-xs"
/>
<span>-</span>
<EditableField
:modelValue="ligne.periode?.fin"
@update:modelValue="updateLigneField(idx, 'periode.fin', $event)"
type="date"
placeholder="fin"
displayClass="text-xs"
/>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Total -->
<EditableField
:modelValue="ligne.total || ligne.loyers"
@update:modelValue="updateLigneField(idx, 'total', $event)"
type="currency"
displayClass="font-medium text-gray-800"
/>
<!-- Bouton supprimer ligne -->
<button
@click="removeLigne(idx)"
class="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="Supprimer cette ligne"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Bouton ajouter ligne si aucune -->
<div v-else class="text-center py-2">
<button
@click="addLigne"
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-600 hover:bg-blue-50 rounded transition-colors mx-auto"
>
<svg class="w-3 h-3" 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 ligne
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import EditableField from './EditableField.vue'
const props = defineProps({
locataire: {
@@ -93,6 +214,8 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:locataire', 'remove'])
const expanded = ref(false)
const totalClass = computed(() => {
@@ -107,13 +230,64 @@ function formatCurrency(value) {
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'
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
function updateField(path, value) {
const updated = JSON.parse(JSON.stringify(props.locataire))
setNestedValue(updated, path, value)
emit('update:locataire', updated)
}
// 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]]
}
return classes[type] || 'bg-gray-100 text-gray-700'
current[parts[parts.length - 1]] = value
}
// Mettre a jour un champ d'une ligne
function updateLigneField(ligneIndex, path, value) {
const updated = JSON.parse(JSON.stringify(props.locataire))
if (!updated.lignes) updated.lignes = []
if (path.includes('.')) {
setNestedValue(updated.lignes[ligneIndex], path, value)
} else {
updated.lignes[ligneIndex][path] = value
}
emit('update:locataire', updated)
}
// Ajouter une nouvelle ligne
function addLigne() {
const updated = JSON.parse(JSON.stringify(props.locataire))
if (!updated.lignes) updated.lignes = []
updated.lignes.push({
type: 'loyer',
periode: { debut: null, fin: null },
loyers: 0,
taxes: 0,
provisions: 0,
divers: { montant: 0, libelle: null },
total: 0,
regles: 0,
impayes: 0
})
emit('update:locataire', updated)
}
// Supprimer une ligne
function removeLigne(ligneIndex) {
const updated = JSON.parse(JSON.stringify(props.locataire))
updated.lignes.splice(ligneIndex, 1)
emit('update:locataire', updated)
}
</script>

View File

@@ -1,11 +1,11 @@
<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">
<div class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors">
<button
@click="expanded = !expanded"
class="flex items-center gap-2 flex-1 text-left"
>
<svg
class="w-3 h-3 text-gray-400 transition-transform flex-shrink-0"
:class="{ 'rotate-90': expanded }"
@@ -19,11 +19,23 @@
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{{ operations.length }} operation{{ operations.length > 1 ? 's' : '' }}
</span>
</button>
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-gray-700">
{{ formatCurrency(totalDebit) }}
</div>
<!-- Bouton ajouter operation -->
<button
@click.stop="addOperation"
class="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded transition-colors"
title="Ajouter une operation"
>
<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>
</div>
<div class="text-sm font-semibold text-gray-700">
{{ formatCurrency(totalDebit) }}
</div>
</button>
</div>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50">
@@ -34,31 +46,124 @@
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.sous_categorie" class="text-gray-400 text-xs mb-0.5">
{{ op.sous_categorie }}
<div class="flex-1 min-w-0 space-y-1">
<!-- Sous-categorie -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Sous-cat:</span>
<EditableField
:modelValue="op.sous_categorie"
@update:modelValue="updateOperationField(idx, 'sous_categorie', $event)"
type="text"
placeholder="-"
displayClass="text-xs text-gray-500"
/>
</div>
<div v-if="op.fournisseur" class="font-medium text-gray-800 truncate">
{{ op.fournisseur }}
<!-- Fournisseur -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Fournisseur:</span>
<EditableField
:modelValue="op.fournisseur"
@update:modelValue="updateOperationField(idx, 'fournisseur', $event)"
type="text"
placeholder="-"
displayClass="font-medium text-gray-800"
/>
</div>
<div class="text-gray-500 truncate">{{ op.description || '-' }}</div>
<div v-if="op.lot_numero || op.lot_concerne" class="text-blue-500 text-xs mt-0.5">
<span v-if="op.lot_numero" class="font-medium">Lot {{ op.lot_numero }}</span>
<span v-if="op.lot_numero && op.lot_concerne"> - </span>
<span v-if="op.lot_concerne">{{ op.lot_concerne }}</span>
<!-- Description -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Description:</span>
<EditableField
:modelValue="op.description"
@update:modelValue="updateOperationField(idx, 'description', $event)"
type="text"
placeholder="-"
displayClass="text-gray-500"
:fullWidth="true"
/>
</div>
<!-- Lot -->
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs w-20">Lot:</span>
<EditableField
:modelValue="op.lot_numero"
@update:modelValue="updateOperationField(idx, 'lot_numero', $event)"
type="text"
placeholder="N°"
displayClass="text-blue-500 font-medium text-xs"
/>
<EditableField
:modelValue="op.lot_concerne"
@update:modelValue="updateOperationField(idx, 'lot_concerne', $event)"
type="text"
placeholder="Concerne"
displayClass="text-blue-500 text-xs"
/>
</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 v-if="op.montants?.locatif" class="text-green-600 text-xs">
Locatif: {{ formatCurrency(op.montants.locatif) }}
</div>
<div v-if="op.montants?.deductible" class="text-orange-600 text-xs">
Deductible: {{ formatCurrency(op.montants.deductible) }}
<div class="flex items-start gap-2">
<!-- Montants -->
<div class="text-right space-y-0.5">
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Debit:</span>
<EditableField
:modelValue="op.montants?.debit"
@update:modelValue="updateOperationField(idx, 'montants.debit', $event)"
type="currency"
displayClass="font-semibold text-gray-800"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Credit:</span>
<EditableField
:modelValue="op.montants?.credit"
@update:modelValue="updateOperationField(idx, 'montants.credit', $event)"
type="currency"
displayClass="text-green-600"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">TVA:</span>
<EditableField
:modelValue="op.montants?.tva"
@update:modelValue="updateOperationField(idx, 'montants.tva', $event)"
type="currency"
displayClass="text-gray-400"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Locatif:</span>
<EditableField
:modelValue="op.montants?.locatif"
@update:modelValue="updateOperationField(idx, 'montants.locatif', $event)"
type="currency"
displayClass="text-green-600 text-xs"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-400 text-xs">Deductible:</span>
<EditableField
:modelValue="op.montants?.deductible"
@update:modelValue="updateOperationField(idx, 'montants.deductible', $event)"
type="currency"
displayClass="text-orange-600 text-xs"
/>
</div>
</div>
<!-- Bouton supprimer -->
<button
@click="removeOperation(idx)"
class="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="Supprimer cette operation"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
@@ -80,6 +185,7 @@
<script setup>
import { ref, computed } from 'vue'
import EditableField from './EditableField.vue'
const props = defineProps({
categorie: {
@@ -92,6 +198,8 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:operations'])
const expanded = ref(false)
const totalDebit = computed(() => {
@@ -104,7 +212,6 @@ const totalTva = computed(() => {
function formatCategorie(cat) {
if (!cat) return '-'
// Convertir DEPENSES_LOCATIVES -> Depenses locatives
return cat
.replace(/_/g, ' ')
.toLowerCase()
@@ -115,4 +222,60 @@ function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
}
// 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 un champ d'une operation
function updateOperationField(opIndex, path, value) {
const updatedOperations = JSON.parse(JSON.stringify(props.operations))
if (path.includes('.')) {
setNestedValue(updatedOperations[opIndex], path, value)
} else {
updatedOperations[opIndex][path] = value
}
emit('update:operations', updatedOperations)
}
// Ajouter une nouvelle operation dans cette categorie
function addOperation() {
const updatedOperations = JSON.parse(JSON.stringify(props.operations))
updatedOperations.push({
categorie: props.categorie,
sous_categorie: null,
fournisseur: null,
description: null,
lot_concerne: null,
lot_numero: null,
montants: {
debit: 0,
credit: 0,
tva: 0,
locatif: 0,
deductible: 0
}
})
emit('update:operations', updatedOperations)
}
// Supprimer une operation
function removeOperation(opIndex) {
const updatedOperations = JSON.parse(JSON.stringify(props.operations))
updatedOperations.splice(opIndex, 1)
emit('update:operations', updatedOperations)
}
</script>

View File

@@ -0,0 +1,254 @@
<template>
<div class="relative" ref="containerRef">
<!-- Input field -->
<div class="relative">
<input
ref="inputRef"
type="text"
v-model="searchQuery"
@focus="onFocus"
@blur="onBlur"
@keydown.down.prevent="navigateDown"
@keydown.up.prevent="navigateUp"
@keydown.enter.prevent="selectHighlighted"
@keydown.escape="closeDropdown"
@keydown.tab="closeDropdown"
:placeholder="placeholder"
class="w-full px-3 py-2 pr-8 border rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
:class="inputClasses"
/>
<!-- Clear button -->
<button
v-if="modelValue"
@mousedown.prevent="clearSelection"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 rounded"
type="button"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Dropdown arrow -->
<button
v-else
@mousedown.prevent="toggleDropdown"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 rounded"
type="button"
>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': isOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- Dropdown -->
<div
v-show="isOpen && filteredOptions.length > 0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<div
v-for="(option, index) in filteredOptions"
:key="option.id"
@mousedown.prevent="selectOption(option)"
@mouseenter="highlightedIndex = index"
class="px-3 py-2 cursor-pointer text-sm transition-colors"
:class="{
'bg-blue-50 text-blue-700': index === highlightedIndex,
'hover:bg-gray-50': index !== highlightedIndex
}"
>
<span v-html="highlightMatch(option.nom)"></span>
</div>
</div>
<!-- No results -->
<div
v-show="isOpen && searchQuery && filteredOptions.length === 0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg p-3 text-sm text-gray-500"
>
Aucun tag trouvé pour "{{ searchQuery }}"
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: [Number, null],
default: null
},
options: {
type: Array,
required: true,
// Expected format: [{ id: 1, nom: 'Tag name' }, ...]
},
placeholder: {
type: String,
default: 'Rechercher un tag...'
},
hasPrediction: {
type: Boolean,
default: false
},
isEmpty: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue'])
const containerRef = ref(null)
const inputRef = ref(null)
const searchQuery = ref('')
const isOpen = ref(false)
const highlightedIndex = ref(0)
const isUserInput = ref(true)
// Compute input classes based on state
const inputClasses = computed(() => {
if (props.modelValue && props.hasPrediction) {
return 'bg-green-50 border-green-300'
}
if (!props.modelValue) {
return 'bg-yellow-50 border-yellow-300'
}
return 'border-gray-300'
})
// Get selected option name for display
const selectedOption = computed(() => {
if (!props.modelValue) return null
return props.options.find(opt => opt.id === props.modelValue)
})
// Filter options based on search query
const filteredOptions = computed(() => {
if (!searchQuery.value) {
return props.options
}
const query = searchQuery.value.toLowerCase().trim()
return props.options.filter(opt =>
opt.nom.toLowerCase().includes(query)
)
})
// Watch for external modelValue changes to update display
watch(() => props.modelValue, (newVal) => {
if (newVal && isUserInput.value) {
const option = props.options.find(opt => opt.id === newVal)
if (option) {
searchQuery.value = option.nom
}
}
}, { immediate: true })
// Watch options to update display when they load
watch(() => props.options, () => {
if (props.modelValue) {
const option = props.options.find(opt => opt.id === props.modelValue)
if (option) {
searchQuery.value = option.nom
}
}
}, { immediate: true })
function onFocus() {
isOpen.value = true
highlightedIndex.value = 0
// Select all text on focus for easy replacement
nextTick(() => {
inputRef.value?.select()
})
}
function onBlur() {
// Delay closing to allow click on option
setTimeout(() => {
isOpen.value = false
// If nothing selected, restore previous selection or clear
if (!searchQuery.value && props.modelValue) {
const option = props.options.find(opt => opt.id === props.modelValue)
if (option) {
searchQuery.value = option.nom
}
} else if (searchQuery.value && !props.modelValue) {
// Check if text matches an option exactly
const exactMatch = props.options.find(
opt => opt.nom.toLowerCase() === searchQuery.value.toLowerCase()
)
if (exactMatch) {
selectOption(exactMatch)
} else {
searchQuery.value = ''
}
}
}, 150)
}
function toggleDropdown() {
if (isOpen.value) {
closeDropdown()
} else {
inputRef.value?.focus()
}
}
function closeDropdown() {
isOpen.value = false
}
function navigateDown() {
if (!isOpen.value) {
isOpen.value = true
return
}
if (highlightedIndex.value < filteredOptions.value.length - 1) {
highlightedIndex.value++
}
}
function navigateUp() {
if (highlightedIndex.value > 0) {
highlightedIndex.value--
}
}
function selectHighlighted() {
if (filteredOptions.value[highlightedIndex.value]) {
selectOption(filteredOptions.value[highlightedIndex.value])
}
}
function selectOption(option) {
isUserInput.value = false
searchQuery.value = option.nom
emit('update:modelValue', option.id)
isOpen.value = false
nextTick(() => {
isUserInput.value = true
})
}
function clearSelection() {
searchQuery.value = ''
emit('update:modelValue', null)
inputRef.value?.focus()
}
function highlightMatch(text) {
if (!searchQuery.value) return text
const query = searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<mark class="bg-yellow-200 rounded px-0.5">$1</mark>')
}
</script>

View File

@@ -49,25 +49,15 @@
</div>
<!-- Tag selector -->
<div class="flex-shrink-0 w-48">
<select
<div class="flex-shrink-0 w-56">
<TagAutocomplete
v-model="selectedTags[index]"
@change="onTagChange(index)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
:class="{
'bg-green-50 border-green-300': selectedTags[index] && predictions[index]?.tag_id,
'bg-yellow-50 border-yellow-300': !selectedTags[index]
}"
>
<option :value="null">-- Sélectionner un tag --</option>
<option
v-for="tag in availableTags"
:key="tag.id"
:value="tag.id"
>
{{ tag.nom }}
</option>
</select>
:options="availableTags"
:has-prediction="!!predictions[index]?.tag_id"
:is-empty="!selectedTags[index]"
placeholder="Rechercher un tag..."
@update:modelValue="onTagChange(index)"
/>
</div>
</div>
@@ -126,6 +116,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import TagAutocomplete from './TagAutocomplete.vue'
const props = defineProps({
depenses: {

View File

@@ -106,7 +106,8 @@
<!-- JSON Viewer (only shown when not tagging) -->
<JsonViewer
v-if="pdfFile && !showTagging"
:data="extractedData"
:data="extractedData"
@update:data="extractedData = $event"
:is-loading="isExtracting"
class="flex-1 overflow-hidden"
/>