Compare commits

...
9 Commits
Author SHA1 Message Date
bachirandClaude Sonnet 5 4636fc3522 Color and bold Montant HT to distinguish entrées from sorties
Single anchor per row -- Montant TTC and the 8 compte columns stay
neutral, so the table doesn't turn into a red/green garland. No 0.5€
threshold like soldeClass (used for aggregate totals): individual
lines are often small (a -1.07€ OVH renewal shouldn't read as
"neutral"), any nonzero sign gets colored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:53:55 +02:00
bachirandClaude Sonnet 5 8eb92c613d Fix totals footer painting behind sticky group-row headers on scroll
tfoot had position: sticky but no explicit z-index (auto/0), while the
sticky month/year group-row headers use z-index: 1 -- so a group row
scrolling past the bottom painted on top of the totals footer instead
of behind it. z-index: 3 (above thead's 2 and the group-row's 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:50:23 +02:00
bachirandClaude Sonnet 5 13461d0cb1 Split action icons into their own columns, tighten spacing, no crosshair
Link and edit icons get one dedicated column each instead of sharing
one text-align: center cell -- with a shared cell, a row with only the
edit icon (non-linkable types) centered differently than a row with
both icons, so pencils never lined up vertically across rows.
Separate columns line up by construction. Verified: identical left
offset (141px) for the edit icon across 15 consecutive rows.

Also: tighter cell padding (was using the table's default 0.6rem
horizontal padding, way more than an icon needs) and no column-hover
crosshair on these technical columns -- nothing meaningful to compare
across rows there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:47:44 +02:00
bachirandClaude Sonnet 5 5f4ec4dbee Fix actions column layout: display:flex on a <td> broke the table
display: flex on td.actions-col was fighting the table's own
column-width calculation -- a ~55px gap opened up between the actions
column and Date, and the buttons rendered outside their cell's actual
boundary. Buttons go back to sitting inline (already inline-flex
individually) inside a plain table-cell, spaced with a margin on the
first one instead of a flex gap on the container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:44:24 +02:00
bachirandClaude Sonnet 5 f3ce872b02 Compact date format, move action buttons to the left
Dates render as jj/mm/aa instead of the API's ISO yyyy-mm-dd -- saves
width in a table already packed with 8 compte columns. Actions column
(edit pencil, link icon) moves from the right end to the first
column. Column-hover crosshair logic is unaffected: it walks colSpan
positions dynamically rather than assuming a fixed index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:37:06 +02:00
bachirandClaude Sonnet 5 0e36274d51 Show montant HT + notes in the entrée client autocomplete
Several entrées for the same client often share the same title
format ("EPAU - 2026-01-05"), which isn't enough to tell them apart
when linking a sortie. Append the amount and the notes/invoice
reference to each suggestion's label -- verified end to end (real
autocomplete HTTP request, then an actual save) that the richer label
still parses back to the right node id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:33:08 +02:00
bachirandClaude Sonnet 5 d04d0e8824 Link sorties to the entrée client they pay out against
New field_entree_liee (entity reference, node -> node) on
ligne_comptable, restricted to entrée-type nodes via a custom
EntreeClientSelection plugin -- narrows further to the same client as
the sortie being linked when one is already known, using the
referencing-entity context Drupal's selection handler API passes
through (getSelectionHandler($field, $entity)).

Quick-link UI (per the associates' explicit ask: no need to open the
full ligne_comptable form just for this):
- A chain-link icon next to the edit pencil on versement/achat/
  hébergement rows (the only types that pay out against a client
  invoice) opens LinkEntreeForm, a one-field AJAX modal, reusing the
  same modal/close-on-save plumbing as the edit form. Filled/colored
  when already linked, with the linked entrée's label on hover.
  Also present (states-hidden unless one of those three types is
  selected) on the full node form for whoever's already there anyway.
- Entrée rows get a reconciliation badge once at least one sortie
  links back to them, clickable to drill the table down to just that
  entrée and its linked sorties.

Conformity check assumes multi-compte répartition on both sides (an
entrée's répartition and each linked sortie's répartition can each
split across several comptes -- confirmed this is the real shape of
"hébergement" sorties, e.g. OVH/HETZNER renewals split across all 8
comptes, even though versement/achat lines happen to always be
single-compte in the current data). Per compte, compares the entrée's
répartition share (positive, owed) against the summed répartition of
every linked sortie (negative, paid) -- a residual near zero means
settled, positive means still owed ("reste à verser"), negative means
overpaid ("sur-versé", flagged for a closer look).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:22:47 +02:00
bachirandClaude Sonnet 5 27a799e937 Lock down the generic entity autocomplete route to logged-in users
Found while adding field_entree_liee's autocomplete: core's
system.entity_autocomplete route is '_access: TRUE' by design --
EntityQuery::accessCheck() only enforces node grants (published
status), not the base 'access content' permission, so it doesn't
respect this site's login-only lockdown the way every other route
does. Verified anonymously via plain curl that the *existing*
field_client (client taxonomy) autocomplete leaked real client names
the same way -- this isn't specific to the new field, it's a gap in
any entity reference autocomplete on the site.

