Files
pdf_oralia_vibe/frontend/src/components/OperationCard.vue
Bertrand Benjamin 8c959f5d5f refactor: donne une source unique aux colonnes du compte rendu
L'ordre et les libellés des colonnes étaient retapés à six endroits : l'en-tête
du tableau, deux boucles de cellules, la ligne « Totaux », la table des libellés
et la liste des colonnes recoupées. Ces copies avaient déjà divergé — le solde
antérieur, rendu hors de la boucle, était comparé aux lignes sans que son écart
puisse s'afficher dans sa cellule. `COLONNES_CRG` devient la seule déclaration,
et tout le reste en dérive ; l'écart du solde antérieur s'affiche du coup là où
on le cherche.

`ecartsAvecLignes` rapporte maintenant l'extrait, le calculé et ce qui manque
entre les deux. Le composant refaisait cette soustraction pour son infobulle,
avec un `|| 0` là où l'utilitaire emploie `Number` et `Number.isFinite` : deux
règles de coercition pour un même calcul, libres de diverger.

Le contenu déplié d'un lot passe de `v-show` à `v-if`. Le tableau des lignes
compte une centaine de champs éditables ; les garder montés pour la vingtaine de
lots d'un document faisait re-rendre à chaque frappe deux mille champs que
personne ne regardait.

`setNestedValue` était recopié à l'identique dans trois composants et
`formatCurrency` redéfini dans le composant alors que `utils/format.js` existe
et dit lui-même que les nouveaux affichages passent par lui. Le premier part
dans `utils/chemin.js`, le second cède la place à `formatMontantPrecis`, dont le
formateur `Intl` est construit une fois pour toutes.

`EditableField` affirmait en dur que sa valeur venait du compte rendu. C'est vrai
des trois écrans qui l'emploient aujourd'hui, mais un formulaire de saisie
manuelle mentirait sans le savoir : l'origine devient une prop, avec cette
valeur par défaut.

Le type des lignes de report est défini des deux côtés de l'application sans
lien entre eux ; chacun renvoie désormais à l'autre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:18:58 +02:00

304 lines
11 KiB
Vue

<template>
<div class="card">
<!-- Header -->
<div class="w-full flex items-center justify-between px-3 py-2 bg-gray-900 hover:bg-gray-800 transition-colors">
<button
@click="expanded = !expanded"
class="flex items-center gap-2 flex-1 text-left"
>
<svg
class="w-3 h-3 text-gray-500 transition-transform flex-shrink-0"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="font-medium text-white text-sm">{{ formatCategorie(categorie) }}</span>
<span class="badge badge-neutral rounded-full">
{{ operations.length }} operation{{ operations.length > 1 ? 's' : '' }}
</span>
</button>
<div class="flex items-center gap-2">
<span
v-if="changedIndices.length"
class="badge badge-warning text-[10px] uppercase tracking-wide"
>
{{ changedIndices.length }} modifiée{{ changedIndices.length > 1 ? 's' : '' }}
</span>
<div class="text-sm font-semibold text-gray-200">
{{ formatCurrency(totalDebit) }}
</div>
<!-- Bouton ajouter operation -->
<button
@click.stop="addOperation"
class="p-1 text-gray-500 hover:text-blue-400 hover:bg-blue-500/10 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>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60">
<div class="divide-y divide-gray-800">
<div
v-for="(op, idx) in operations"
:key="idx"
:ref="el => { if (el) opRefs[idx] = el }"
class="px-3 py-2 text-xs transition-all duration-300"
:class="changedIndices.includes(idx) ? 'ring-2 ring-amber-500/50 bg-amber-500/5' : (highlightedIdx === idx ? 'ring-2 ring-blue-500/50 bg-blue-500/5' : '')"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0 space-y-1">
<!-- Sous-categorie -->
<div class="flex items-center gap-2">
<span class="text-gray-500 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>
<!-- Fournisseur -->
<div class="flex items-center gap-2">
<span class="text-gray-500 text-xs w-20">Fournisseur:</span>
<EditableField
:modelValue="op.fournisseur"
@update:modelValue="updateOperationField(idx, 'fournisseur', $event)"
type="text"
placeholder="-"
displayClass="font-medium text-gray-200"
/>
</div>
<!-- Description -->
<div class="flex items-center gap-2">
<span class="text-gray-500 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-500 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-400 font-medium text-xs"
/>
<EditableField
:modelValue="op.lot_concerne"
@update:modelValue="updateOperationField(idx, 'lot_concerne', $event)"
type="text"
placeholder="Concerne"
displayClass="text-blue-400 text-xs"
/>
</div>
</div>
<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-500 text-xs">Debit:</span>
<EditableField
:modelValue="op.montants?.debit"
@update:modelValue="updateOperationField(idx, 'montants.debit', $event)"
type="currency"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-500 text-xs">Credit:</span>
<EditableField
:modelValue="op.montants?.credit"
@update:modelValue="updateOperationField(idx, 'montants.credit', $event)"
type="currency"
displayClass="text-green-400"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-500 text-xs">TVA:</span>
<EditableField
:modelValue="op.montants?.tva"
@update:modelValue="updateOperationField(idx, 'montants.tva', $event)"
type="currency"
displayClass="text-gray-500"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-500 text-xs">Locatif:</span>
<EditableField
:modelValue="op.montants?.locatif"
@update:modelValue="updateOperationField(idx, 'montants.locatif', $event)"
type="currency"
displayClass="text-green-400 text-xs"
/>
</div>
<div class="flex items-center justify-end gap-1">
<span class="text-gray-500 text-xs">Deductible:</span>
<EditableField
:modelValue="op.montants?.deductible"
@update:modelValue="updateOperationField(idx, 'montants.deductible', $event)"
type="currency"
displayClass="text-orange-400 text-xs"
/>
</div>
</div>
<!-- Bouton supprimer -->
<button
@click="removeOperation(idx)"
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 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>
</div>
<!-- Totaux de la categorie -->
<div class="px-3 py-2 bg-gray-800 border-t border-gray-700 text-xs">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-400">Total {{ formatCategorie(categorie) }}</span>
<div class="text-right">
<span class="font-semibold text-white">{{ formatCurrency(totalDebit) }}</span>
<span v-if="totalTva > 0" class="text-gray-500 ml-2">(TVA: {{ formatCurrency(totalTva) }})</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import EditableField from './EditableField.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({
categorie: {
type: String,
required: true
},
operations: {
type: Array,
required: true
},
highlightIndex: {
type: Number,
default: -1
},
changedIndices: {
type: Array,
default: () => []
}
})
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)
})
const totalTva = computed(() => {
return props.operations.reduce((sum, op) => sum + (op.montants?.tva || 0), 0)
})
function formatCategorie(cat) {
if (!cat) return '-'
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
}
function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(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>