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:
@@ -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>
|
||||
|
||||
184
frontend/src/components/EditableField.vue
Normal file
184
frontend/src/components/EditableField.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
254
frontend/src/components/TagAutocomplete.vue
Normal file
254
frontend/src/components/TagAutocomplete.vue
Normal 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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user