Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4636fc3522 | ||
|
|
8eb92c613d | ||
|
|
13461d0cb1 | ||
|
|
5f4ec4dbee | ||
|
|
f3ce872b02 | ||
|
|
0e36274d51 | ||
|
|
d04d0e8824 | ||
|
|
27a799e937 | ||
|
|
384b283547 |
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user