Compare commits
9
Commits
994c7c7aad
...
4636fc3522
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4636fc3522 | ||
|
|
8eb92c613d | ||
|
|
13461d0cb1 | ||
|
|
5f4ec4dbee | ||
|
|
f3ce872b02 | ||
|
|
0e36274d51 | ||
|
|
d04d0e8824 | ||
|
|
27a799e937 | ||
|
|
384b283547 |
@@ -5,6 +5,7 @@ dependencies:
|
||||
config:
|
||||
- field.field.node.ligne_comptable.field_client
|
||||
- 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_ttc
|
||||
- field.field.node.ligne_comptable.field_notes
|
||||
@@ -42,6 +43,16 @@ content:
|
||||
region: content
|
||||
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:
|
||||
type: number
|
||||
weight: 3
|
||||
|
||||
@@ -5,6 +5,7 @@ dependencies:
|
||||
config:
|
||||
- field.field.node.ligne_comptable.field_client
|
||||
- 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_ttc
|
||||
- field.field.node.ligne_comptable.field_notes
|
||||
@@ -38,6 +39,14 @@ content:
|
||||
third_party_settings: { }
|
||||
weight: 0
|
||||
region: content
|
||||
field_entree_liee:
|
||||
type: entity_reference_label
|
||||
label: above
|
||||
settings:
|
||||
link: true
|
||||
third_party_settings: { }
|
||||
weight: 2
|
||||
region: content
|
||||
field_montant_ht:
|
||||
type: number_decimal
|
||||
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
|
||||
@@ -16,3 +16,4 @@ permissions:
|
||||
- 'create ligne_comptable content'
|
||||
- 'delete any ligne_comptable content'
|
||||
- 'edit any ligne_comptable content'
|
||||
- 'view ligne_comptable revisions'
|
||||
|
||||
@@ -16,3 +16,4 @@ permissions:
|
||||
- 'create ligne_comptable content'
|
||||
- 'delete any ligne_comptable content'
|
||||
- 'edit any ligne_comptable content'
|
||||
- 'view ligne_comptable revisions'
|
||||
|
||||
@@ -2,7 +2,10 @@ uuid: 24f4e46d-c714-486d-b000-f092eb727470
|
||||
langcode: en
|
||||
status: true
|
||||
dependencies:
|
||||
config:
|
||||
- node.type.ligne_comptable
|
||||
module:
|
||||
- node
|
||||
- system
|
||||
id: user
|
||||
label: 'Utilisateur (lecture seule)'
|
||||
@@ -10,3 +13,4 @@ weight: 4
|
||||
is_admin: false
|
||||
permissions:
|
||||
- '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 {
|
||||
position: sticky;
|
||||
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 {
|
||||
@@ -149,6 +153,18 @@ html.gin--dark-mode #figli-home-app {
|
||||
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 --
|
||||
easier to read, doesn't fight with dark mode. */
|
||||
#figli-home-app tr.figli-error-row td {
|
||||
@@ -198,32 +214,79 @@ html.gin--dark-mode #figli-home-app {
|
||||
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 th.actions-col {
|
||||
width: 1%;
|
||||
padding: 0.2rem 0.1rem;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--figli-text-light);
|
||||
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);
|
||||
border-color: var(--figli-border);
|
||||
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 {
|
||||
background: #fde8e8;
|
||||
border: 1px solid #f4a3a3;
|
||||
|
||||
@@ -13,3 +13,11 @@ figli_compta_ledger.dashboard:
|
||||
menu_name: admin
|
||||
parent: system.admin
|
||||
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
|
||||
* Enforces the one invariant that explained most of the errors found in the
|
||||
* historical spreadsheets: sum(répartition.montant) must equal montant_ht.
|
||||
* Enforces two invariants for ligne_comptable: sum(répartition.montant)
|
||||
* 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;
|
||||
@@ -33,6 +36,36 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
|
||||
}
|
||||
$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();
|
||||
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
|
||||
$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') {
|
||||
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')) {
|
||||
return;
|
||||
}
|
||||
@@ -142,7 +189,7 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
||||
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
||||
return [
|
||||
'figli_compta_home' => [
|
||||
'variables' => [],
|
||||
'variables' => ['can_view_history' => FALSE],
|
||||
'template' => 'figli-compta-home',
|
||||
],
|
||||
'figli_compta_dashboard' => [
|
||||
|
||||
@@ -13,3 +13,23 @@ figli_compta_ledger.dashboard:
|
||||
_title: 'Tableau de bord - SAS Figures Libres'
|
||||
requirements:
|
||||
_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',
|
||||
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() {
|
||||
// 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
|
||||
// secondary sort key, offset pagination can silently duplicate or skip
|
||||
// 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 includedMap = new Map();
|
||||
while (url) {
|
||||
@@ -55,6 +58,7 @@
|
||||
const rels = node.relationships || {};
|
||||
const attrs = node.attributes;
|
||||
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 = {};
|
||||
let somme = 0;
|
||||
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
||||
@@ -82,6 +86,9 @@
|
||||
somme,
|
||||
ecart,
|
||||
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 || ''));
|
||||
@@ -102,6 +109,7 @@
|
||||
groupBy: 'month',
|
||||
onlyErrors: false,
|
||||
hoverCol: null,
|
||||
filterEntreeId: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -117,6 +125,53 @@
|
||||
errorCount() {
|
||||
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() {
|
||||
const parCompte = {};
|
||||
this.allComptes.forEach((c) => { parCompte[c] = 0; });
|
||||
@@ -132,6 +187,13 @@
|
||||
return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart };
|
||||
},
|
||||
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) => {
|
||||
if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false;
|
||||
if (this.filterClient && r.client !== this.filterClient) return false;
|
||||
@@ -172,6 +234,13 @@
|
||||
formatEur(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) {
|
||||
return TYPE_LABELS[t] || t;
|
||||
},
|
||||
@@ -180,6 +249,16 @@
|
||||
if (v < -0.5) return 'figli-solde-debiteur';
|
||||
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() {
|
||||
Drupal.ajax({
|
||||
url: '/node/add/ligne_comptable',
|
||||
@@ -196,9 +275,26 @@
|
||||
progress: { type: 'throbber' },
|
||||
}).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) {
|
||||
const cell = evt.target.closest('td, th');
|
||||
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
|
||||
// first cell has colspan="4", which shifts every cell.cellIndex
|
||||
// after it out of alignment with the body rows.
|
||||
|
||||
@@ -17,6 +17,7 @@ class DashboardController extends ControllerBase {
|
||||
public function home() {
|
||||
return [
|
||||
'#theme' => 'figli_compta_home',
|
||||
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
|
||||
'#attached' => [
|
||||
'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;
|
||||
}
|
||||
|
||||
}
|
||||
+81
@@ -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 class="figli-toolbar">
|
||||
<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
|
||||
<select v-model="filterCompte">
|
||||
@@ -50,6 +55,8 @@
|
||||
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +66,8 @@
|
||||
<table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="actions-col"></th>
|
||||
<th class="actions-col"></th>
|
||||
<th>Date</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
@@ -67,23 +76,29 @@
|
||||
<th class="amount">Montant TTC</th>
|
||||
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
|
||||
<th class="amount">Écart</th>
|
||||
<th class="actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in groupedRows" :key="item.key">
|
||||
<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 v-else :class="{'figli-error-row': item.hasError}">
|
||||
<td>{{ 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 }}</td>
|
||||
<td class="amount">{{ 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>
|
||||
<td class="actions-col">
|
||||
<button
|
||||
v-if="item.linkable"
|
||||
type="button"
|
||||
class="figli-link-btn"
|
||||
:class="{'is-linked': item.entreeLieeId}"
|
||||
:title="item.entreeLieeId ? 'Lié à : ' + item.entreeLieeLabel : 'Lier à une entrée client'"
|
||||
@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">
|
||||
<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">
|
||||
@@ -91,17 +106,35 @@
|
||||
</svg>
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<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 class="amount">{{ formatEur(footerTotals.montant_ht) }}</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 class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
|
||||
<td class="actions-col"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user