feat: highlight source element when navigating from tables to edit page
Clicking the pencil icon in revenus/depenses tables now passes query params to identify the source row. The edit page auto-expands the matching section, scrolls to the element, and briefly highlights it with a blue ring that fades after 2 seconds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -110,11 +110,12 @@
|
||||
@toggle="toggleSection('locataires')"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<LocataireCard
|
||||
v-for="(loc, idx) in data.data?.situation_locataires"
|
||||
<LocataireCard
|
||||
v-for="(loc, idx) in data.data?.situation_locataires"
|
||||
:key="idx"
|
||||
:locataire="loc"
|
||||
:index="idx"
|
||||
:highlighted="highlightedLocataireIndex === idx"
|
||||
@update:locataire="updateLocataire(idx, $event)"
|
||||
@remove="removeLocataire(idx)"
|
||||
/>
|
||||
@@ -140,11 +141,12 @@
|
||||
@toggle="toggleSection('operations')"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<OperationCard
|
||||
v-for="(group, idx) in operationsGroupedByCategory"
|
||||
<OperationCard
|
||||
v-for="(group, idx) in operationsGroupedByCategory"
|
||||
:key="idx"
|
||||
:categorie="group.categorie"
|
||||
:operations="group.operations"
|
||||
:highlightIndex="group.categorie === highlightOperationCategorie ? highlightOperationIndex : -1"
|
||||
@update:operations="updateOperationsGroup(group.categorie, $event)"
|
||||
/>
|
||||
|
||||
@@ -166,7 +168,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import DataSection from './DataSection.vue'
|
||||
import DataCard from './DataCard.vue'
|
||||
import DataRow from './DataRow.vue'
|
||||
@@ -181,6 +183,10 @@ const props = defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
highlight: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -214,6 +220,55 @@ const expandedSections = reactive({
|
||||
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 }
|
||||
)
|
||||
|
||||
function toggleSection(section) {
|
||||
expandedSections[section] = !expandedSections[section]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div ref="rootEl" class="border rounded-lg overflow-hidden transition-all duration-300" :class="isHighlighted ? 'border-blue-400 ring-2 ring-blue-400 bg-blue-50' : 'border-gray-200'">
|
||||
<!-- Header -->
|
||||
<div class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors">
|
||||
<button
|
||||
@@ -200,7 +200,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import EditableField from './EditableField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -211,12 +211,31 @@ const props = defineProps({
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
highlighted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:locataire', 'remove'])
|
||||
|
||||
const expanded = ref(false)
|
||||
const rootEl = ref(null)
|
||||
const isHighlighted = ref(false)
|
||||
|
||||
watch(() => props.highlighted, (val) => {
|
||||
if (val) {
|
||||
expanded.value = true
|
||||
isHighlighted.value = true
|
||||
nextTick(() => {
|
||||
rootEl.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
setTimeout(() => {
|
||||
isHighlighted.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const totalClass = computed(() => {
|
||||
const total = props.locataire.totaux?.total || 0
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
<!-- Content -->
|
||||
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50">
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="(op, idx) in operations"
|
||||
<div
|
||||
v-for="(op, idx) in operations"
|
||||
:key="idx"
|
||||
class="px-3 py-2 bg-white text-xs"
|
||||
:ref="el => { if (el) opRefs[idx] = el }"
|
||||
class="px-3 py-2 bg-white text-xs transition-all duration-300"
|
||||
:class="highlightedIdx === idx ? 'ring-2 ring-blue-400 bg-blue-50' : ''"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
@@ -184,7 +186,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import EditableField from './EditableField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -195,12 +197,34 @@ const props = defineProps({
|
||||
operations: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
highlightIndex: {
|
||||
type: Number,
|
||||
default: -1
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:operations'])
|
||||
|
||||
const expanded = ref(false)
|
||||
const opRefs = ref({})
|
||||
const highlightedIdx = ref(-1)
|
||||
|
||||
watch(() => props.highlightIndex, (val) => {
|
||||
if (val >= 0) {
|
||||
expanded.value = true
|
||||
highlightedIdx.value = val
|
||||
nextTick(() => {
|
||||
const el = opRefs.value[val]
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
setTimeout(() => {
|
||||
highlightedIdx.value = -1
|
||||
}, 2000)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const totalDebit = computed(() => {
|
||||
return props.operations.reduce((sum, op) => sum + (op.montants?.debit || 0), 0)
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-400 uppercase">Tag</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Debit</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-400 uppercase">Credit</th>
|
||||
<th class="px-3 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800">
|
||||
@@ -82,6 +83,17 @@
|
||||
<td class="px-3 py-2 text-right text-green-400 font-mono whitespace-nowrap">
|
||||
{{ dep.credit > 0 ? formatCurrency(dep.credit) : '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<router-link
|
||||
:to="{ path: '/documents/' + dep.document_id + '/edit', query: { highlight: 'operation', fournisseur: dep.fournisseur, description: dep.description } }"
|
||||
class="text-gray-500 hover:text-blue-400 transition-colors"
|
||||
title="Editer le document source"
|
||||
>
|
||||
<svg class="w-4 h-4 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<th class="px-4 py-3 text-right">Total</th>
|
||||
<th class="px-4 py-3 text-right">Regle</th>
|
||||
<th class="px-4 py-3 text-right">Impaye</th>
|
||||
<th class="px-4 py-3 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800">
|
||||
@@ -77,6 +78,17 @@
|
||||
{{ formatAmount(revenu.impayes) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<router-link
|
||||
:to="{ path: '/documents/' + revenu.document_id + '/edit', query: { highlight: 'locataire', lot_numero: revenu.lot_numero, locataire_nom: revenu.locataire_nom } }"
|
||||
class="text-gray-500 hover:text-blue-400 transition-colors"
|
||||
title="Editer le document source"
|
||||
>
|
||||
<svg class="w-4 h-4 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -77,11 +77,12 @@
|
||||
<!-- Droite : Édition ou Tagging -->
|
||||
<div class="w-1/2 flex flex-col bg-gray-50">
|
||||
<!-- Vue 1 : JsonViewer (mode édition) -->
|
||||
<JsonViewer
|
||||
<JsonViewer
|
||||
v-if="!showTagging && extractedData"
|
||||
:data="extractedData"
|
||||
@update:data="extractedData = $event"
|
||||
:is-loading="false"
|
||||
:highlight="highlightInfo"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
@@ -110,7 +111,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import PdfPreview from '../components/PdfPreview.vue'
|
||||
import JsonViewer from '../components/JsonViewer.vue'
|
||||
@@ -127,6 +128,13 @@ const showTagging = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const saveError = ref(null)
|
||||
|
||||
const highlightInfo = computed(() => {
|
||||
const q = route.query
|
||||
if (q.highlight === 'locataire') return { type: 'locataire', lot_numero: q.lot_numero, locataire_nom: q.locataire_nom }
|
||||
if (q.highlight === 'operation') return { type: 'operation', fournisseur: q.fournisseur, description: q.description }
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDocument()
|
||||
})
|
||||
|
||||
@@ -99,6 +99,7 @@ class RevenuDetailResponse(BaseModel):
|
||||
"""Detail d'un revenu."""
|
||||
|
||||
id: int
|
||||
document_id: int
|
||||
document_date: str
|
||||
document_reference: str | None
|
||||
immeuble_code: str
|
||||
@@ -442,6 +443,7 @@ async def get_revenus_details(
|
||||
results.append(
|
||||
RevenuDetailResponse(
|
||||
id=rev.id,
|
||||
document_id=rev.document_id,
|
||||
document_date=str(row.document_date),
|
||||
document_reference=row.document_reference,
|
||||
immeuble_code=row.immeuble_code,
|
||||
|
||||
Reference in New Issue
Block a user