Highlight versements not (fully) backed by their entrée

For "versement freelance" rows specifically: an amber outline plus a
badge when the versement either isn't linked to any entrée client at
all ("Non liée"), or is linked but reconciliationByEntree still shows
a residual on that entrée ("Reste à verser" / "Sur-versé", with the
amount in the tooltip). The residual belongs to the entrée as a whole,
not to any one sortie -- when several versements share an entrée, each
shows the same aggregate figure, since there's no way to attribute the
shortfall to one specific payment. No highlight when the linked entrée
isn't in the currently loaded window (can't tell either way -- same
accepted trade-off as reconciliationByEntree itself).

Verified: filtering to "Versement freelance" shows 21 of 25 loaded
rows flagged (17 unlinked, 4 with a reste-à-verser residual), the
remaining 4 fully-reconciled rows correctly unflagged, and the amber
outline resolves correctly in dark mode.
This commit is contained in:
2026-09-05 10:40:01 +02:00
parent ef2d7b801d
commit 1ab38bc2a7
3 changed files with 56 additions and 1 deletions
@@ -9,6 +9,7 @@
--figli-border: #dcdee2;
--figli-error: #c9312b;
--figli-positive: #1a7f37;
--figli-warning: #d97a0a;
--figli-col-hover: rgba(15, 23, 42, 0.05);
font-family: Inter, -apple-system, sans-serif;
@@ -24,6 +25,7 @@ html.gin--dark-mode #figli-home-app {
--figli-border: #3d3e42;
--figli-error: #ff6b6b;
--figli-positive: #4ade80;
--figli-warning: #f0a94e;
--figli-col-hover: rgba(255, 255, 255, 0.07);
}
@@ -187,6 +189,24 @@ html.gin--dark-mode #figli-home-app {
border-right: 1px solid var(--figli-error);
}
/* Versement freelance not (fully) backed by its entrée: same thin-outline
treatment as an error row, but amber -- it's not the ligne itself
that's inconsistent (répartition still matches montant HT), it's the
entrée/sortie relationship that isn't settled yet. Doesn't stack with
figli-error-row's red in practice (different conditions), but scoped
to :not() just in case a row is somehow both, so one outline wins
rather than the borders visually competing. */
#figli-home-app tr.figli-versement-warning-row:not(.figli-error-row) td {
border-top: 1px solid var(--figli-warning);
border-bottom: 1px solid var(--figli-warning);
}
#figli-home-app tr.figli-versement-warning-row:not(.figli-error-row) td:first-child {
border-left: 1px solid var(--figli-warning);
}
#figli-home-app tr.figli-versement-warning-row:not(.figli-error-row) td:last-child {
border-right: 1px solid var(--figli-warning);
}
#figli-home-app td.figli-ecart {
color: var(--figli-error);
font-weight: 700;
@@ -383,6 +383,35 @@
const detail = relevant.map((compte) => compte + ' : ' + this.formatEur(ecarts[compte])).join(', ');
return { detail, comptes: relevant.length };
},
// Flags a "versement freelance" row that isn't (fully) backed by
// the entrée client it pays out against: either not linked at all,
// or linked but reconciliationByEntree still shows a residual on
// that entrée. The residual belongs to the entrée as a whole, not
// to any one sortie -- when several versements share an entrée,
// each shows the same aggregate residual, since there's no way to
// say which specific one is "the" shortfall. Null (no highlight)
// when the linked entrée isn't in the currently loaded window --
// same accepted trade-off as reconciliationByEntree itself.
versementStatus(item) {
if (item.type !== 'versement') return null;
if (!item.entreeLieeId) {
return { kind: 'non-liee', detail: 'Aucune entrée client liée.' };
}
const recon = this.reconciliationByEntree.get(item.entreeLieeId);
if (!recon) return null;
if (recon.resteAVerser > 0.01) {
return { kind: 'reste', detail: 'Reste à verser sur lentrée liée : ' + this.formatEur(recon.resteAVerser) };
}
if (recon.surVerse > 0.01) {
return { kind: 'sur-verse', detail: 'Sur-versé sur lentrée liée : ' + this.formatEur(recon.surVerse) };
}
return null;
},
versementStatusLabel(kind) {
if (kind === 'non-liee') return 'Non liée';
if (kind === 'reste') return 'Reste à verser';
return 'Sur-versé';
},
// jj/mm/aa -- shorter than the API's ISO yyyy-mm-dd, saves column
// width in a table already packed with 8 compte columns.
formatDate(iso) {
@@ -101,7 +101,7 @@
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
<td :colspan="6 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr>
<tr v-else :class="{'figli-error-row': item.hasError}" :data-year="item.date ? item.date.slice(0, 4) : null">
<tr v-else :class="{'figli-error-row': item.hasError, 'figli-versement-warning-row': versementStatus(item)}" :data-year="item.date ? item.date.slice(0, 4) : null">
<td class="actions-col">
<button
v-if="item.linkable"
@@ -158,6 +158,12 @@
class="figli-recon-badge is-anomalie"
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
<span
v-if="versementStatus(item)"
class="figli-recon-badge"
:class="{'is-anomalie': versementStatus(item).kind !== 'reste', 'is-reste': versementStatus(item).kind === 'reste'}"
:title="versementStatus(item).detail"
>⚠ {{ versementStatusLabel(versementStatus(item).kind) }}</span>
</td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>