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>
304 lines
11 KiB
Vue
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>
|