hook_route_alter() doesn't exist anymore in this Drupal version
(routing moved to an event-based RoutingEvents::ALTER subscriber) --
a first attempt using the procedural hook silently did nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:22:26 +02:00
bachirandClaude Sonnet 5 384b283547 Mandatory revisions on ligne_comptable + cross-node history page
Every save now forces a new revision, unconditionally:
- Form: hide the "Create new revision" checkbox and the log message
  field (#access = FALSE, not just a default) so submitted values for
  either can't override them -- Form API discards user input for
  #access-denied elements and falls back to #default_value.
- hook_node_presave(): the same thing enforced for any save that
  doesn't go through the form (drush scripts, etc.), plus explicitly
  setting the revision author (current user) and revision timestamp.
  setNewRevision(TRUE) alone does NOT refresh revision_timestamp -- it
  carries over the previous revision's value, which would silently
  mislabel every edit with its predecessor's save time. Verified via a
  drush test save before/after.
- Applies regardless of figli_compta_ledger.skip_validation: that flag
  is about the répartition-sum check on historical imports, a
  different concern -- revision history is never exempted.

New /lignes/historique page (HistoryController): a single reverse-
chronological feed across every ligne_comptable's revisions, gated by
the 'view ligne_comptable revisions' permission (granted to all three
associate roles). Conceptually a revision of one ligne is a revision
of the grand livre as a whole, so this aggregates across nodes rather
than reusing Drupal's per-node revision history page. Each row links
to that specific revision via core's existing revision-view route.
Linked from the /lignes toolbar, shown only when the current user has
the permission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:25:00 +02:00
20 changed files with 646 additions and 19 deletions
@@ -5,6 +5,7 @@ dependencies:
config: config:
- field.field.node.ligne_comptable.field_client - field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne - field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_montant_ht - field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc - field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes - field.field.node.ligne_comptable.field_notes
@@ -42,6 +43,16 @@ content:
region: content region: content
settings: { } settings: { }
third_party_settings: { } third_party_settings: { }
field_entree_liee:
type: entity_reference_autocomplete
weight: 2
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
field_montant_ht: field_montant_ht:
type: number type: number
weight: 3 weight: 3
@@ -5,6 +5,7 @@ dependencies:
config: config:
- field.field.node.ligne_comptable.field_client - field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne - field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_montant_ht - field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc - field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes - field.field.node.ligne_comptable.field_notes
@@ -38,6 +39,14 @@ content:
third_party_settings: { } third_party_settings: { }
weight: 0 weight: 0
region: content region: content
field_entree_liee:
type: entity_reference_label
label: above
settings:
link: true
third_party_settings: { }
weight: 2
region: content
field_montant_ht: field_montant_ht:
type: number_decimal type: number_decimal
label: above label: above
@@ -0,0 +1,23 @@
uuid: c2d68287-8e24-4529-a035-39395acca4d3
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_entree_liee
- node.type.ligne_comptable
id: node.ligne_comptable.field_entree_liee
field_name: field_entree_liee
entity_type: node
bundle: ligne_comptable
label: 'Entrée client liée'
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
handler: 'figli_compta_ledger:entree_client'
handler_settings:
target_bundles:
ligne_comptable: ligne_comptable
field_type: entity_reference
@@ -0,0 +1,19 @@
uuid: d7148451-7a96-4515-b0b5-42ad9fecbdce
langcode: en
status: true
dependencies:
module:
- node
id: node.field_entree_liee
field_name: field_entree_liee
entity_type: node
type: entity_reference
settings:
target_type: node
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
+1
View File
@@ -16,3 +16,4 @@ permissions:
- 'create ligne_comptable content' - 'create ligne_comptable content'
- 'delete any ligne_comptable content' - 'delete any ligne_comptable content'
- 'edit any ligne_comptable content' - 'edit any ligne_comptable content'
- 'view ligne_comptable revisions'
+1
View File
@@ -16,3 +16,4 @@ permissions:
- 'create ligne_comptable content' - 'create ligne_comptable content'
- 'delete any ligne_comptable content' - 'delete any ligne_comptable content'
- 'edit any ligne_comptable content' - 'edit any ligne_comptable content'
- 'view ligne_comptable revisions'
+4
View File
@@ -2,7 +2,10 @@ uuid: 24f4e46d-c714-486d-b000-f092eb727470
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
config:
- node.type.ligne_comptable
module: module:
- node
- system - system
id: user id: user
label: 'Utilisateur (lecture seule)' label: 'Utilisateur (lecture seule)'
@@ -10,3 +13,4 @@ weight: 4
is_admin: false is_admin: false
permissions: permissions:
- 'access content' - 'access content'
- 'view ligne_comptable revisions'
@@ -0,0 +1,5 @@
# EntreeClientSelection only adds query conditions on top of NodeSelection
# (itself a DefaultSelection subclass) -- no new handler_settings keys, so
# it uses the same schema shape as the core 'default' handler.
entity_reference_selection.figli_compta_ledger:
type: entity_reference_selection.default
@@ -132,6 +132,10 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app tfoot { #figli-home-app tfoot {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
/* Higher than thead (2) and the sticky group-row headers (1) -- without
this, tfoot had no explicit z-index (auto), so a group-row header
scrolling underneath it painted on top instead of behind. */
z-index: 3;
} }
#figli-home-app tr.figli-totals-row td { #figli-home-app tr.figli-totals-row td {
@@ -149,6 +153,18 @@ html.gin--dark-mode #figli-home-app {
color: var(--figli-error); color: var(--figli-error);
} }
/* Single colored + bold anchor per row (Montant HT only) so entrées vs
sorties are scannable at a glance -- everything else in the row (TTC,
the 8 compte columns) stays neutral, not a red/green garland. */
#figli-home-app td.figli-montant-positif {
color: var(--figli-positive);
font-weight: 700;
}
#figli-home-app td.figli-montant-negatif {
color: var(--figli-error);
font-weight: 700;
}
/* Error rows: a thin red outline around the row, not a background fill -- /* Error rows: a thin red outline around the row, not a background fill --
easier to read, doesn't fight with dark mode. */ easier to read, doesn't fight with dark mode. */
#figli-home-app tr.figli-error-row td { #figli-home-app tr.figli-error-row td {
@@ -198,32 +214,79 @@ html.gin--dark-mode #figli-home-app {
font-size: 0.75rem; font-size: 0.75rem;
} }
/* One column per icon (link, edit) rather than one shared cell -- with a
shared cell, text-align: center puts a lone button (rows with no link
icon) in a different spot than a row with both, so the edit pencil
never lined up from row to row. Separate columns line up by
construction, and get a tight, icon-sized width instead of the
table's default 0.6rem horizontal cell padding. */
#figli-home-app td.actions-col, #figli-home-app td.actions-col,
#figli-home-app th.actions-col { #figli-home-app th.actions-col {
width: 1%; width: 1%;
padding: 0.2rem 0.1rem;
text-align: center; text-align: center;
white-space: nowrap;
} }
#figli-home-app .figli-edit-btn { /* Buttons stay inline (not flex) inside the <td> -- display: flex on a
table cell breaks the table's own column-width calculation (observed:
a ~55px gap opening up between this column and the next, and the
buttons rendering outside their cell's actual boundary). */
#figli-home-app .figli-edit-btn,
#figli-home-app .figli-link-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 1.6rem; width: 1.4rem;
height: 1.6rem; height: 1.4rem;
padding: 0; padding: 0;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 4px; border-radius: 4px;
color: var(--figli-text-light); color: var(--figli-text-light);
cursor: pointer; cursor: pointer;
vertical-align: middle;
} }
#figli-home-app .figli-edit-btn:hover { #figli-home-app .figli-edit-btn:hover,
#figli-home-app .figli-link-btn:hover {
background: var(--figli-bg-alt); background: var(--figli-bg-alt);
border-color: var(--figli-border); border-color: var(--figli-border);
color: var(--figli-text); color: var(--figli-text);
} }
#figli-home-app .figli-link-btn.is-linked {
color: #3b6fe0;
}
/* Reconciliation badge on entrée rows: répartition (money owed) vs the
combined répartition of every linked sortie (money actually paid out). */
#figli-home-app .figli-recon-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.05rem 0.4rem;
border-radius: 10px;
font-size: 0.68rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: #1a7f371a;
color: var(--figli-positive);
}
#figli-home-app .figli-recon-badge.is-reste {
background: #d97a0a1a;
color: #d97a0a;
}
#figli-home-app .figli-recon-badge.is-anomalie {
background: #c9312b1a;
color: var(--figli-error);
}
#figli-home-app .figli-clear-drilldown {
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
}
#figli-home-app .figli-error { #figli-home-app .figli-error {
background: #fde8e8; background: #fde8e8;
border: 1px solid #f4a3a3; border: 1px solid #f4a3a3;
@@ -13,3 +13,11 @@ figli_compta_ledger.dashboard:
menu_name: admin menu_name: admin
parent: system.admin parent: system.admin
weight: -10 weight: -10
figli_compta_ledger.history:
title: 'Historique du grand livre'
description: 'Toutes les révisions de toutes les lignes comptables'
route_name: figli_compta_ledger.history
menu_name: admin
parent: system.admin
weight: -9
@@ -2,8 +2,11 @@
/** /**
* @file * @file
* Enforces the one invariant that explained most of the errors found in the * Enforces two invariants for ligne_comptable: sum(répartition.montant)
* historical spreadsheets: sum(répartition.montant) must equal montant_ht. * must equal montant_ht (explained most of the errors found in the
* historical spreadsheets), and every save must create a revision with no
* way to opt out, for an audit trail of who changed the shared ledger and
* when.
*/ */
use Drupal\node\NodeInterface; use Drupal\node\NodeInterface;
@@ -33,6 +36,36 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
} }
$form['#validate'][] = 'figli_compta_ledger_validate_repartition'; $form['#validate'][] = 'figli_compta_ledger_validate_repartition';
// Every save must create a revision, unconditionally -- there's no
// legitimate reason to skip it for an accounting record, so don't let
// anyone opt out. #access = FALSE (rather than just leaving the
// #default_value alone) makes Form API discard whatever a tampered
// request might submit for this field and fall back to #default_value.
// figli_compta_ledger_node_presave() enforces the same thing for any
// save that doesn't go through this form at all.
if (isset($form['revision'])) {
$form['revision']['#default_value'] = TRUE;
$form['revision']['#access'] = FALSE;
}
if (isset($form['revision_log'])) {
$form['revision_log']['#access'] = FALSE;
}
// field_entree_liee only makes sense on the "sortie" types that pay out
// against a client invoice -- charge/autre/ouverture aren't tied to a
// specific entrée, and entree lines don't link to themselves.
if (isset($form['field_entree_liee'])) {
$form['field_entree_liee']['#states'] = [
'visible' => [
[':input[name="field_type_ligne"]' => ['value' => 'versement']],
'or',
[':input[name="field_type_ligne"]' => ['value' => 'achat']],
'or',
[':input[name="field_type_ligne"]' => ['value' => 'hebergement']],
],
];
}
$request = \Drupal::request(); $request = \Drupal::request();
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog']; $wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE) $is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
@@ -110,6 +143,20 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') { if ($node->bundle() !== 'ligne_comptable') {
return; return;
} }
// Force a revision on every save, with no log message and the actual
// acting user as its author -- this is the audit trail for a shared
// ledger, not the répartition-sum check below, so it's never exempted
// (not even during a skip_validation import: migrated data still needs
// an honest revision history).
$node->setNewRevision(TRUE);
$node->setRevisionLogMessage('');
$node->setRevisionUserId(\Drupal::currentUser()->id());
// setNewRevision() alone doesn't refresh this -- it stays whatever it
// was on the entity as loaded (the previous revision's timestamp),
// silently mislabeling every edit with its predecessor's save time.
$node->setRevisionCreationTime(\Drupal::time()->getRequestTime());
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) { if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
return; return;
} }
@@ -142,7 +189,7 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
function figli_compta_ledger_theme($existing, $type, $theme, $path) { function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [ return [
'figli_compta_home' => [ 'figli_compta_home' => [
'variables' => [], 'variables' => ['can_view_history' => FALSE],
'template' => 'figli-compta-home', 'template' => 'figli-compta-home',
], ],
'figli_compta_dashboard' => [ 'figli_compta_dashboard' => [
@@ -13,3 +13,23 @@ figli_compta_ledger.dashboard:
_title: 'Tableau de bord - SAS Figures Libres' _title: 'Tableau de bord - SAS Figures Libres'
requirements: requirements:
_permission: 'access content' _permission: 'access content'
figli_compta_ledger.history:
path: '/lignes/historique'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\HistoryController::history'
_title: 'Historique du grand livre - SAS Figures Libres'
requirements:
_permission: 'view ligne_comptable revisions'
figli_compta_ledger.link_entree:
path: '/lignes/{node}/lier'
defaults:
_form: '\Drupal\figli_compta_ledger\Form\LinkEntreeForm'
_title: 'Lier à une entrée client'
requirements:
_entity_access: 'node.update'
options:
parameters:
node:
type: entity:node
@@ -0,0 +1,5 @@
services:
figli_compta_ledger.route_subscriber:
class: Drupal\figli_compta_ledger\EventSubscriber\RouteSubscriber
tags:
- { name: event_subscriber }
@@ -21,13 +21,16 @@
autre: 'Autre', autre: 'Autre',
ouverture: 'Ouverture', ouverture: 'Ouverture',
}; };
// Sorties that can be linked to the entrée client they pay out against
// (field_entree_liee) -- charge/autre/ouverture aren't client-specific.
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement'];
async function fetchAllLignes() { async function fetchAllLignes() {
// sort includes drupal_internal__nid as a tie-breaker: field_date_ligne // sort includes drupal_internal__nid as a tie-breaker: field_date_ligne
// alone is not unique (many lines share a date), and without a unique // alone is not unique (many lines share a date), and without a unique
// secondary sort key, offset pagination can silently duplicate or skip // secondary sort key, offset pagination can silently duplicate or skip
// rows across pages. // rows across pages.
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=field_date_ligne,drupal_internal__nid'; let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee&page[limit]=50&sort=field_date_ligne,drupal_internal__nid';
const allData = []; const allData = [];
const includedMap = new Map(); const includedMap = new Map();
while (url) { while (url) {
@@ -55,6 +58,7 @@
const rels = node.relationships || {}; const rels = node.relationships || {};
const attrs = node.attributes; const attrs = node.attributes;
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data); const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
const entreeLieeNode = resolve(includedMap, rels.field_entree_liee && rels.field_entree_liee.data);
const parCompte = {}; const parCompte = {};
let somme = 0; let somme = 0;
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || []; const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
@@ -82,6 +86,9 @@
somme, somme,
ecart, ecart,
hasError: Math.abs(ecart) > 0.01, hasError: Math.abs(ecart) > 0.01,
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
entreeLieeId: entreeLieeNode ? entreeLieeNode.id : null,
entreeLieeLabel: entreeLieeNode ? (entreeLieeNode.attributes.title || null) : null,
}); });
} }
rows.sort((a, b) => (a.date || '').localeCompare(b.date || '')); rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
@@ -102,6 +109,7 @@
groupBy: 'month', groupBy: 'month',
onlyErrors: false, onlyErrors: false,
hoverCol: null, hoverCol: null,
filterEntreeId: null,
}; };
}, },
computed: { computed: {
@@ -117,6 +125,53 @@
errorCount() { errorCount() {
return this.rows.filter((r) => r.hasError).length; return this.rows.filter((r) => r.hasError).length;
}, },
sortiesByEntree() {
const map = new Map();
for (const r of this.rows) {
if (!r.entreeLieeId) continue;
if (!map.has(r.entreeLieeId)) map.set(r.entreeLieeId, []);
map.get(r.entreeLieeId).push(r);
}
return map;
},
// Compares each entrée's répartition (positive shares owed) against
// the combined répartition of every sortie linked to it (negative
// amounts paid out), per compte -- both sides can be split across
// several comptes. A residual near zero means fully settled;
// positive means still owed ("reste à verser"); negative means more
// was paid out than the entrée allocated ("sur-versé", worth a
// closer look). Precomputed once for all entrée rows rather than
// recomputed per template read.
reconciliationByEntree() {
const map = new Map();
for (const entreeRow of this.rows) {
if (entreeRow.type !== 'entree') continue;
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
const versementsParCompte = {};
for (const s of linked) {
for (const [compte, montant] of Object.entries(s.parCompte)) {
versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant;
}
}
const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
let resteAVerser = 0;
let surVerse = 0;
const detail = [];
for (const c of comptes) {
const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
if (residual > 0.01) resteAVerser += residual;
else if (residual < -0.01) surVerse += -residual;
if (Math.abs(residual) > 0.01) detail.push(c + ' : ' + this.formatEur(residual));
}
map.set(entreeRow.id, {
count: linked.length,
resteAVerser: Math.round(resteAVerser * 100) / 100,
surVerse: Math.round(surVerse * 100) / 100,
detail: detail.join(', ') || 'Entièrement soldé',
});
}
return map;
},
footerTotals() { footerTotals() {
const parCompte = {}; const parCompte = {};
this.allComptes.forEach((c) => { parCompte[c] = 0; }); this.allComptes.forEach((c) => { parCompte[c] = 0; });
@@ -132,6 +187,13 @@
return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart }; return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart };
}, },
filteredRows() { filteredRows() {
// Drill-down mode: an entrée and only the sorties linked to it,
// ignoring the other filters -- clicking its badge again clears it.
if (this.filterEntreeId) {
const entree = this.rows.find((r) => r.id === this.filterEntreeId);
const linked = this.sortiesByEntree.get(this.filterEntreeId) || [];
return entree ? [entree, ...linked] : linked;
}
return this.rows.filter((r) => { return this.rows.filter((r) => {
if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false; if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false;
if (this.filterClient && r.client !== this.filterClient) return false; if (this.filterClient && r.client !== this.filterClient) return false;
@@ -172,6 +234,13 @@
formatEur(v) { formatEur(v) {
return v === null || v === undefined ? '' : EUR.format(v); return v === null || v === undefined ? '' : EUR.format(v);
}, },
// 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) {
if (!iso) return '';
const [y, m, d] = iso.split('-');
return d + '/' + m + '/' + y.slice(2);
},
typeLabel(t) { typeLabel(t) {
return TYPE_LABELS[t] || t; return TYPE_LABELS[t] || t;
}, },
@@ -180,6 +249,16 @@
if (v < -0.5) return 'figli-solde-debiteur'; if (v < -0.5) return 'figli-solde-debiteur';
return ''; return '';
}, },
// Single colored anchor per row (Montant HT only, not every compte
// column) so entrées/sorties are scannable at a glance without the
// table turning into a red/green garland. No 0.5€ threshold like
// soldeClass -- individual lines are often small (e.g. a -1.07€ OVH
// renewal), any nonzero sign should read as positive/negative.
montantClass(v) {
if (v > 0) return 'figli-montant-positif';
if (v < 0) return 'figli-montant-negatif';
return '';
},
openAddForm() { openAddForm() {
Drupal.ajax({ Drupal.ajax({
url: '/node/add/ligne_comptable', url: '/node/add/ligne_comptable',
@@ -196,9 +275,26 @@
progress: { type: 'throbber' }, progress: { type: 'throbber' },
}).execute(); }).execute();
}, },
openLinkForm(nid) {
Drupal.ajax({
url: '/lignes/' + nid + '/lier',
dialogType: 'modal',
dialog: { width: 500, title: 'Lier à une entrée client' },
progress: { type: 'throbber' },
}).execute();
},
toggleEntreeFilter(id) {
this.filterEntreeId = this.filterEntreeId === id ? null : id;
},
onCellHover(evt) { onCellHover(evt) {
const cell = evt.target.closest('td, th'); const cell = evt.target.closest('td, th');
if (!cell) return; if (!cell) return;
// No crosshair on the technical action columns (link/edit icons) --
// there's nothing to compare across rows there.
if (cell.classList.contains('actions-col')) {
this.clearColHover();
return;
}
// Logical column position, not DOM sibling index: the totals row's // Logical column position, not DOM sibling index: the totals row's
// first cell has colspan="4", which shifts every cell.cellIndex // first cell has colspan="4", which shifts every cell.cellIndex
// after it out of alignment with the body rows. // after it out of alignment with the body rows.
@@ -17,6 +17,7 @@ class DashboardController extends ControllerBase {
public function home() { public function home() {
return [ return [
'#theme' => 'figli_compta_home', '#theme' => 'figli_compta_home',
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
'#attached' => [ '#attached' => [
'library' => ['figli_compta_ledger/home'], 'library' => ['figli_compta_ledger/home'],
], ],
@@ -0,0 +1,84 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
/**
* Grand livre history: a single reverse-chronological feed across every
* ligne_comptable's revisions, rather than Drupal's default per-node
* history page -- a revision of one ligne is, conceptually, a revision of
* the grand livre as a whole.
*/
class HistoryController extends ControllerBase {
/**
* Lists every ligne_comptable revision, most recent change first.
*/
public function history() {
$node_storage = $this->entityTypeManager()->getStorage('node');
$date_formatter = \Drupal::service('date.formatter');
$revision_ids = $this->entityTypeManager()->getStorage('node')->getQuery()
->accessCheck(TRUE)
->allRevisions()
->condition('type', 'ligne_comptable')
->sort('revision_timestamp', 'DESC')
->pager(50)
->execute();
$rows = [];
foreach ($revision_ids as $vid => $nid) {
$revision = $node_storage->loadRevision($vid);
if (!$revision) {
continue;
}
$author = $revision->getRevisionUser();
$rows[] = [
$date_formatter->format($revision->getRevisionCreationTime(), 'short'),
$author ? $author->getDisplayName() : $this->t('Utilisateur supprimé'),
$revision->hasField('field_date_ligne') ? $revision->get('field_date_ligne')->value : '',
$revision->label(),
$revision->hasField('field_montant_ht') && !$revision->get('field_montant_ht')->isEmpty()
? $revision->get('field_montant_ht')->value . ' €'
: '',
[
'data' => [
'#type' => 'link',
'#title' => $this->t('Voir cette version'),
'#url' => Url::fromRoute('entity.node.revision', [
'node' => $nid,
'node_revision' => $vid,
]),
],
],
];
}
return [
'table' => [
'#type' => 'table',
'#header' => [
$this->t('Modifié le'),
$this->t('Par'),
$this->t('Date de la ligne'),
$this->t('Ligne'),
$this->t('Montant HT'),
$this->t('Version'),
],
'#rows' => $rows,
'#empty' => $this->t('Aucune révision.'),
'#attributes' => ['class' => ['figli-historique-table']],
],
'pager' => [
'#type' => 'pager',
],
'#cache' => [
'contexts' => ['user.permissions'],
'tags' => ['node_list:ligne_comptable'],
],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace Drupal\figli_compta_ledger\EventSubscriber;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Core's generic entity autocomplete route ('system.entity_autocomplete')
* is '_access: TRUE' by design -- EntityQuery::accessCheck() only enforces
* node grants (published status), not the base 'access content'
* permission, so it doesn't actually block anonymous users the way every
* other route on this confidential site does. Discovered while adding
* field_entree_liee's autocomplete: verified the *existing* field_client
* (client taxonomy) autocomplete had the same gap, leaking real client
* names to anonymous requests via a plain curl call. Since every field on
* this content type is confidential, lock the route down site-wide rather
* than special-casing our own field.
*/
class RouteSubscriber extends RouteSubscriberBase {
/**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('system.entity_autocomplete')) {
$route->setRequirement('_permission', 'access content');
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace Drupal\figli_compta_ledger\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\node\NodeInterface;
/**
* Quick-link form: sets field_entree_liee on a single sortie line without
* opening the full ligne_comptable edit form -- the associates only ever
* need to touch this one field to link a versement/achat/hébergement to
* the entrée client it pays out against.
*/
class LinkEntreeForm extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'figli_compta_ledger_link_entree_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL) {
$form_state->set('node', $node);
$form['field_entree_liee'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Entrée client liée'),
'#target_type' => 'node',
'#selection_handler' => 'figli_compta_ledger:entree_client',
'#selection_settings' => [
'target_bundles' => ['ligne_comptable' => 'ligne_comptable'],
// Narrows the autocomplete to the same client as this sortie --
// read by EntreeClientSelection::buildEntityQuery().
'entity' => $node,
],
'#default_value' => $node->get('field_entree_liee')->entity,
'#description' => $this->t('Laisser vide pour retirer le lien.'),
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Enregistrer'),
'#ajax' => [
'callback' => '::ajaxSubmit',
],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\node\NodeInterface $node */
$node = $form_state->get('node');
$node->set('field_entree_liee', $form_state->getValue('field_entree_liee') ?: NULL);
$node->save();
}
/**
* #ajax callback: close the modal on success (the /lignes table picks up
* "dialog:afterclose" to refresh), or re-render the form in place.
*/
public function ajaxSubmit(array &$form, FormStateInterface $form_state) {
$response = new AjaxResponse();
if ($form_state->getErrors()) {
unset($form['#prefix'], $form['#suffix']);
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
return $response;
}
$response->addCommand(new CloseModalDialogCommand());
return $response;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Drupal\figli_compta_ledger\Plugin\EntityReferenceSelection;
use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\Attribute\EntityReferenceSelection;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\node\Plugin\EntityReferenceSelection\NodeSelection;
/**
* Restricts field_entree_liee's candidates to "entrée" ligne_comptable
* nodes, scoped to the same client as the sortie being linked when one is
* already set on it.
*/
#[EntityReferenceSelection(
id: "figli_compta_ledger:entree_client",
label: new TranslatableMarkup("Entrées client (SAS Figures Libres)"),
entity_types: ["node"],
group: "figli_compta_ledger",
weight: 0,
)]
class EntreeClientSelection extends NodeSelection {
/**
* {@inheritdoc}
*/
protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
$query = parent::buildEntityQuery($match, $match_operator);
$query->condition('field_type_ligne', 'entree');
$entity = $this->configuration['entity'] ?? NULL;
if ($entity && $entity->hasField('field_client') && !$entity->get('field_client')->isEmpty()) {
$query->condition('field_client', $entity->get('field_client')->target_id);
}
return $query;
}
/**
* {@inheritdoc}
*
* Appends montant HT and the notes/détail text to the label -- several
* entrées for the same client often share the same title format ("EPAU -
* 2026-01-05"), so the amount and invoice reference are what actually let
* someone tell them apart when linking a sortie.
*/
public function getReferenceableEntities($match = NULL, $match_operator = 'CONTAINS', $limit = 0) {
$options = parent::getReferenceableEntities($match, $match_operator, $limit);
if (empty($options)) {
return $options;
}
$ids = [];
foreach ($options as $items) {
$ids += array_keys($items);
}
$entities = $this->entityTypeManager->getStorage('node')->loadMultiple($ids);
foreach ($options as $bundle => &$items) {
foreach ($items as $id => $label) {
$entity = $entities[$id] ?? NULL;
if (!$entity) {
continue;
}
$details = [];
if ($entity->hasField('field_montant_ht') && !$entity->get('field_montant_ht')->isEmpty()) {
$details[] = number_format((float) $entity->get('field_montant_ht')->value, 2, ',', ' ') . ' €';
}
if ($entity->hasField('field_notes') && !$entity->get('field_notes')->isEmpty()) {
$details[] = Html::escape($entity->get('field_notes')->value);
}
if ($details) {
$items[$id] = $label . ' — ' . implode(' — ', $details);
}
}
}
return $options;
}
}
@@ -9,6 +9,11 @@
<div id="figli-home-app"> <div id="figli-home-app">
<div class="figli-toolbar"> <div class="figli-toolbar">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a> <a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_view_history %}
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
{% endif %}
{% verbatim %}
<label>Compte <label>Compte
<select v-model="filterCompte"> <select v-model="filterCompte">
@@ -50,6 +55,8 @@
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement <input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
</label> </label>
<button type="button" class="button figli-clear-drilldown" v-if="filterEntreeId" @click="filterEntreeId = null">✕ Entrée + sorties liées uniquement</button>
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span> <span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
</div> </div>
@@ -59,6 +66,8 @@
<table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover"> <table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover">
<thead> <thead>
<tr> <tr>
<th class="actions-col"></th>
<th class="actions-col"></th>
<th>Date</th> <th>Date</th>
<th>Client</th> <th>Client</th>
<th>Type</th> <th>Type</th>
@@ -67,23 +76,29 @@
<th class="amount">Montant TTC</th> <th class="amount">Montant TTC</th>
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th> <th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
<th class="amount">Écart</th> <th class="amount">Écart</th>
<th class="actions-col"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="item in groupedRows" :key="item.key"> <template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row"> <tr v-if="item.isGroup" class="figli-group-row">
<td :colspan="6 + allComptes.length + 2">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td> <td :colspan="6 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr> </tr>
<tr v-else :class="{'figli-error-row': item.hasError}"> <tr v-else :class="{'figli-error-row': item.hasError}">
<td>{{ item.date }}</td> <td class="actions-col">
<td>{{ item.client || '—' }}</td> <button
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td> v-if="item.linkable"
<td class="figli-libelle">{{ item.libelle }}</td> type="button"
<td class="amount">{{ formatEur(item.montant_ht) }}</td> class="figli-link-btn"
<td class="amount">{{ formatEur(item.montant_ttc) }}</td> :class="{'is-linked': item.entreeLieeId}"
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td> :title="item.entreeLieeId ? 'Lié à : ' + item.entreeLieeLabel : 'Lier à une entrée client'"
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td> @click="openLinkForm(item.nid)"
>
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 12a3 3 0 0 0 4.24 0l2-2a3 3 0 0 0-4.24-4.24l-1 1" />
<path d="M12 8a3 3 0 0 0-4.24 0l-2 2a3 3 0 0 0 4.24 4.24l1-1" />
</svg>
</button>
</td>
<td class="actions-col"> <td class="actions-col">
<button type="button" class="figli-edit-btn" title="Modifier" @click="openEditForm(item.nid)"> <button type="button" class="figli-edit-btn" title="Modifier" @click="openEditForm(item.nid)">
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@@ -91,17 +106,35 @@
</svg> </svg>
</button> </button>
</td> </td>
<td>{{ formatDate(item.date) }}</td>
<td>{{ item.client || '—' }}</td>
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
<td class="figli-libelle">
{{ item.libelle }}
<span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
:title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)"
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
</td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="figli-totals-row"> <tr class="figli-totals-row">
<td class="actions-col"></td>
<td class="actions-col"></td>
<td colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td> <td colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td>
<td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td> <td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td>
<td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td> <td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="soldeClass(footerTotals.parCompte[c])">{{ formatEur(footerTotals.parCompte[c]) }}</td> <td v-for="c in allComptes" :key="c" class="amount compte-col" :class="soldeClass(footerTotals.parCompte[c])">{{ formatEur(footerTotals.parCompte[c]) }}</td>
<td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td> <td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
<td class="actions-col"></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>