Compare commits
58
Commits
4636fc3522
...
9234050a68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9234050a68 | ||
|
|
11250bea77 | ||
|
|
ec2af0ea7e | ||
|
|
bd251d0a5e | ||
|
|
22f57379e5 | ||
|
|
c7c143e038 | ||
|
|
463a7f343c | ||
|
|
775ee0955f | ||
|
|
78661628fc | ||
|
|
9ce5d87ace | ||
|
|
3f8767b4ef | ||
|
|
fa28783bcc | ||
|
|
eda79e75a3 | ||
|
|
34b1082991 | ||
|
|
ceaf301bf0 | ||
|
|
3404ee14c3 | ||
|
|
71fde0f3c0 | ||
|
|
0c893b1b24 | ||
|
|
fc89ae6232 | ||
|
|
18cb4cc3c8 | ||
|
|
0efc41f121 | ||
|
|
179a994f69 | ||
|
|
11eb9cadb0 | ||
|
|
50a6432691 | ||
|
|
b6540e56c7 | ||
|
|
852eda2056 | ||
|
|
bb01c2dd90 | ||
|
|
319b22207d | ||
|
|
0479f3fbbb | ||
|
|
0c9f8ca41f | ||
|
|
8f65a9097a | ||
|
|
2a6d36c603 | ||
|
|
6c0bddd964 | ||
|
|
b73a980282 | ||
|
|
619a2ab70c | ||
|
|
73818779c1 | ||
|
|
82b03ac5e2 | ||
|
|
9ec0aa4699 | ||
|
|
0f33d57ec0 | ||
|
|
0314625593 | ||
|
|
0170ec4475 | ||
|
|
9a70205c83 | ||
|
|
6bdf12b8fe | ||
|
|
5a311b8439 | ||
|
|
1ab38bc2a7 | ||
|
|
ef2d7b801d | ||
|
|
2f9ad7d52c | ||
|
|
2947a542da | ||
|
|
3ec894c167 | ||
|
|
f260e8a605 | ||
|
|
a45d55cb81 | ||
|
|
53c4e58e72 | ||
|
|
5afc5905d7 | ||
|
|
7eabcc6bd8 | ||
|
|
831c0a0e62 | ||
|
|
9e489b48e6 | ||
|
|
b6358a7258 | ||
|
|
059d31f63d |
@@ -6,9 +6,11 @@ dependencies:
|
|||||||
- 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_entree_liee
|
||||||
|
- field.field.node.ligne_comptable.field_flag
|
||||||
- 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
|
||||||
|
- field.field.node.ligne_comptable.field_numero_facture
|
||||||
- field.field.node.ligne_comptable.field_repartition
|
- field.field.node.ligne_comptable.field_repartition
|
||||||
- field.field.node.ligne_comptable.field_type_ligne
|
- field.field.node.ligne_comptable.field_type_ligne
|
||||||
- node.type.ligne_comptable
|
- node.type.ligne_comptable
|
||||||
@@ -53,6 +55,16 @@ content:
|
|||||||
size: 60
|
size: 60
|
||||||
placeholder: ''
|
placeholder: ''
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
|
field_flag:
|
||||||
|
type: entity_reference_autocomplete_tags
|
||||||
|
weight: 8
|
||||||
|
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
|
||||||
@@ -75,6 +87,14 @@ content:
|
|||||||
rows: 5
|
rows: 5
|
||||||
placeholder: ''
|
placeholder: ''
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
|
field_numero_facture:
|
||||||
|
type: string_textfield
|
||||||
|
weight: 3
|
||||||
|
region: content
|
||||||
|
settings:
|
||||||
|
size: 60
|
||||||
|
placeholder: ''
|
||||||
|
third_party_settings: { }
|
||||||
field_repartition:
|
field_repartition:
|
||||||
type: paragraphs
|
type: paragraphs
|
||||||
weight: 5
|
weight: 5
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ dependencies:
|
|||||||
- 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_entree_liee
|
||||||
|
- field.field.node.ligne_comptable.field_flag
|
||||||
- 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
|
||||||
|
- field.field.node.ligne_comptable.field_numero_facture
|
||||||
- field.field.node.ligne_comptable.field_repartition
|
- field.field.node.ligne_comptable.field_repartition
|
||||||
- field.field.node.ligne_comptable.field_type_ligne
|
- field.field.node.ligne_comptable.field_type_ligne
|
||||||
- node.type.ligne_comptable
|
- node.type.ligne_comptable
|
||||||
@@ -47,6 +49,14 @@ content:
|
|||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 2
|
weight: 2
|
||||||
region: content
|
region: content
|
||||||
|
field_flag:
|
||||||
|
type: entity_reference_label
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
link: true
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 8
|
||||||
|
region: content
|
||||||
field_montant_ht:
|
field_montant_ht:
|
||||||
type: number_decimal
|
type: number_decimal
|
||||||
label: above
|
label: above
|
||||||
@@ -76,6 +86,15 @@ content:
|
|||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 6
|
weight: 6
|
||||||
region: content
|
region: content
|
||||||
|
field_numero_facture:
|
||||||
|
type: string
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
link_to_entity: false
|
||||||
|
link_rel: canonical
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 3
|
||||||
|
region: content
|
||||||
field_repartition:
|
field_repartition:
|
||||||
type: entity_reference_revisions_entity_view
|
type: entity_reference_revisions_entity_view
|
||||||
label: above
|
label: above
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
uuid: c89c4d76-167a-4b60-b5f1-50aa3b349b80
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- field.storage.node.field_flag
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
- taxonomy.vocabulary.flag
|
||||||
|
id: node.ligne_comptable.field_flag
|
||||||
|
field_name: field_flag
|
||||||
|
entity_type: node
|
||||||
|
bundle: ligne_comptable
|
||||||
|
label: Signalement
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
|
translatable: true
|
||||||
|
default_value: { }
|
||||||
|
default_value_callback: ''
|
||||||
|
settings:
|
||||||
|
handler: 'default:taxonomy_term'
|
||||||
|
handler_settings:
|
||||||
|
target_bundles:
|
||||||
|
flag: flag
|
||||||
|
auto_create: true
|
||||||
|
field_type: entity_reference
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
uuid: f090cb6f-56d8-44cf-b18c-9f81fd3fd518
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- field.storage.node.field_numero_facture
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
id: node.ligne_comptable.field_numero_facture
|
||||||
|
field_name: field_numero_facture
|
||||||
|
entity_type: node
|
||||||
|
bundle: ligne_comptable
|
||||||
|
label: 'N° Facture'
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
|
translatable: true
|
||||||
|
default_value: { }
|
||||||
|
default_value_callback: ''
|
||||||
|
settings: { }
|
||||||
|
field_type: string
|
||||||
@@ -12,7 +12,7 @@ settings:
|
|||||||
target_type: node
|
target_type: node
|
||||||
module: core
|
module: core
|
||||||
locked: false
|
locked: false
|
||||||
cardinality: 1
|
cardinality: -1
|
||||||
translatable: true
|
translatable: true
|
||||||
indexes: { }
|
indexes: { }
|
||||||
persist_with_no_fields: false
|
persist_with_no_fields: false
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
uuid: 0921a265-d874-487c-bf0a-09a389322d6b
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
module:
|
||||||
|
- node
|
||||||
|
- taxonomy
|
||||||
|
id: node.field_flag
|
||||||
|
field_name: field_flag
|
||||||
|
entity_type: node
|
||||||
|
type: entity_reference
|
||||||
|
settings:
|
||||||
|
target_type: taxonomy_term
|
||||||
|
module: core
|
||||||
|
locked: false
|
||||||
|
cardinality: -1
|
||||||
|
translatable: true
|
||||||
|
indexes: { }
|
||||||
|
persist_with_no_fields: false
|
||||||
|
custom_storage: false
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
uuid: 6819b88a-a788-471f-bae0-a363bb9a1670
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
module:
|
||||||
|
- node
|
||||||
|
id: node.field_numero_facture
|
||||||
|
field_name: field_numero_facture
|
||||||
|
entity_type: node
|
||||||
|
type: string
|
||||||
|
settings:
|
||||||
|
max_length: 64
|
||||||
|
case_sensitive: false
|
||||||
|
is_ascii: false
|
||||||
|
module: core
|
||||||
|
locked: false
|
||||||
|
cardinality: 1
|
||||||
|
translatable: true
|
||||||
|
indexes: { }
|
||||||
|
persist_with_no_fields: false
|
||||||
|
custom_storage: false
|
||||||
@@ -32,6 +32,15 @@ settings:
|
|||||||
-
|
-
|
||||||
value: hebergement
|
value: hebergement
|
||||||
label: Hébergement
|
label: Hébergement
|
||||||
|
-
|
||||||
|
value: sous_traitant
|
||||||
|
label: Sous-traitant
|
||||||
|
-
|
||||||
|
value: salaire_stage
|
||||||
|
label: 'Salaire / stage'
|
||||||
|
-
|
||||||
|
value: charges_local_pro
|
||||||
|
label: 'Charges local pro'
|
||||||
allowed_values_function: ''
|
allowed_values_function: ''
|
||||||
module: options
|
module: options
|
||||||
locked: false
|
locked: false
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
uuid: 01c508f8-ba10-40d7-8d8b-5c2c362bd761
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies: { }
|
||||||
|
name: Signalement
|
||||||
|
vid: flag
|
||||||
|
description: null
|
||||||
|
weight: 0
|
||||||
|
new_revision: false
|
||||||
@@ -1,14 +1,111 @@
|
|||||||
/* Core Navigation's top bar (`.top-bar`) only shows itself when its
|
/* Grand livre / Dashboard switcher, top-right of both pages. Only present
|
||||||
tools/context/actions regions are non-empty -- but with no blocks placed
|
on those two custom routes, but attached here rather than duplicated
|
||||||
there, the regions still contain whitespace text nodes from the Twig
|
between home.css and dashboard.css -- and defined with its own explicit
|
||||||
loop scaffolding, which defeats the `:not(:empty)` check. Result: an
|
colors rather than relying on either page's #figli-*-app-scoped custom
|
||||||
empty top bar renders anyway, with the page content pushed down to make
|
properties, since this <nav> sits outside both of those elements. */
|
||||||
room for it. We only use the Gin sidebar (the left icon rail), so force
|
.figli-page-nav {
|
||||||
the phantom bar off and collapse the space it would otherwise reserve. */
|
display: flex;
|
||||||
.top-bar {
|
justify-content: flex-end;
|
||||||
display: none !important;
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
.figli-page-nav a {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #6b7280;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.figli-page-nav a:hover {
|
||||||
|
border-color: #dcdee2;
|
||||||
|
}
|
||||||
|
.figli-page-nav a.is-active {
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
html.gin--dark-mode .figli-page-nav a {
|
||||||
|
color: #a1a5ab;
|
||||||
|
}
|
||||||
|
html.gin--dark-mode .figli-page-nav a:hover {
|
||||||
|
border-color: #3d3e42;
|
||||||
|
}
|
||||||
|
html.gin--dark-mode .figli-page-nav a.is-active {
|
||||||
|
background: #2c2d30;
|
||||||
|
color: #e8e9ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar ~ .dialog-off-canvas-main-canvas {
|
/* "Lier à une entrée client" modal (LinkEntreeForm): one autocomplete
|
||||||
margin-block-start: 0 !important;
|
field per linked entrée instead of a single comma-separated field --
|
||||||
|
tighten the default spacing between them a little. */
|
||||||
|
#figli-entree-liee-items .form-item {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drupal status messages: fixed top-right, out of document flow, so they
|
||||||
|
never shift the page layout the way the default in-flow placement does
|
||||||
|
-- and shrunk to a fraction of Gin's default size, which is sized for a
|
||||||
|
single full-width flash banner on an admin form, not a toast for
|
||||||
|
frequent background feedback here. Targets both the real region
|
||||||
|
([data-drupal-messages], present once a message is queued on a normal
|
||||||
|
page load) and the JS-only fallback container
|
||||||
|
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
|
||||||
|
own MessageCommand-driven AJAX messages included -- has no region to
|
||||||
|
attach to). Auto-dismiss timing for non-error messages is handled in
|
||||||
|
admin-chrome.js. */
|
||||||
|
[data-drupal-messages],
|
||||||
|
[data-drupal-messages-fallback] {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
left: auto !important;
|
||||||
|
width: auto;
|
||||||
|
max-width: 22rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .messages-list__wrapper,
|
||||||
|
[data-drupal-messages] .messages__wrapper,
|
||||||
|
[data-drupal-messages-fallback] .messages-list__wrapper,
|
||||||
|
[data-drupal-messages-fallback] .messages__wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .messages,
|
||||||
|
[data-drupal-messages-fallback] .messages {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .messages__header,
|
||||||
|
[data-drupal-messages-fallback] .messages__header {
|
||||||
|
margin-block-end: 0.15rem;
|
||||||
|
margin-inline-end: 1.1em;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .messages__header::before,
|
||||||
|
[data-drupal-messages-fallback] .messages__header::before {
|
||||||
|
width: 1rem !important;
|
||||||
|
height: 1rem !important;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .messages__title,
|
||||||
|
[data-drupal-messages-fallback] .messages__title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .messages__title,
|
||||||
|
[data-drupal-messages] .messages__content,
|
||||||
|
[data-drupal-messages-fallback] .messages__title,
|
||||||
|
[data-drupal-messages-fallback] .messages__content {
|
||||||
|
margin-inline-start: 1.4rem;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .button--dismiss,
|
||||||
|
[data-drupal-messages-fallback] .button--dismiss {
|
||||||
|
height: 20px !important;
|
||||||
|
width: 20px !important;
|
||||||
|
margin: 0.3rem !important;
|
||||||
|
}
|
||||||
|
[data-drupal-messages] .button--dismiss .icon-close,
|
||||||
|
[data-drupal-messages-fallback] .button--dismiss .icon-close {
|
||||||
|
mask-size: 11px 11px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
--figli-border: #dcdee2;
|
--figli-border: #dcdee2;
|
||||||
--figli-error: #c9312b;
|
--figli-error: #c9312b;
|
||||||
--figli-positive: #1a7f37;
|
--figli-positive: #1a7f37;
|
||||||
|
--figli-warning: #b8860b;
|
||||||
|
|
||||||
font-family: Inter, -apple-system, sans-serif;
|
font-family: Inter, -apple-system, sans-serif;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
@@ -23,54 +24,22 @@ html.gin--dark-mode #figli-dashboard-app {
|
|||||||
--figli-border: #3d3e42;
|
--figli-border: #3d3e42;
|
||||||
--figli-error: #ff6b6b;
|
--figli-error: #ff6b6b;
|
||||||
--figli-positive: #4ade80;
|
--figli-positive: #4ade80;
|
||||||
|
--figli-warning: #f0b429;
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-dashboard-app h2 {
|
#figli-dashboard-app h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 2rem 0 0.75rem;
|
margin: 0 0 0.25rem;
|
||||||
color: var(--figli-text);
|
color: var(--figli-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-dashboard-app .figli-note {
|
#figli-dashboard-app .figli-note {
|
||||||
color: var(--figli-text-light);
|
color: var(--figli-text-light);
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-dashboard-app table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--figli-bg);
|
|
||||||
color: var(--figli-text);
|
|
||||||
border: 1px solid var(--figli-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app th,
|
|
||||||
#figli-dashboard-app td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem 0.85rem;
|
|
||||||
border-bottom: 1px solid var(--figli-border);
|
|
||||||
color: var(--figli-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app th {
|
|
||||||
background: var(--figli-bg-alt);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app td.amount {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-dashboard-app tr.positive td.amount { color: var(--figli-positive); }
|
|
||||||
#figli-dashboard-app tr.negative td.amount { color: var(--figli-error); }
|
|
||||||
#figli-dashboard-app tr.total td { font-weight: 700; border-top: 2px solid var(--figli-border); }
|
|
||||||
|
|
||||||
#figli-dashboard-app .figli-error {
|
#figli-dashboard-app .figli-error {
|
||||||
background: #fde8e8;
|
background: #fde8e8;
|
||||||
border: 1px solid #f4a3a3;
|
border: 1px solid #f4a3a3;
|
||||||
@@ -78,3 +47,448 @@ html.gin--dark-mode #figli-dashboard-app {
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Summary cards --- */
|
||||||
|
#figli-dashboard-app .figli-summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-card {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-summary-value.is-positive { color: var(--figli-positive); }
|
||||||
|
#figli-dashboard-app .figli-summary-value.is-negative { color: var(--figli-error); }
|
||||||
|
#figli-dashboard-app .figli-summary-card.is-alert { border-color: var(--figli-error); }
|
||||||
|
|
||||||
|
/* --- Compte selector (dashboard par compte) --- */
|
||||||
|
#figli-dashboard-app .figli-compte-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-compte-selector label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-compte-selector select {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signalement filter (Lignes signalées section) -- same multiselect
|
||||||
|
widget as /lignes' Compte/Type filters (home.css), duplicated here
|
||||||
|
rather than shared, see this project's established convention for
|
||||||
|
dashboard.js/dashboard-compte.js vs home.js. */
|
||||||
|
#figli-dashboard-app .figli-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-filter-clear {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.15rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-filter-clear:hover {
|
||||||
|
color: var(--figli-error);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-multiselect {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-multiselect-trigger {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 12rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-multiselect-arrow {
|
||||||
|
float: right;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-multiselect-panel {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-multiselect:hover .figli-multiselect-panel {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-multiselect-option {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Reconciliation tables (reste à verser, sur-versé, non liés) --- */
|
||||||
|
#figli-dashboard-app .figli-recon-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-recon-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-recon-table td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-recon-table td.is-negative { color: var(--figli-error); font-weight: 600; }
|
||||||
|
#figli-dashboard-app .figli-recon-table tbody tr:hover { background: var(--figli-bg-alt); }
|
||||||
|
#figli-dashboard-app .figli-recon-table tfoot td { font-weight: 700; border-bottom: none; border-top: 2px solid var(--figli-border); }
|
||||||
|
#figli-dashboard-app .figli-recon-table a { color: inherit; text-decoration: underline; text-decoration-color: var(--figli-border); }
|
||||||
|
|
||||||
|
/* Signalée rows: same amber left-edge accent as /lignes (box-shadow, not
|
||||||
|
border, so it doesn't fight the row's existing bottom border). */
|
||||||
|
#figli-dashboard-app .figli-recon-table tr.figli-flag-row td:first-child {
|
||||||
|
box-shadow: inset 4px 0 0 var(--figli-warning);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-flag-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
margin: 0 0 0 0.4rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
|
||||||
|
color: var(--figli-warning);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Year bars chart (évolution du solde, entrées vs versements) --- */
|
||||||
|
#figli-dashboard-app .figli-year-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 14rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-bars {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
/* Slot stays in normal flex flow (so justify-content/gap on
|
||||||
|
.figli-year-bars above still lays several bars out side by side) and
|
||||||
|
gives the bar a definite height (100% of the track) to anchor
|
||||||
|
against. The bar itself must be position: absolute, not relative --
|
||||||
|
with relative, an explicit height opts it out of the flex stretch
|
||||||
|
default, so it renders flex-start (top) aligned *first*, and the
|
||||||
|
top/bottom: 50% from barStyle() only *offsets* that already-top
|
||||||
|
position instead of anchoring the edge to the middle -- the visible
|
||||||
|
bug this replaces (bars pinned near the top instead of growing from
|
||||||
|
the zero line). */
|
||||||
|
#figli-dashboard-app .figli-year-bar-slot {
|
||||||
|
position: relative;
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-bar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
min-height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
#figli-dashboard-app .figli-year-bar {
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Chart sections --- */
|
||||||
|
#figli-dashboard-app .figli-chart-section {
|
||||||
|
background: var(--figli-bg);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem 1.5rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Horizontal bar chart (solde par compte, par type, top clients) --- */
|
||||||
|
#figli-dashboard-app .figli-hbar-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem 1fr 7rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-track {
|
||||||
|
position: relative;
|
||||||
|
height: 0.9rem;
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-track.is-diverging {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-zero {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
#figli-dashboard-app .figli-hbar-fill {
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact variant -- same chart, narrower fixed columns and smaller
|
||||||
|
text so it fits inside a small-multiples grid card (see
|
||||||
|
.figli-year-hbar-grid below) instead of the full-width layout. */
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact {
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-row {
|
||||||
|
grid-template-columns: 5.5rem 1fr 3.5rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-track {
|
||||||
|
height: 0.65rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-hbar-chart.is-compact .figli-hbar-value {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Year small multiples (par année, next to the all-time chart) --- */
|
||||||
|
#figli-dashboard-app .figli-year-hbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-hbar-card {
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-year-hbar-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Vertical bar chart (CA par année) --- */
|
||||||
|
#figli-dashboard-app .figli-vbar-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 12rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-track {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-fill {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--figli-positive);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
#figli-dashboard-app .figli-vbar-fill {
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-vbar-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Trend grid (small multiples, one per compte) --- */
|
||||||
|
#figli-dashboard-app .figli-trend-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-trend-card {
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-trend-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-trend {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.2rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-fill.is-positive {
|
||||||
|
bottom: 50%;
|
||||||
|
background: var(--figli-positive);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-fill.is-negative {
|
||||||
|
top: 50%;
|
||||||
|
background: var(--figli-error);
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-mini-bar-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/* Only attached on this module's own front-end routes (see
|
||||||
|
figli_compta_ledger_page_attachments()) -- real Drupal admin pages
|
||||||
|
(/admin/*, node edit forms, etc.) keep all of this as normal.
|
||||||
|
Previously the top-bar rule below was unconditional (attached on every
|
||||||
|
page via admin_chrome), on the assumption that core Navigation's top
|
||||||
|
bar (`.top-bar`) always renders empty -- true on our own pages (no
|
||||||
|
blocks placed there, so whitespace text nodes from the Twig loop
|
||||||
|
scaffolding defeat its own `:not(:empty)` visibility check), but wrong
|
||||||
|
on entity edit forms, where Gin puts the page's local actions (the
|
||||||
|
Save button included) *inside* `.top-bar` -- hiding it there hid Save
|
||||||
|
itself, not just an empty bar. */
|
||||||
|
.top-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.top-bar ~ .dialog-off-canvas-main-canvas {
|
||||||
|
margin-block-start: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The core Navigation module's admin sidebar (#admin-toolbar, ~65px fixed
|
||||||
|
rail) + its collapsed-state control bar and overlay. */
|
||||||
|
#admin-toolbar,
|
||||||
|
.admin-toolbar-control-bar,
|
||||||
|
.admin-toolbar-overlay {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.dialog-off-canvas-main-canvas {
|
||||||
|
margin-inline-start: 0 !important;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
--figli-border: #dcdee2;
|
--figli-border: #dcdee2;
|
||||||
--figli-error: #c9312b;
|
--figli-error: #c9312b;
|
||||||
--figli-positive: #1a7f37;
|
--figli-positive: #1a7f37;
|
||||||
|
--figli-warning: #b8860b;
|
||||||
--figli-col-hover: rgba(15, 23, 42, 0.05);
|
--figli-col-hover: rgba(15, 23, 42, 0.05);
|
||||||
|
|
||||||
font-family: Inter, -apple-system, sans-serif;
|
font-family: Inter, -apple-system, sans-serif;
|
||||||
@@ -24,6 +25,7 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
--figli-border: #3d3e42;
|
--figli-border: #3d3e42;
|
||||||
--figli-error: #ff6b6b;
|
--figli-error: #ff6b6b;
|
||||||
--figli-positive: #4ade80;
|
--figli-positive: #4ade80;
|
||||||
|
--figli-warning: #f0b429;
|
||||||
--figli-col-hover: rgba(255, 255, 255, 0.07);
|
--figli-col-hover: rgba(255, 255, 255, 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,91 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Each filter control + its "✕ effacer" button, side by side. */
|
||||||
|
#figli-home-app .figli-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-filter-clear {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.15rem;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-filter-clear:hover {
|
||||||
|
color: var(--figli-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compte/Type filters: a native <select multiple> can't collapse to a
|
||||||
|
single-line trigger and expand as an overlay -- it's either a
|
||||||
|
single-line dropdown, or an always-open multi-row list box. Custom
|
||||||
|
dropdown instead: a trigger showing the current selection, and a
|
||||||
|
checkbox list overlay shown on hover (pure CSS, no open/close state
|
||||||
|
needed) -- checkboxes rather than ctrl/shift+click, but same result
|
||||||
|
(several comptes/types at once) with an even more discoverable
|
||||||
|
interaction (no keyboard modifier to know about).*/
|
||||||
|
#figli-home-app .figli-multiselect {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-multiselect-trigger {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-multiselect-arrow {
|
||||||
|
float: right;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-multiselect-panel {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-multiselect:hover .figli-multiselect-panel {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-multiselect-option {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-client-input {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
#figli-home-app .figli-checkbox {
|
#figli-home-app .figli-checkbox {
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -72,24 +159,49 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#figli-home-app .figli-table-wrap {
|
#figli-home-app .figli-table-wrap {
|
||||||
overflow-x: auto;
|
/* Fixed columns below are sized to always fit -- hidden (not auto) so
|
||||||
|
there's never a horizontal scrollbar to fight the vertical one for
|
||||||
|
layout space. With both set to auto, the browser has to guess
|
||||||
|
whether a vertical scrollbar will appear before it can lay out the
|
||||||
|
table's width:100%, and getting that guess wrong understates the
|
||||||
|
available width by one scrollbar's worth, which was enough to tip
|
||||||
|
this from "just barely fits" into "needs to scroll horizontally
|
||||||
|
too" -- and any actual residual overflow (a column pushed a few px
|
||||||
|
over by content this doesn't attempt to break) is silently clipped
|
||||||
|
here instead of surfacing a scrollbar for it. */
|
||||||
|
overflow-x: hidden;
|
||||||
border: 1px solid var(--figli-border);
|
border: 1px solid var(--figli-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* table-layout: fixed -- auto layout was recomputing every column's
|
||||||
|
width from whatever happened to be currently loaded, so the table
|
||||||
|
visibly reflowed (and drifted wider than the viewport, forcing
|
||||||
|
horizontal scroll) every time loadOlder()/loadNewer() brought in rows
|
||||||
|
with different content. Fixed layout locks each column to the widths
|
||||||
|
set below regardless of content, so the table's total width never
|
||||||
|
changes -- no more horizontal scroll, and loadOlder()'s scroll
|
||||||
|
compensation (which assumes the height delta after prepending is
|
||||||
|
*only* the new rows' own height) is now accurate again, since existing
|
||||||
|
rows no longer reflow when new ones are added. */
|
||||||
#figli-home-app table {
|
#figli-home-app table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
background: var(--figli-bg);
|
background: var(--figli-bg);
|
||||||
color: var(--figli-text);
|
color: var(--figli-text);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-home-app th,
|
#figli-home-app th,
|
||||||
#figli-home-app td {
|
#figli-home-app td {
|
||||||
|
/* border-box -- otherwise each column's percentage width (fixed
|
||||||
|
layout, above) sets only the content box, and this cell's own
|
||||||
|
padding/border get added on top of it, so 18 columns' worth of
|
||||||
|
padding quietly pushes the table wider than 100% again. */
|
||||||
|
box-sizing: border-box;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--figli-border);
|
border-bottom: 1px solid var(--figli-border);
|
||||||
@@ -114,10 +226,16 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#figli-home-app th:nth-child(6),
|
||||||
#figli-home-app td.figli-libelle {
|
#figli-home-app td.figli-libelle {
|
||||||
|
width: 10%;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app th:nth-child(7),
|
||||||
|
#figli-home-app td.figli-flag-cell {
|
||||||
|
width: 6%;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
min-width: 220px;
|
|
||||||
max-width: 340px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-home-app tr.figli-group-row td {
|
#figli-home-app tr.figli-group-row td {
|
||||||
@@ -152,6 +270,15 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
#figli-home-app td.figli-solde-debiteur {
|
#figli-home-app td.figli-solde-debiteur {
|
||||||
color: var(--figli-error);
|
color: var(--figli-error);
|
||||||
}
|
}
|
||||||
|
/* Footer totals row sets its own `color` (higher specificity: tr.class +
|
||||||
|
td beats a lone td.class) which otherwise overrides the solde colors
|
||||||
|
above -- repeat them scoped to the row so red/green still wins there. */
|
||||||
|
#figli-home-app tr.figli-totals-row td.figli-solde-crediteur {
|
||||||
|
color: var(--figli-positive);
|
||||||
|
}
|
||||||
|
#figli-home-app tr.figli-totals-row td.figli-solde-debiteur {
|
||||||
|
color: var(--figli-error);
|
||||||
|
}
|
||||||
|
|
||||||
/* Single colored + bold anchor per row (Montant HT only) so entrées vs
|
/* 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,
|
sorties are scannable at a glance -- everything else in the row (TTC,
|
||||||
@@ -165,24 +292,30 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error rows: a thin red outline around the row, not a background fill --
|
/* Écart is called out by the Écart column itself (bold red, see
|
||||||
easier to read, doesn't fight with dark mode. */
|
.figli-ecart below) -- no separate row-level treatment needed. */
|
||||||
#figli-home-app tr.figli-error-row td {
|
|
||||||
border-top: 1px solid var(--figli-error);
|
|
||||||
border-bottom: 1px solid var(--figli-error);
|
|
||||||
}
|
|
||||||
#figli-home-app tr.figli-error-row td:first-child {
|
|
||||||
border-left: 1px solid var(--figli-error);
|
|
||||||
}
|
|
||||||
#figli-home-app tr.figli-error-row td:last-child {
|
|
||||||
border-right: 1px solid var(--figli-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
#figli-home-app td.figli-ecart {
|
#figli-home-app td.figli-ecart {
|
||||||
color: var(--figli-error);
|
color: var(--figli-error);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Signalé rows: a thick amber accent bar on the left edge. */
|
||||||
|
#figli-home-app tr.figli-flag-row td:first-child {
|
||||||
|
box-shadow: inset 4px 0 0 var(--figli-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-flag-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
margin: 0 0.2rem 0.15rem 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
|
||||||
|
color: var(--figli-warning);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Column highlight to pair with the row hover, forming a crosshair over
|
/* Column highlight to pair with the row hover, forming a crosshair over
|
||||||
the hovered cell. box-shadow (not background) so it layers on top of
|
the hovered cell. box-shadow (not background) so it layers on top of
|
||||||
whatever the cell already has -- sticky header/footer backgrounds,
|
whatever the cell already has -- sticky header/footer backgrounds,
|
||||||
@@ -206,8 +339,56 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
#figli-home-app .type-achat { background: #1d4ed81a; color: #3b6fe0; }
|
#figli-home-app .type-achat { background: #1d4ed81a; color: #3b6fe0; }
|
||||||
#figli-home-app .type-hebergement { background: #0e91821a; color: #0e9182; }
|
#figli-home-app .type-hebergement { background: #0e91821a; color: #0e9182; }
|
||||||
#figli-home-app .type-ouverture { background: #7c3aed1a; color: #9061f0; }
|
#figli-home-app .type-ouverture { background: #7c3aed1a; color: #9061f0; }
|
||||||
|
#figli-home-app .type-sous_traitant { background: #c9312b1a; color: #c9312b; }
|
||||||
|
#figli-home-app .type-salaire_stage { background: #0891b21a; color: #0891b2; }
|
||||||
|
#figli-home-app .type-charges_local_pro { background: #65a30d1a; color: #65a30d; }
|
||||||
#figli-home-app .type-autre { background: #6b72801a; color: var(--figli-text-light); }
|
#figli-home-app .type-autre { background: #6b72801a; color: var(--figli-text-light); }
|
||||||
|
|
||||||
|
/* Click-to-edit type badge -- swaps for a native <select> in place
|
||||||
|
(figli-type-select below), no modal needed for this one field. */
|
||||||
|
#figli-home-app .figli-badge-editable {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-badge-editable:hover {
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-type-select {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.05rem 0.2rem;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client/Facture/Libellé: click-to-edit like the type badge above, but
|
||||||
|
plain text rather than a pill -- a dotted underline is enough of an
|
||||||
|
affordance without implying a fixed set of choices the way the type
|
||||||
|
badge's pill shape does. */
|
||||||
|
/* display: block + min-height (not just the default inline text-only
|
||||||
|
box) so the whole cell is a forgiving click target even when empty --
|
||||||
|
the "—" placeholder alone is a tiny, easy-to-miss target otherwise. */
|
||||||
|
#figli-home-app .figli-editable-cell {
|
||||||
|
display: block;
|
||||||
|
min-height: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px dotted transparent;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-editable-cell:hover {
|
||||||
|
border-bottom-color: var(--figli-text-light);
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-inline-input {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
#figli-home-app .figli-note {
|
#figli-home-app .figli-note {
|
||||||
color: var(--figli-text-light);
|
color: var(--figli-text-light);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -222,18 +403,57 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
table's default 0.6rem horizontal cell padding. */
|
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: 3%;
|
||||||
padding: 0.2rem 0.1rem;
|
padding: 0.2rem 0.1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Column widths for the fixed layout above -- sized so the widest
|
||||||
|
realistic content (longest client/type label, an 8-figure amount)
|
||||||
|
fits without pushing the table past 100%, while the two free-text
|
||||||
|
columns (Client, Libellé/Détail) get most of the remaining room.
|
||||||
|
Every column is a percentage (including actions-col/Date above,
|
||||||
|
deliberately not rem) -- mixing units meant the rem columns' pixel
|
||||||
|
width was added *on top of* the percentage budget instead of coming
|
||||||
|
out of it, silently pushing the table a few pixels past 100% and
|
||||||
|
reintroducing the horizontal scrollbar this is meant to avoid.
|
||||||
|
Percentages intentionally sum to a little under 100%: table-layout:
|
||||||
|
fixed treats them as relative weights, not a hard budget, so slightly
|
||||||
|
under leaves headroom rather than risking every column getting
|
||||||
|
scaled down to fit. */
|
||||||
|
#figli-home-app th:nth-child(2),
|
||||||
|
#figli-home-app td:nth-child(2) {
|
||||||
|
width: 5%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-home-app th:nth-child(3),
|
||||||
|
#figli-home-app td:nth-child(3) {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
#figli-home-app th:nth-child(4),
|
||||||
|
#figli-home-app td:nth-child(4) {
|
||||||
|
width: 7%;
|
||||||
|
}
|
||||||
|
#figli-home-app th:nth-child(5),
|
||||||
|
#figli-home-app td:nth-child(5) {
|
||||||
|
width: 5%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-home-app .amount:not(.compte-col) {
|
||||||
|
width: 6.2%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#figli-home-app .amount.compte-col {
|
||||||
|
width: 4.3%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons stay inline (not flex) inside the <td> -- display: flex on a
|
/* Buttons stay inline (not flex) inside the <td> -- display: flex on a
|
||||||
table cell breaks the table's own column-width calculation (observed:
|
table cell breaks the table's own column-width calculation (observed:
|
||||||
a ~55px gap opening up between this column and the next, and the
|
a ~55px gap opening up between this column and the next, and the
|
||||||
buttons rendering outside their cell's actual boundary). */
|
buttons rendering outside their cell's actual boundary). */
|
||||||
#figli-home-app .figli-edit-btn,
|
#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;
|
||||||
@@ -248,17 +468,12 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
vertical-align: middle;
|
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
|
/* Reconciliation badge on entrée rows: répartition (money owed) vs the
|
||||||
combined répartition of every linked sortie (money actually paid out). */
|
combined répartition of every linked sortie (money actually paid out). */
|
||||||
#figli-home-app .figli-recon-badge {
|
#figli-home-app .figli-recon-badge {
|
||||||
@@ -269,13 +484,21 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
|
||||||
background: #1a7f371a;
|
background: #1a7f371a;
|
||||||
color: var(--figli-positive);
|
color: var(--figli-positive);
|
||||||
}
|
}
|
||||||
|
/* Not every badge using this class does something on click (the
|
||||||
|
ouverture/clôture écart badge is purely informational) -- only show
|
||||||
|
the pointer cursor where a click actually goes somewhere. */
|
||||||
|
#figli-home-app .figli-recon-badge.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* Still green, not orange -- it's money owed, not a warning, and
|
||||||
|
green-but-darker reads as "related to, but not yet, Lié" (the default
|
||||||
|
badge green above) rather than as an unrelated anomaly color. */
|
||||||
#figli-home-app .figli-recon-badge.is-reste {
|
#figli-home-app .figli-recon-badge.is-reste {
|
||||||
background: #d97a0a1a;
|
background: #1665341a;
|
||||||
color: #d97a0a;
|
color: #166534;
|
||||||
}
|
}
|
||||||
#figli-home-app .figli-recon-badge.is-anomalie {
|
#figli-home-app .figli-recon-badge.is-anomalie {
|
||||||
background: #c9312b1a;
|
background: #c9312b1a;
|
||||||
@@ -287,6 +510,166 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
padding: 0.2rem 0.6rem;
|
padding: 0.2rem 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Entrée + sorties liées drill-down: a modal overlay rather than
|
||||||
|
replacing the main table's rows in place, so opening/closing it never
|
||||||
|
disturbs the main table's scroll position. */
|
||||||
|
#figli-home-app .figli-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal {
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 96vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-close:hover {
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border-color: var(--figli-border);
|
||||||
|
color: var(--figli-text);
|
||||||
|
}
|
||||||
|
/* Signalement modal -- much smaller than the entrées/sorties drill-down
|
||||||
|
above (a short list of tags, not a table), so it gets its own narrow
|
||||||
|
width instead of the 96vw default. */
|
||||||
|
#figli-home-app .figli-flag-modal {
|
||||||
|
width: 24rem;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-flag-modal-body {
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-flag-modal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-flag-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-flag-remove:hover {
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border-color: var(--figli-border);
|
||||||
|
color: var(--figli-error);
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-flag-modal-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-flag-modal-add input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
color: var(--figli-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body table {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
/* Only a handful of rows in this view -- wrap instead of the main
|
||||||
|
table's nowrap, so 14 columns fit the modal width without ever
|
||||||
|
needing horizontal scroll. */
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
/* table-layout: fixed otherwise spreads all 16 columns evenly, squeezing
|
||||||
|
the libellé text into the same width as an empty compte column --
|
||||||
|
give the columns that actually carry content (client/type/libellé)
|
||||||
|
proportionally more room, comptes/montants less. */
|
||||||
|
#figli-home-app .figli-modal-body th:nth-child(1),
|
||||||
|
#figli-home-app .figli-modal-body td:nth-child(1) {
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body th:nth-child(2),
|
||||||
|
#figli-home-app .figli-modal-body td:nth-child(2) {
|
||||||
|
width: 4.5rem;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body th:nth-child(3),
|
||||||
|
#figli-home-app .figli-modal-body td:nth-child(3) {
|
||||||
|
width: 9%;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body th:nth-child(4),
|
||||||
|
#figli-home-app .figli-modal-body td:nth-child(4) {
|
||||||
|
width: 8%;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body th:nth-child(5),
|
||||||
|
#figli-home-app .figli-modal-body td.figli-libelle {
|
||||||
|
width: 24%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body .amount.compte-col {
|
||||||
|
width: 4.5%;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-modal-body .amount:not(.compte-col) {
|
||||||
|
width: 7%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliding-window edge markers (IntersectionObserver targets) -- kept
|
||||||
|
short so they don't add visible dead space when idle, tall enough
|
||||||
|
(min-height) to reliably intersect the observer's root margin. */
|
||||||
|
#figli-home-app tr.figli-sentinel-row td {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
#figli-home-app .figli-error {
|
#figli-home-app .figli-error {
|
||||||
background: #fde8e8;
|
background: #fde8e8;
|
||||||
border: 1px solid #f4a3a3;
|
border: 1px solid #f4a3a3;
|
||||||
@@ -294,3 +677,14 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dismissible, doesn't replace the table like the top-level fetch error
|
||||||
|
does -- a failed inline type change is a small hiccup, not a reason to
|
||||||
|
hide everything that's already loaded. */
|
||||||
|
#figli-home-app .figli-inline-error {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ function _figli_compta_ledger_clients() {
|
|||||||
function figli_compta_ledger_install() {
|
function figli_compta_ledger_install() {
|
||||||
_figli_compta_ledger_create_vocabulary('compte', 'Compte', _figli_compta_ledger_comptes());
|
_figli_compta_ledger_create_vocabulary('compte', 'Compte', _figli_compta_ledger_comptes());
|
||||||
_figli_compta_ledger_create_vocabulary('client', 'Client', _figli_compta_ledger_clients());
|
_figli_compta_ledger_create_vocabulary('client', 'Client', _figli_compta_ledger_clients());
|
||||||
|
// No starter terms -- unlike compte/client, flags are created on the fly
|
||||||
|
// as they're needed (see field_flag's auto_create handler setting below).
|
||||||
|
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
|
||||||
_figli_compta_ledger_create_paragraph_repartition();
|
_figli_compta_ledger_create_paragraph_repartition();
|
||||||
_figli_compta_ledger_create_node_type_ligne_comptable();
|
_figli_compta_ledger_create_node_type_ligne_comptable();
|
||||||
}
|
}
|
||||||
@@ -87,16 +90,25 @@ function _figli_field($entity_type, $bundle, $field_name, $label, $type, array $
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $target_type, $target_bundle, $required = FALSE) {
|
function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $target_type, $target_bundle, $required = FALSE, $cardinality = 1, $auto_create = FALSE) {
|
||||||
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
|
if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
|
||||||
FieldStorageConfig::create([
|
FieldStorageConfig::create([
|
||||||
'field_name' => $field_name,
|
'field_name' => $field_name,
|
||||||
'entity_type' => $entity_type,
|
'entity_type' => $entity_type,
|
||||||
'type' => 'entity_reference',
|
'type' => 'entity_reference',
|
||||||
|
'cardinality' => $cardinality,
|
||||||
'settings' => ['target_type' => $target_type],
|
'settings' => ['target_type' => $target_type],
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
|
if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
|
||||||
|
$handler_settings = ['target_bundles' => [$target_bundle => $target_bundle]];
|
||||||
|
// Lets the tags-style widget create a new term on the fly instead of
|
||||||
|
// rejecting anything not already in the vocabulary -- same "autocreate"
|
||||||
|
// behavior LedgerActionsController::updateField() already replicates
|
||||||
|
// by hand for the /lignes inline-edit endpoint (client, flag).
|
||||||
|
if ($auto_create) {
|
||||||
|
$handler_settings['auto_create'] = TRUE;
|
||||||
|
}
|
||||||
FieldConfig::create([
|
FieldConfig::create([
|
||||||
'field_name' => $field_name,
|
'field_name' => $field_name,
|
||||||
'entity_type' => $entity_type,
|
'entity_type' => $entity_type,
|
||||||
@@ -105,7 +117,7 @@ function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $ta
|
|||||||
'required' => $required,
|
'required' => $required,
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'handler' => 'default:' . $target_type,
|
'handler' => 'default:' . $target_type,
|
||||||
'handler_settings' => ['target_bundles' => [$target_bundle => $target_bundle]],
|
'handler_settings' => $handler_settings,
|
||||||
],
|
],
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
@@ -188,15 +200,28 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
|||||||
'hebergement' => 'Hébergement',
|
'hebergement' => 'Hébergement',
|
||||||
'autre' => 'Autre',
|
'autre' => 'Autre',
|
||||||
'ouverture' => "Ligne d'ouverture",
|
'ouverture' => "Ligne d'ouverture",
|
||||||
|
'sous_traitant' => 'Sous-traitant',
|
||||||
|
'salaire_stage' => 'Salaire / stage',
|
||||||
|
'charges_local_pro' => 'Charges local pro',
|
||||||
],
|
],
|
||||||
], TRUE);
|
], TRUE);
|
||||||
|
|
||||||
_figli_entity_ref_field('node', 'ligne_comptable', 'field_client', 'Client', 'taxonomy_term', 'client');
|
_figli_entity_ref_field('node', 'ligne_comptable', 'field_client', 'Client', 'taxonomy_term', 'client');
|
||||||
|
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
|
||||||
|
|
||||||
_figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
|
_figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
|
||||||
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
|
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
|
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
|
||||||
|
|
||||||
|
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely
|
||||||
|
// informational, never read by figli_compta_ledger_node_presave() or any
|
||||||
|
// total/solde calculation. Multi-value (-1) + auto_create: a line can
|
||||||
|
// carry several tags, and typing a new one creates it rather than
|
||||||
|
// rejecting it, same as the /lignes inline-edit endpoint already does
|
||||||
|
// by hand for field_client.
|
||||||
|
_figli_entity_ref_field('node', 'ligne_comptable', 'field_flag', 'Signalement', 'taxonomy_term', 'flag', FALSE, -1, TRUE);
|
||||||
|
|
||||||
_figli_paragraph_field('node', 'ligne_comptable', 'field_repartition', 'Répartition', 'repartition');
|
_figli_paragraph_field('node', 'ligne_comptable', 'field_repartition', 'Répartition', 'repartition');
|
||||||
|
|
||||||
if (!EntityFormDisplay::load('node.ligne_comptable.default')) {
|
if (!EntityFormDisplay::load('node.ligne_comptable.default')) {
|
||||||
@@ -209,10 +234,12 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
|||||||
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
|
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
|
||||||
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
|
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
|
||||||
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
|
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
|
||||||
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 3])
|
->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
|
||||||
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 4])
|
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 4])
|
||||||
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 5, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
|
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 5])
|
||||||
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 6])
|
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 6, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
|
||||||
|
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 7])
|
||||||
|
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])
|
||||||
->save();
|
->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,10 +253,103 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
|||||||
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
|
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
|
||||||
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
|
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
|
||||||
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
|
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
|
||||||
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 3])
|
->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
|
||||||
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 4])
|
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 4])
|
||||||
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 5])
|
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 5])
|
||||||
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 6])
|
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 6])
|
||||||
|
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 7])
|
||||||
|
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])
|
||||||
->save();
|
->save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds field_numero_facture (N° Facture) to ligne_comptable. Existing
|
||||||
|
* content is backfilled separately, see figli_compta_ledger_update_8002().
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8001() {
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
|
||||||
|
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display && !$form_display->getComponent('field_numero_facture')) {
|
||||||
|
$form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])->save();
|
||||||
|
}
|
||||||
|
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($view_display && !$view_display->getComponent('field_numero_facture')) {
|
||||||
|
$view_display->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills field_numero_facture for existing ligne_comptable content by
|
||||||
|
* extracting an invoice number from field_notes (or the title when notes
|
||||||
|
* is empty -- the same fallback the front-end libellé column uses)
|
||||||
|
* wherever one can be confidently identified, left blank otherwise
|
||||||
|
* (never guessed).
|
||||||
|
*
|
||||||
|
* Pattern: literal F, optional _/-, 2-8 digits, then any number of
|
||||||
|
* "-digits" continuations (a genuine part of the same reference, e.g.
|
||||||
|
* "F2549-50-51" or "F2025-06-002"), then 0-3 trailing uppercase letters
|
||||||
|
* (e.g. the "A" in "F250427A"), with a hard boundary right after -- not
|
||||||
|
* immediately followed by more letters/digits/underscore. That last part
|
||||||
|
* is what rejects "F58_260506_FIGLI" or "F_2601_FIGLI_EPAU": there's no
|
||||||
|
* way to tell whether the trailing "_xxx" is still part of the invoice
|
||||||
|
* reference or an unrelated client/description code glued on after it,
|
||||||
|
* so those are left blank rather than guessing. Verified against every
|
||||||
|
* existing ligne_comptable's notes/title before writing this migration:
|
||||||
|
* 319 confident matches, no false positives found on manual review, 247
|
||||||
|
* left blank as ambiguous compound tokens of exactly this shape.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8002() {
|
||||||
|
$pattern = '/(?<![A-Za-z0-9])F[_-]?\d{2,8}(?:-\d+)*[A-Z]{0,3}(?![A-Za-z0-9_])/';
|
||||||
|
|
||||||
|
$storage = \Drupal::entityTypeManager()->getStorage('node');
|
||||||
|
$nids = $storage->getQuery()
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$filled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
// Migrated historical data has known, deliberately-preserved
|
||||||
|
// répartition mismatches -- saving these nodes for an unrelated field
|
||||||
|
// would otherwise trip figli_compta_ledger_node_presave()'s
|
||||||
|
// sum(répartition) == montant_ht check.
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||||
|
foreach ($storage->loadMultiple($nids) as $node) {
|
||||||
|
$text = $node->hasField('field_notes') && !$node->get('field_notes')->isEmpty()
|
||||||
|
? $node->get('field_notes')->value
|
||||||
|
: $node->label();
|
||||||
|
if (!$text || !preg_match($pattern, $text, $matches)) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$node->set('field_numero_facture', $matches[0]);
|
||||||
|
$node->save();
|
||||||
|
$filled++;
|
||||||
|
}
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||||
|
|
||||||
|
return "Numéro de facture rempli pour $filled lignes, $skipped laissées vides (aucun motif fiable trouvé).";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds field_flag (Signalement) to ligne_comptable -- free tagging for
|
||||||
|
* problems that can't be detected automatically (e.g. "client impayé"),
|
||||||
|
* see figli_compta_ledger.module's docblock. No backfill: unlike
|
||||||
|
* field_numero_facture there's nothing to infer from existing content,
|
||||||
|
* this is new operational metadata going forward.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8003() {
|
||||||
|
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
|
||||||
|
_figli_entity_ref_field('node', 'ligne_comptable', 'field_flag', 'Signalement', 'taxonomy_term', 'flag', FALSE, -1, TRUE);
|
||||||
|
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display && !$form_display->getComponent('field_flag')) {
|
||||||
|
$form_display->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])->save();
|
||||||
|
}
|
||||||
|
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($view_display && !$view_display->getComponent('field_flag')) {
|
||||||
|
$view_display->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,26 @@ dashboard:
|
|||||||
- core/drupal
|
- core/drupal
|
||||||
- figli_compta_ledger/vue
|
- figli_compta_ledger/vue
|
||||||
|
|
||||||
|
dashboard_compte:
|
||||||
|
js:
|
||||||
|
js/dashboard-compte.js: {}
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/dashboard.css: {}
|
||||||
|
dependencies:
|
||||||
|
- core/drupal
|
||||||
|
- figli_compta_ledger/vue
|
||||||
|
|
||||||
admin_chrome:
|
admin_chrome:
|
||||||
css:
|
css:
|
||||||
theme:
|
theme:
|
||||||
css/admin-chrome.css: {}
|
css/admin-chrome.css: {}
|
||||||
|
js:
|
||||||
|
js/admin-chrome.js: {}
|
||||||
|
dependencies:
|
||||||
|
- core/drupal
|
||||||
|
|
||||||
|
hide_admin_chrome:
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/hide-admin-chrome.css: {}
|
||||||
|
|||||||
@@ -14,10 +14,18 @@ figli_compta_ledger.dashboard:
|
|||||||
parent: system.admin
|
parent: system.admin
|
||||||
weight: -10
|
weight: -10
|
||||||
|
|
||||||
|
figli_compta_ledger.dashboard_compte:
|
||||||
|
title: 'Tableau de bord par compte'
|
||||||
|
description: 'Entrées et versements par compte associé, reste à verser'
|
||||||
|
route_name: figli_compta_ledger.dashboard_compte
|
||||||
|
menu_name: admin
|
||||||
|
parent: system.admin
|
||||||
|
weight: -9
|
||||||
|
|
||||||
figli_compta_ledger.history:
|
figli_compta_ledger.history:
|
||||||
title: 'Historique du grand livre'
|
title: 'Historique du grand livre'
|
||||||
description: 'Toutes les révisions de toutes les lignes comptables'
|
description: 'Toutes les révisions de toutes les lignes comptables'
|
||||||
route_name: figli_compta_ledger.history
|
route_name: figli_compta_ledger.history
|
||||||
menu_name: admin
|
menu_name: admin
|
||||||
parent: system.admin
|
parent: system.admin
|
||||||
weight: -9
|
weight: -8
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Drupal\Core\Entity\EntityStorageException;
|
|||||||
use Drupal\Core\Form\FormStateInterface;
|
use Drupal\Core\Form\FormStateInterface;
|
||||||
use Drupal\Core\Ajax\AjaxResponse;
|
use Drupal\Core\Ajax\AjaxResponse;
|
||||||
use Drupal\Core\Ajax\CloseModalDialogCommand;
|
use Drupal\Core\Ajax\CloseModalDialogCommand;
|
||||||
|
use Drupal\Core\Ajax\MessageCommand;
|
||||||
use Drupal\Core\Ajax\ReplaceCommand;
|
use Drupal\Core\Ajax\ReplaceCommand;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,8 +53,10 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// field_entree_liee only makes sense on the "sortie" types that pay out
|
// 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
|
// against a client invoice -- charge/autre/ouverture/salaire_stage
|
||||||
// specific entrée, and entree lines don't link to themselves.
|
// aren't tied to a specific entrée, and entree lines don't link to
|
||||||
|
// themselves. Mirrors LedgerActionsController::LINKABLE_TYPES (the
|
||||||
|
// inline type-change endpoint) and js/home.js's LINKABLE_TYPES.
|
||||||
if (isset($form['field_entree_liee'])) {
|
if (isset($form['field_entree_liee'])) {
|
||||||
$form['field_entree_liee']['#states'] = [
|
$form['field_entree_liee']['#states'] = [
|
||||||
'visible' => [
|
'visible' => [
|
||||||
@@ -62,6 +65,8 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
|
|||||||
[':input[name="field_type_ligne"]' => ['value' => 'achat']],
|
[':input[name="field_type_ligne"]' => ['value' => 'achat']],
|
||||||
'or',
|
'or',
|
||||||
[':input[name="field_type_ligne"]' => ['value' => 'hebergement']],
|
[':input[name="field_type_ligne"]' => ['value' => 'hebergement']],
|
||||||
|
'or',
|
||||||
|
[':input[name="field_type_ligne"]' => ['value' => 'sous_traitant']],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -117,10 +122,32 @@ function figli_compta_ledger_validate_repartition(array &$form, FormStateInterfa
|
|||||||
function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterface $form_state) {
|
function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterface $form_state) {
|
||||||
$response = new AjaxResponse();
|
$response = new AjaxResponse();
|
||||||
if ($form_state->getErrors()) {
|
if ($form_state->getErrors()) {
|
||||||
|
// The re-rendered form below does mark every répartition row with an
|
||||||
|
// `error`/`has-error` CSS class (a subtle red outline), but
|
||||||
|
// setErrorByName()'s message text itself has nowhere to render --
|
||||||
|
// that normally goes through the page's status-messages region,
|
||||||
|
// which isn't part of this standalone AJAX-replaced form fragment.
|
||||||
|
// Without this, the message was silently discarded: the modal just
|
||||||
|
// stayed open with reddened fields and no visible explanation.
|
||||||
|
foreach ($form_state->getErrors() as $error) {
|
||||||
|
$response->addCommand(new MessageCommand($error, NULL, ['type' => 'error'], FALSE));
|
||||||
|
}
|
||||||
unset($form['#prefix'], $form['#suffix']);
|
unset($form['#prefix'], $form['#suffix']);
|
||||||
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
|
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
// Surface whatever messenger() queued during save() -- e.g. NodeForm's
|
||||||
|
// own "Ligne comptable X has been created/updated." -- before closing
|
||||||
|
// the modal. Without this the message is silently lost: the /lignes
|
||||||
|
// table only ever refreshes via AJAX afterwards ("dialog:afterclose" ->
|
||||||
|
// reloadWindow()), never a full page load, so a session-queued message
|
||||||
|
// would otherwise sit unseen until some unrelated future page render.
|
||||||
|
foreach (\Drupal::messenger()->all() as $type => $messages) {
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$response->addCommand(new MessageCommand($message, NULL, ['type' => $type], FALSE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\Drupal::messenger()->deleteAll();
|
||||||
$response->addCommand(new CloseModalDialogCommand());
|
$response->addCommand(new CloseModalDialogCommand());
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
@@ -189,25 +216,41 @@ 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' => ['can_view_history' => FALSE],
|
'variables' => ['can_view_history' => FALSE, 'current_route' => NULL],
|
||||||
'template' => 'figli-compta-home',
|
'template' => 'figli-compta-home',
|
||||||
],
|
],
|
||||||
'figli_compta_dashboard' => [
|
'figli_compta_dashboard' => [
|
||||||
'variables' => [],
|
'variables' => ['current_route' => NULL],
|
||||||
'template' => 'figli-compta-dashboard',
|
'template' => 'figli-compta-dashboard',
|
||||||
],
|
],
|
||||||
|
'figli_compta_dashboard_compte' => [
|
||||||
|
'variables' => ['current_route' => NULL],
|
||||||
|
'template' => 'figli-compta-dashboard-compte',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements hook_page_attachments().
|
* Implements hook_page_attachments().
|
||||||
*
|
*
|
||||||
* Core Navigation's top bar renders empty (whitespace-only regions defeat
|
* The admin top bar and sidebar (Gin/core Navigation chrome) are only
|
||||||
* its own :not(:empty) visibility check) on every page, not just admin
|
* hidden on this module's own front-end pages -- real Drupal admin pages
|
||||||
* routes -- attach the fix globally rather than per-route.
|
* (/admin/*, node edit forms, etc.) keep them, since Gin puts the page's
|
||||||
|
* local actions (Save included) inside the top bar there. Route-scoped
|
||||||
|
* rather than folded into the always-on admin_chrome attachment below.
|
||||||
*/
|
*/
|
||||||
function figli_compta_ledger_page_attachments(array &$attachments) {
|
function figli_compta_ledger_page_attachments(array &$attachments) {
|
||||||
$attachments['#attached']['library'][] = 'figli_compta_ledger/admin_chrome';
|
$attachments['#attached']['library'][] = 'figli_compta_ledger/admin_chrome';
|
||||||
|
|
||||||
|
$front_end_routes = [
|
||||||
|
'figli_compta_ledger.home',
|
||||||
|
'figli_compta_ledger.dashboard',
|
||||||
|
'figli_compta_ledger.history',
|
||||||
|
'figli_compta_ledger.link_entree',
|
||||||
|
];
|
||||||
|
if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
|
||||||
|
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ figli_compta_ledger.dashboard:
|
|||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access content'
|
||||||
|
|
||||||
|
figli_compta_ledger.dashboard_compte:
|
||||||
|
path: '/dashboard/compte'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView'
|
||||||
|
_title: 'Tableau de bord par compte - SAS Figures Libres'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
figli_compta_ledger.history:
|
figli_compta_ledger.history:
|
||||||
path: '/lignes/historique'
|
path: '/lignes/historique'
|
||||||
defaults:
|
defaults:
|
||||||
@@ -33,3 +41,66 @@ figli_compta_ledger.link_entree:
|
|||||||
parameters:
|
parameters:
|
||||||
node:
|
node:
|
||||||
type: entity:node
|
type: entity:node
|
||||||
|
|
||||||
|
figli_compta_ledger.api_totaux_annee:
|
||||||
|
path: '/lignes/api/totaux'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
|
figli_compta_ledger.api_annees:
|
||||||
|
path: '/lignes/api/annees'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
|
figli_compta_ledger.api_reconciliation_ouverture:
|
||||||
|
path: '/lignes/api/reconciliation-ouverture'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
|
figli_compta_ledger.api_groupe_entree:
|
||||||
|
path: '/lignes/api/groupe/{node}'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
options:
|
||||||
|
parameters:
|
||||||
|
node:
|
||||||
|
type: entity:node
|
||||||
|
|
||||||
|
figli_compta_ledger.api_dashboard_stats:
|
||||||
|
path: '/dashboard/api/stats'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
|
figli_compta_ledger.update_type:
|
||||||
|
path: '/lignes/{node}/type'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateType'
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
_entity_access: 'node.update'
|
||||||
|
options:
|
||||||
|
parameters:
|
||||||
|
node:
|
||||||
|
type: entity:node
|
||||||
|
|
||||||
|
figli_compta_ledger.update_field:
|
||||||
|
path: '/lignes/{node}/champ'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateField'
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
_entity_access: 'node.update'
|
||||||
|
options:
|
||||||
|
parameters:
|
||||||
|
node:
|
||||||
|
type: entity:node
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
* Non-error Drupal status messages (status/warning) auto-dismiss after a
|
||||||
|
* couple seconds; error messages always require an explicit click on
|
||||||
|
* Gin's own dismiss button (.js-message-button-hide) -- something that
|
||||||
|
* needs attention shouldn't vanish on its own.
|
||||||
|
*
|
||||||
|
* Drupal.Message.add() (used both for messages already in the initial
|
||||||
|
* page render and for MessageCommand-driven AJAX messages) just appends a
|
||||||
|
* DOM node directly -- it never re-fires Drupal.attachBehaviors(), so a
|
||||||
|
* behaviors/once()-based approach would miss anything added after the
|
||||||
|
* initial page load. A MutationObserver sees every insertion path.
|
||||||
|
*/
|
||||||
|
(function (Drupal) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var AUTO_DISMISS_MS = 2000;
|
||||||
|
|
||||||
|
function dismiss(el) {
|
||||||
|
var btn = el.querySelector('.js-message-button-hide');
|
||||||
|
if (btn) {
|
||||||
|
btn.click();
|
||||||
|
} else {
|
||||||
|
el.style.opacity = 0;
|
||||||
|
el.classList.add('visually-hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(el) {
|
||||||
|
if (el.dataset.figliAutoDismissSeen) return;
|
||||||
|
el.dataset.figliAutoDismissSeen = '1';
|
||||||
|
if (el.classList.contains('messages--error')) return;
|
||||||
|
setTimeout(function () {
|
||||||
|
dismiss(el);
|
||||||
|
}, AUTO_DISMISS_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(root) {
|
||||||
|
root.querySelectorAll('.messages-list__item').forEach(handleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
scan(document);
|
||||||
|
new MutationObserver(function (mutations) {
|
||||||
|
mutations.forEach(function (mutation) {
|
||||||
|
mutation.addedNodes.forEach(function (node) {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
if (node.classList && node.classList.contains('messages-list__item')) {
|
||||||
|
handleMessage(node);
|
||||||
|
}
|
||||||
|
if (node.querySelectorAll) {
|
||||||
|
scan(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})(Drupal);
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
* Dashboard par compte associé (freelance) : un compte à la fois, choisi
|
||||||
|
* dans un menu. L'objectif principal est de faire ressortir clairement les
|
||||||
|
* "versement freelance" pas (encore) compensés par une "entrée client" --
|
||||||
|
* les autres types de ligne (charge, achat, hébergement, sous-traitant...)
|
||||||
|
* ne rentrent pas dans ce rapprochement, seuls entrée/versement comptent
|
||||||
|
* ici (contrairement à /lignes, où tout type "liable" est concerné).
|
||||||
|
*
|
||||||
|
* Comme dashboard.js : pas de librairie de graphes, tout est fait en
|
||||||
|
* div/CSS (voir dashboard.css) -- un dépendance de plus pour une poignée de
|
||||||
|
* barres n'en vaut pas la peine.
|
||||||
|
*
|
||||||
|
* Le rapprochement entrée/versement reprend exactement l'algorithme déjà
|
||||||
|
* en place dans home.js (reconciliationByEntree) : un versement peut être
|
||||||
|
* lié à plusieurs entrées à la fois (paiement groupé), auquel cas son
|
||||||
|
* montant est réparti à parts égales entre elles. Dupliqué ici plutôt que
|
||||||
|
* factorisé -- home.js et dashboard.js sont déjà deux fichiers autonomes
|
||||||
|
* sans module partagé, donc c'est la convention existante du projet, pas
|
||||||
|
* une entorse.
|
||||||
|
*/
|
||||||
|
(function (Drupal, Vue) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API_BASE = '/jsonapi/node/ligne_comptable';
|
||||||
|
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
|
||||||
|
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||||
|
const MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
|
||||||
|
// Same labels/colors as dashboard.js's "Répartition de l'activité par
|
||||||
|
// type" -- duplicated rather than shared, see this file's docblock.
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
entree: 'Entrée client',
|
||||||
|
charge: 'Charge structurelle',
|
||||||
|
versement: 'Versement freelance',
|
||||||
|
achat: 'Achat client',
|
||||||
|
hebergement: 'Hébergement',
|
||||||
|
sous_traitant: 'Sous-traitant',
|
||||||
|
salaire_stage: 'Salaire / stage',
|
||||||
|
charges_local_pro: 'Charges local pro',
|
||||||
|
autre: 'Autre',
|
||||||
|
};
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
entree: '#1a7f37',
|
||||||
|
charge: '#6b7280',
|
||||||
|
versement: '#d97a0a',
|
||||||
|
achat: '#3b6fe0',
|
||||||
|
hebergement: '#0e9182',
|
||||||
|
sous_traitant: '#c9312b',
|
||||||
|
salaire_stage: '#0891b2',
|
||||||
|
charges_local_pro: '#65a30d',
|
||||||
|
autre: '#9061f0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolve(includedMap, ref) {
|
||||||
|
if (!ref) return null;
|
||||||
|
return includedMap.get(ref.type + ':' + ref.id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same shape as home.js's buildRows() -- only the two types this page
|
||||||
|
// cares about ever reach it (see fetchEntreesEtVersements()).
|
||||||
|
function buildRows(data, includedMap) {
|
||||||
|
const rows = [];
|
||||||
|
for (const node of data) {
|
||||||
|
const rels = node.relationships || {};
|
||||||
|
const attrs = node.attributes;
|
||||||
|
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
||||||
|
const entreeLieeRefs = (rels.field_entree_liee && rels.field_entree_liee.data) || [];
|
||||||
|
const entreeLieeIds = entreeLieeRefs.map((ref) => ref.id);
|
||||||
|
const flagRefs = (rels.field_flag && rels.field_flag.data) || [];
|
||||||
|
const flags = flagRefs.map((ref) => resolve(includedMap, ref)).filter(Boolean).map((t) => t.attributes.name);
|
||||||
|
const parCompte = {};
|
||||||
|
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
||||||
|
for (const ref of repartitionRefs) {
|
||||||
|
const paragraph = resolve(includedMap, ref);
|
||||||
|
if (!paragraph) continue;
|
||||||
|
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
||||||
|
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
||||||
|
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
||||||
|
parCompte[compteName] = (parCompte[compteName] || 0) + montant;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
id: node.id,
|
||||||
|
nid: attrs.drupal_internal__nid,
|
||||||
|
date: attrs.field_date_ligne,
|
||||||
|
type: attrs.field_type_ligne,
|
||||||
|
client: clientTerm ? clientTerm.attributes.name : null,
|
||||||
|
libelle: attrs.field_notes || attrs.title,
|
||||||
|
parCompte,
|
||||||
|
entreeLieeIds,
|
||||||
|
flags,
|
||||||
|
hasFlag: flags.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every entrée/versement, whatever their date -- unlike /lignes there's
|
||||||
|
// no sliding window here: this page needs the *complete* picture to
|
||||||
|
// reconcile a compte's entrées against its versements (one can easily
|
||||||
|
// be paid out a year or more after the other), and entrée+versement
|
||||||
|
// alone is a small enough slice of the ~1500+ line ledger to fetch in
|
||||||
|
// one page load (the rest -- charge/achat/hébergement/sous-traitant/
|
||||||
|
// autre/ouverture -- is exactly what this page deliberately excludes).
|
||||||
|
async function fetchEntreesEtVersements() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag');
|
||||||
|
params.set('filter[typeFilter][condition][path]', 'field_type_ligne');
|
||||||
|
params.set('filter[typeFilter][condition][operator]', 'IN');
|
||||||
|
params.append('filter[typeFilter][condition][value][]', 'entree');
|
||||||
|
params.append('filter[typeFilter][condition][value][]', 'versement');
|
||||||
|
params.set('page[limit]', '50');
|
||||||
|
params.set('sort', 'field_date_ligne,drupal_internal__nid');
|
||||||
|
let url = API_BASE + '?' + params.toString();
|
||||||
|
const allData = [];
|
||||||
|
const includedMap = new Map();
|
||||||
|
while (url) {
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
||||||
|
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
||||||
|
const json = await res.json();
|
||||||
|
allData.push(...(json.data || []));
|
||||||
|
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
|
||||||
|
url = json.links && json.links.next ? json.links.next.href : null;
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
|
||||||
|
return buildRows(dedup, includedMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// page[limit]=200 would be silently clamped to core's hard cap of 50 --
|
||||||
|
// same reasoning as fetchClientNames() in home.js -- but with only 8
|
||||||
|
// comptes, one page always covers all of them; the pagination loop is
|
||||||
|
// kept anyway so this doesn't silently break if the vocabulary grows.
|
||||||
|
async function fetchComptes() {
|
||||||
|
let url = '/jsonapi/taxonomy_term/compte?sort=weight,name&page[limit]=50';
|
||||||
|
const names = [];
|
||||||
|
while (url) {
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
||||||
|
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
||||||
|
const json = await res.json();
|
||||||
|
names.push(...(json.data || []).map((t) => t.attributes.name).filter(Boolean));
|
||||||
|
url = json.links && json.links.next ? json.links.next.href : null;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuses the existing whole-ledger aggregate endpoint (plain SQL, all
|
||||||
|
// comptes/types/années at once) for the numbers that must reflect the
|
||||||
|
// *true* accounting balance -- solde par compte -- rather than
|
||||||
|
// recomputing a partial one from just entrée+versement rows, which
|
||||||
|
// would silently ignore that compte's charges/achats/etc.
|
||||||
|
async function fetchDashboardStats() {
|
||||||
|
const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHashCompte() {
|
||||||
|
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
return params.get('compte') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHashCompte(compte) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (compte) params.set('compte', compte);
|
||||||
|
const hash = params.toString();
|
||||||
|
history.replaceState(null, '', location.pathname + location.search + (hash ? '#' + hash : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical bar(s) per year, diverging from a zero baseline -- shared by
|
||||||
|
// "Évolution du solde" (one bar/year) and "Entrées vs versements par
|
||||||
|
// année" (two bars/year, side by side). A single flexible component
|
||||||
|
// instead of two near-identical ones.
|
||||||
|
const YearBarsChart = {
|
||||||
|
props: {
|
||||||
|
// [{ label, bars: [{ value, color, title }] }]
|
||||||
|
years: { type: Array, required: true },
|
||||||
|
formatValue: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
maxAbs() {
|
||||||
|
return Math.max(1, ...this.years.flatMap((y) => y.bars.map((b) => Math.abs(b.value))));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
barStyle(bar) {
|
||||||
|
const pct = (Math.abs(bar.value) / this.maxAbs) * 100;
|
||||||
|
return bar.value >= 0
|
||||||
|
? { bottom: '50%', height: pct / 2 + '%', background: bar.color }
|
||||||
|
: { top: '50%', height: pct / 2 + '%', background: bar.color };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<div class="figli-year-chart">' +
|
||||||
|
'<div class="figli-year-col" v-for="y in years" :key="y.label">' +
|
||||||
|
'<div class="figli-year-track">' +
|
||||||
|
'<div class="figli-year-bars">' +
|
||||||
|
'<div class="figli-year-bar-slot" v-for="(bar, i) in y.bars" :key="i">' +
|
||||||
|
'<div class="figli-year-bar" :style="barStyle(bar)" :title="bar.title + \' : \' + formatValue(bar.value)"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="figli-year-label">{{ y.label }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same shape as dashboard.js's HBarChart, colorFor included (used by
|
||||||
|
// the type-breakdown chart; Top clients below just omits it and gets
|
||||||
|
// the plain positive-green default).
|
||||||
|
const HBarChart = {
|
||||||
|
props: {
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
formatValue: { type: Function, required: true },
|
||||||
|
colorFor: { type: Function, default: null },
|
||||||
|
// Narrower label/value columns, smaller text -- for the per-année
|
||||||
|
// small-multiples grids, where a full-width chart wouldn't fit in
|
||||||
|
// a grid card.
|
||||||
|
compact: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
maxAbs() {
|
||||||
|
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fillColor(item) {
|
||||||
|
return this.colorFor ? this.colorFor(item) : 'var(--figli-positive)';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<div class="figli-hbar-chart" :class="{\'is-compact\': compact}">' +
|
||||||
|
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
|
||||||
|
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
|
||||||
|
'<div class="figli-hbar-track">' +
|
||||||
|
'<div class="figli-hbar-fill" :style="{left: 0, width: (Math.abs(item.value) / maxAbs * 100) + \'%\', background: fillColor(item)}"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = {
|
||||||
|
components: { YearBarsChart, HBarChart },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
allRows: [],
|
||||||
|
comptes: [],
|
||||||
|
stats: null,
|
||||||
|
selectedCompte: '',
|
||||||
|
// Filtre "Lignes signalées" -- même modèle multi-valeurs/OR que
|
||||||
|
// sur /lignes. Propre à ce compte (voir selectCompte()).
|
||||||
|
flagFilter: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sortiesByEntree() {
|
||||||
|
const map = new Map();
|
||||||
|
for (const r of this.allRows) {
|
||||||
|
for (const entreeId of r.entreeLieeIds) {
|
||||||
|
if (!map.has(entreeId)) map.set(entreeId, []);
|
||||||
|
map.get(entreeId).push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
// Same algorithm as home.js's reconciliationByEntree: per entrée,
|
||||||
|
// per compte, résidu = part de l'entrée pour ce compte + part des
|
||||||
|
// versements liés pour ce compte (montants négatifs), un versement
|
||||||
|
// lié à plusieurs entrées voyant son montant réparti à parts égales
|
||||||
|
// entre elles.
|
||||||
|
reconciliationByEntree() {
|
||||||
|
const map = new Map();
|
||||||
|
for (const entreeRow of this.allRows) {
|
||||||
|
if (entreeRow.type !== 'entree') continue;
|
||||||
|
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
|
||||||
|
const versementsParCompte = {};
|
||||||
|
for (const s of linked) {
|
||||||
|
const share = s.entreeLieeIds.length || 1;
|
||||||
|
for (const [compte, montant] of Object.entries(s.parCompte)) {
|
||||||
|
versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant / share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
|
||||||
|
const parCompteResidual = {};
|
||||||
|
for (const c of comptes) {
|
||||||
|
parCompteResidual[c] = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
|
||||||
|
}
|
||||||
|
map.set(entreeRow.id, { parCompteResidual });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
entreesDuCompte() {
|
||||||
|
if (!this.selectedCompte) return [];
|
||||||
|
return this.allRows.filter((r) => r.type === 'entree' && r.parCompte[this.selectedCompte] !== undefined);
|
||||||
|
},
|
||||||
|
// Entrées dont la part du compte sélectionné n'est pas (entièrement)
|
||||||
|
// versée -- résidu positif = encore dû, négatif = sur-versé. C'est
|
||||||
|
// le coeur de la page : ce que le collectif doit encore à ce
|
||||||
|
// compte associé, entrée par entrée.
|
||||||
|
resteAVerserRows() {
|
||||||
|
return this.entreesDuCompte
|
||||||
|
.map((entree) => {
|
||||||
|
const recon = this.reconciliationByEntree.get(entree.id);
|
||||||
|
const residual = recon ? (recon.parCompteResidual[this.selectedCompte] ?? entree.parCompte[this.selectedCompte]) : entree.parCompte[this.selectedCompte];
|
||||||
|
return {
|
||||||
|
entree,
|
||||||
|
montantAttribue: Math.round(entree.parCompte[this.selectedCompte] * 100) / 100,
|
||||||
|
residual,
|
||||||
|
dejaVerse: Math.round((entree.parCompte[this.selectedCompte] - residual) * 100) / 100,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => Math.abs(r.residual) > 0.01)
|
||||||
|
.sort((a, b) => (a.entree.date || '').localeCompare(b.entree.date || ''));
|
||||||
|
},
|
||||||
|
resteAVerserPositif() {
|
||||||
|
return this.resteAVerserRows.filter((r) => r.residual > 0.01);
|
||||||
|
},
|
||||||
|
surVerseRows() {
|
||||||
|
return this.resteAVerserRows.filter((r) => r.residual < -0.01);
|
||||||
|
},
|
||||||
|
// Versements de ce compte qui ne pointent vers aucune entrée du
|
||||||
|
// tout -- ni "reste à verser" ni "sur-versé" ne les couvre (ces
|
||||||
|
// deux listes ne regardent que les *entrées*), donc sans ça un
|
||||||
|
// versement orphelin resterait invisible alors que c'est justement
|
||||||
|
// le genre d'anomalie que cette page doit faire ressortir.
|
||||||
|
versementsNonLies() {
|
||||||
|
if (!this.selectedCompte) return [];
|
||||||
|
return this.allRows
|
||||||
|
.filter((r) => r.type === 'versement' && r.parCompte[this.selectedCompte] !== undefined && r.entreeLieeIds.length === 0)
|
||||||
|
.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
||||||
|
},
|
||||||
|
// Toute ligne entrée/versement de ce compte portant un signalement
|
||||||
|
// (voir /lignes), qu'elle apparaisse déjà dans une des listes
|
||||||
|
// ci-dessus ou non -- une ligne entièrement soldée peut quand même
|
||||||
|
// porter un problème sans rapport avec le montant (ex. "client
|
||||||
|
// injoignable"), auquel cas aucune des trois listes ci-dessus ne
|
||||||
|
// la montrerait autrement.
|
||||||
|
lignesSignalees() {
|
||||||
|
if (!this.selectedCompte) return [];
|
||||||
|
return this.allRows
|
||||||
|
.filter((r) => r.hasFlag && r.parCompte[this.selectedCompte] !== undefined)
|
||||||
|
.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
||||||
|
},
|
||||||
|
// Tags réellement présents parmi les lignes signalées de ce compte
|
||||||
|
// -- dérivé de lignesSignalees (pas de lignesSignaleesFiltrees), pour
|
||||||
|
// que la liste d'options du filtre reste stable même une fois un tag
|
||||||
|
// sélectionné (sinon les autres tags disparaîtraient du menu dès
|
||||||
|
// qu'on en coche un).
|
||||||
|
flagsDisponibles() {
|
||||||
|
return Array.from(new Set(this.lignesSignalees.flatMap((r) => r.flags))).sort();
|
||||||
|
},
|
||||||
|
// Même modèle "plusieurs valeurs, sémantique OR" que le filtre Type
|
||||||
|
// sur /lignes -- une ligne ressort si elle porte au moins un des
|
||||||
|
// tags cochés.
|
||||||
|
lignesSignaleesFiltrees() {
|
||||||
|
if (!this.flagFilter.length) return this.lignesSignalees;
|
||||||
|
return this.lignesSignalees.filter((r) => this.flagFilter.some((f) => r.flags.includes(f)));
|
||||||
|
},
|
||||||
|
// Solde net des lignes signalées (filtrées) elles-mêmes (entrées
|
||||||
|
// reçues moins versements sortis, pour ce compte) -- pas un total
|
||||||
|
// "reste dû" comme totalResteAVerser ci-dessous, juste la somme des
|
||||||
|
// montants affichés dans le tableau, pour avoir une idée de
|
||||||
|
// l'ampleur de ce qui est signalé (ou de ce sous-ensemble de tags).
|
||||||
|
totalLignesSignalees() {
|
||||||
|
return Math.round(this.lignesSignaleesFiltrees.reduce((sum, r) => sum + r.parCompte[this.selectedCompte], 0) * 100) / 100;
|
||||||
|
},
|
||||||
|
totalResteAVerser() {
|
||||||
|
return Math.round(this.resteAVerserPositif.reduce((sum, r) => sum + r.residual, 0) * 100) / 100;
|
||||||
|
},
|
||||||
|
totalSurVerse() {
|
||||||
|
return Math.round(this.surVerseRows.reduce((sum, r) => sum - r.residual, 0) * 100) / 100;
|
||||||
|
},
|
||||||
|
totalNonLies() {
|
||||||
|
return Math.round(this.versementsNonLies.reduce((sum, r) => sum - (r.parCompte[this.selectedCompte] || 0), 0) * 100) / 100;
|
||||||
|
},
|
||||||
|
soldeActuel() {
|
||||||
|
if (!this.stats || !this.selectedCompte) return 0;
|
||||||
|
return this.stats.solde_par_compte[this.selectedCompte] || 0;
|
||||||
|
},
|
||||||
|
// Une seule barre/année, verte au-dessus de zéro / rouge en
|
||||||
|
// dessous -- solde de clôture de ce compte, année par année (même
|
||||||
|
// source que /dashboard, filtrée à ce seul compte).
|
||||||
|
evolutionSoldeYears() {
|
||||||
|
if (!this.stats || !this.selectedCompte) return [];
|
||||||
|
return this.stats.annees.map((y) => {
|
||||||
|
const v = this.stats.solde_par_compte_par_annee[y] ? this.stats.solde_par_compte_par_annee[y][this.selectedCompte] : undefined;
|
||||||
|
const value = v !== undefined ? v : 0;
|
||||||
|
return {
|
||||||
|
label: y,
|
||||||
|
bars: [{ value, color: value >= 0 ? 'var(--figli-positive)' : 'var(--figli-error)', title: 'Solde ' + y }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Deux barres/année : entrées attribuées à ce compte (vert, vers le
|
||||||
|
// haut) et versements de ce compte (rouge, déjà négatifs -- vers le
|
||||||
|
// bas) -- répond visuellement, année par année, à la question
|
||||||
|
// centrale de cette page.
|
||||||
|
entreeVsVersementYears() {
|
||||||
|
if (!this.stats || !this.selectedCompte) return [];
|
||||||
|
const parAnnee = new Map();
|
||||||
|
for (const y of this.stats.annees) parAnnee.set(y, { entree: 0, versement: 0 });
|
||||||
|
for (const r of this.allRows) {
|
||||||
|
const montant = r.parCompte[this.selectedCompte];
|
||||||
|
if (montant === undefined || !r.date) continue;
|
||||||
|
const y = r.date.slice(0, 4);
|
||||||
|
if (!parAnnee.has(y)) continue;
|
||||||
|
const bucket = parAnnee.get(y);
|
||||||
|
if (r.type === 'entree') bucket.entree += montant;
|
||||||
|
else bucket.versement += montant;
|
||||||
|
}
|
||||||
|
return this.stats.annees.map((y) => {
|
||||||
|
const b = parAnnee.get(y);
|
||||||
|
return {
|
||||||
|
label: y,
|
||||||
|
bars: [
|
||||||
|
{ value: Math.round(b.entree * 100) / 100, color: 'var(--figli-positive)', title: 'Entrées ' + y },
|
||||||
|
{ value: Math.round(b.versement * 100) / 100, color: 'var(--figli-error)', title: 'Versements ' + y },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
topClientsItems() {
|
||||||
|
const parClient = new Map();
|
||||||
|
for (const r of this.entreesDuCompte) {
|
||||||
|
const client = r.client || '(sans client)';
|
||||||
|
parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]);
|
||||||
|
}
|
||||||
|
return Array.from(parClient.entries())
|
||||||
|
.map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10);
|
||||||
|
},
|
||||||
|
// Same chart as /dashboard's "Répartition de l'activité par type",
|
||||||
|
// filtered to this compte -- unlike the reconciliation tables above
|
||||||
|
// (deliberately entrée/versement only, see this file's docblock),
|
||||||
|
// this comes straight from /dashboard/api/stats's per-compte
|
||||||
|
// breakdown, so it covers every type touching this compte's
|
||||||
|
// répartition (charge, achat, hébergement...), matching what the
|
||||||
|
// general dashboard shows for the whole ledger.
|
||||||
|
typeItems() {
|
||||||
|
if (!this.stats || !this.selectedCompte) return [];
|
||||||
|
const parType = this.stats.total_par_type_par_compte[this.selectedCompte] || {};
|
||||||
|
return Object.entries(parType)
|
||||||
|
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
|
// Small multiples, one per year -- same source as typeItems() above
|
||||||
|
// (total_par_type_par_compte_par_annee is the same répartition-level
|
||||||
|
// SQL query, just also grouped by année, no extra request).
|
||||||
|
typeItemsParAnnee() {
|
||||||
|
if (!this.stats || !this.selectedCompte) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const parType = (this.stats.total_par_type_par_compte_par_annee[annee] || {})[this.selectedCompte] || {};
|
||||||
|
const items = Object.entries(parType)
|
||||||
|
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
return { annee, items };
|
||||||
|
}).filter((y) => y.items.length);
|
||||||
|
},
|
||||||
|
// Top 5 (not 10 like the all-time chart, above) -- one per year
|
||||||
|
// keeps the small-multiples grid readable.
|
||||||
|
topClientsParAnnee() {
|
||||||
|
if (!this.selectedCompte) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const parClient = new Map();
|
||||||
|
for (const r of this.entreesDuCompte) {
|
||||||
|
if ((r.date || '').slice(0, 4) !== annee) continue;
|
||||||
|
const client = r.client || '(sans client)';
|
||||||
|
parClient.set(client, (parClient.get(client) || 0) + r.parCompte[this.selectedCompte]);
|
||||||
|
}
|
||||||
|
const items = Array.from(parClient.entries())
|
||||||
|
.map(([label, value]) => ({ label, value: Math.round(value * 100) / 100 }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 5);
|
||||||
|
return { annee, items };
|
||||||
|
}).filter((y) => y.items.length);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatEur(v) {
|
||||||
|
return v === null || v === undefined ? '' : EUR.format(v);
|
||||||
|
},
|
||||||
|
formatEurRound(v) {
|
||||||
|
return EUR_ROUND.format(v);
|
||||||
|
},
|
||||||
|
typeColor(item) {
|
||||||
|
return TYPE_COLORS[item.type] || '#6b7280';
|
||||||
|
},
|
||||||
|
formatDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
const parts = d.split('-');
|
||||||
|
return parts[2] + ' ' + MONTHS_SHORT[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
|
||||||
|
},
|
||||||
|
// Ouvre le grand livre déjà filtré sur ce client -- pour aller voir
|
||||||
|
// le détail des lignes plutôt que de dupliquer une vue détaillée
|
||||||
|
// ici.
|
||||||
|
ligneHref(client) {
|
||||||
|
return '/lignes#client=' + encodeURIComponent(client) + '&type=entree,versement';
|
||||||
|
},
|
||||||
|
selectCompte(compte) {
|
||||||
|
this.selectedCompte = compte;
|
||||||
|
// A tag selected for one compte may not even exist for the next
|
||||||
|
// one -- flagsDisponibles() would just drop it from the visible
|
||||||
|
// options while leaving it silently active in flagFilter.
|
||||||
|
this.flagFilter = [];
|
||||||
|
writeHashCompte(compte);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const [allRows, comptes, stats] = await Promise.all([
|
||||||
|
fetchEntreesEtVersements(),
|
||||||
|
fetchComptes(),
|
||||||
|
fetchDashboardStats(),
|
||||||
|
]);
|
||||||
|
this.allRows = allRows;
|
||||||
|
this.comptes = comptes;
|
||||||
|
this.stats = stats;
|
||||||
|
const fromHash = readHashCompte();
|
||||||
|
this.selectedCompte = fromHash && comptes.includes(fromHash) ? fromHash : (comptes[0] || '');
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Drupal.behaviors.figliComptaDashboardCompte = {
|
||||||
|
attach(context) {
|
||||||
|
const root = context.querySelector ? context.querySelector('#figli-dashboard-app') : null;
|
||||||
|
if (root && !root.dataset.figliInitialized) {
|
||||||
|
root.dataset.figliInitialized = '1';
|
||||||
|
Vue.createApp(App).mount(root);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(Drupal, Vue);
|
||||||
@@ -1,107 +1,225 @@
|
|||||||
/**
|
/**
|
||||||
* @file
|
* @file
|
||||||
* Aggregate dashboard: solde par compte / solde par client, computed
|
* Dashboard: charts and aggregate totals (solde par compte, chiffre
|
||||||
* client-side from JSON:API. The line-by-line spreadsheet view is the
|
* d'affaires par année, répartition par type, top clients), computed
|
||||||
* site's home page (home.js), not this one.
|
* server-side (DashboardStatsController -- plain SQL GROUP BY, not Entity
|
||||||
|
* API) and rendered here as small dependency-free div/CSS bar charts. No
|
||||||
|
* charting library: this project vendors its own JS (see js/vendor/), and
|
||||||
|
* a handful of bar/line charts don't warrant pulling one in.
|
||||||
*/
|
*/
|
||||||
(function (Drupal, Vue) {
|
(function (Drupal, Vue) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const API_BASE = '/jsonapi/node/ligne_comptable';
|
|
||||||
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
|
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
|
||||||
|
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
entree: 'Entrée client',
|
||||||
|
charge: 'Charge structurelle',
|
||||||
|
versement: 'Versement freelance',
|
||||||
|
achat: 'Achat client',
|
||||||
|
hebergement: 'Hébergement',
|
||||||
|
sous_traitant: 'Sous-traitant',
|
||||||
|
salaire_stage: 'Salaire / stage',
|
||||||
|
charges_local_pro: 'Charges local pro',
|
||||||
|
autre: 'Autre',
|
||||||
|
};
|
||||||
|
// Stable colour per type, independent of sort order -- a viewer
|
||||||
|
// comparing this chart across page loads shouldn't see "achat" change
|
||||||
|
// colour just because its rank shifted.
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
entree: '#1a7f37',
|
||||||
|
charge: '#6b7280',
|
||||||
|
versement: '#d97a0a',
|
||||||
|
achat: '#3b6fe0',
|
||||||
|
hebergement: '#0e9182',
|
||||||
|
sous_traitant: '#c9312b',
|
||||||
|
salaire_stage: '#0891b2',
|
||||||
|
charges_local_pro: '#65a30d',
|
||||||
|
autre: '#9061f0',
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchAllLignes() {
|
async function fetchStats() {
|
||||||
// sort by nid: without an explicit, unique sort key, offset pagination
|
const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } });
|
||||||
// can silently duplicate or skip rows across pages.
|
if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status);
|
||||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=drupal_internal__nid';
|
return res.json();
|
||||||
const allData = [];
|
|
||||||
const includedMap = new Map();
|
|
||||||
while (url) {
|
|
||||||
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } });
|
|
||||||
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
|
|
||||||
const json = await res.json();
|
|
||||||
allData.push(...(json.data || []));
|
|
||||||
(json.included || []).forEach((item) => includedMap.set(item.type + ':' + item.id, item));
|
|
||||||
url = json.links && json.links.next ? json.links.next.href : null;
|
|
||||||
}
|
|
||||||
// Defensive de-dup by node id, in case pagination ever repeats a row.
|
|
||||||
const seen = new Set();
|
|
||||||
const dedup = allData.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
|
|
||||||
return { data: dedup, includedMap };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(includedMap, ref) {
|
// Horizontal bar chart -- one row per item, label left, proportional
|
||||||
if (!ref) return null;
|
// bar, value right. Switches to a zero-centered "diverging" layout
|
||||||
return includedMap.get(ref.type + ':' + ref.id) || null;
|
// automatically when values can be negative (solde par compte), so a
|
||||||
}
|
// debit and a credit of the same magnitude read as mirror images
|
||||||
|
// instead of one dwarfing the other from a shared zero baseline.
|
||||||
|
const HBarChart = {
|
||||||
|
props: {
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
formatValue: { type: Function, required: true },
|
||||||
|
colorFor: { type: Function, default: null },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hasNegative() {
|
||||||
|
return this.items.some((i) => i.value < 0);
|
||||||
|
},
|
||||||
|
maxAbs() {
|
||||||
|
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fillStyle(item) {
|
||||||
|
const pct = (Math.abs(item.value) / this.maxAbs) * 100;
|
||||||
|
if (this.hasNegative) {
|
||||||
|
return item.value >= 0
|
||||||
|
? { left: '50%', width: pct / 2 + '%' }
|
||||||
|
: { right: '50%', width: pct / 2 + '%' };
|
||||||
|
}
|
||||||
|
return { left: 0, width: pct + '%' };
|
||||||
|
},
|
||||||
|
fillColor(item) {
|
||||||
|
if (this.colorFor) return this.colorFor(item);
|
||||||
|
return item.value < 0 ? 'var(--figli-error)' : 'var(--figli-positive)';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<div class="figli-hbar-chart">' +
|
||||||
|
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
|
||||||
|
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
|
||||||
|
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
|
||||||
|
'<div class="figli-hbar-zero" v-if="hasNegative"></div>' +
|
||||||
|
'<div class="figli-hbar-fill" :style="[fillStyle(item), {background: fillColor(item)}]"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
function addTo(map, key, montant) {
|
// Vertical bar chart -- for a short time series (CA par année): a
|
||||||
if (!map.has(key)) map.set(key, { entrees: 0, sorties: 0 });
|
// handful of columns read left-to-right as a trend more naturally than
|
||||||
const row = map.get(key);
|
// horizontal bars would.
|
||||||
if (montant >= 0) row.entrees += montant;
|
const VBarChart = {
|
||||||
else row.sorties += montant;
|
props: {
|
||||||
}
|
items: { type: Array, required: true },
|
||||||
|
formatValue: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
max() {
|
||||||
|
return Math.max(1, ...this.items.map((i) => i.value));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
barHeight(item) {
|
||||||
|
return Math.max(2, (item.value / this.max) * 100) + '%';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<div class="figli-vbar-chart">' +
|
||||||
|
'<div class="figli-vbar-col" v-for="item in items" :key="item.label">' +
|
||||||
|
'<div class="figli-vbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
|
'<div class="figli-vbar-track"><div class="figli-vbar-fill" :style="{height: barHeight(item)}"></div></div>' +
|
||||||
|
'<div class="figli-vbar-label">{{ item.label }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
function computeAggregations(data, includedMap) {
|
// Small multiples: one compact zero-centered bar-per-year trend per
|
||||||
const parComptes = new Map();
|
// compte, instead of a single 8-series line chart -- eight overlapping
|
||||||
const parClients = new Map();
|
// lines sharing one small area is hard to read; eight small independent
|
||||||
for (const node of data) {
|
// trends, each answering "is this person's balance growing or
|
||||||
const rels = node.relationships || {};
|
// shrinking", is not.
|
||||||
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
const MiniTrend = {
|
||||||
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
props: {
|
||||||
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
annees: { type: Array, required: true },
|
||||||
for (const ref of repartitionRefs) {
|
values: { type: Array, required: true },
|
||||||
const paragraph = resolve(includedMap, ref);
|
formatValue: { type: Function, required: true },
|
||||||
if (!paragraph) continue;
|
},
|
||||||
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
computed: {
|
||||||
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
maxAbs() {
|
||||||
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
|
||||||
addTo(parComptes, compteName, montant);
|
},
|
||||||
addTo(parClients, clientName, montant);
|
},
|
||||||
}
|
methods: {
|
||||||
}
|
barHeight(v) {
|
||||||
return { parComptes, parClients };
|
if (v === null) return '0%';
|
||||||
}
|
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
|
||||||
|
},
|
||||||
function mapToRows(map) {
|
},
|
||||||
return Array.from(map.entries())
|
template:
|
||||||
.map(([name, v]) => ({ name, entrees: v.entrees, sorties: v.sorties, solde: v.entrees + v.sorties }))
|
'<div class="figli-mini-trend">' +
|
||||||
.sort((a, b) => a.solde - b.solde);
|
'<div class="figli-mini-bar-col" v-for="(v, i) in values" :key="annees[i]" :title="annees[i] + \' : \' + (v === null ? \'—\' : formatValue(v))">' +
|
||||||
}
|
'<div class="figli-mini-bar-track">' +
|
||||||
|
'<div class="figli-mini-bar-fill" :class="v !== null && v < 0 ? \'is-negative\' : \'is-positive\'" :style="{height: barHeight(v)}"></div>' +
|
||||||
function totalsOf(rows) {
|
'</div>' +
|
||||||
return rows.reduce(
|
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
|
||||||
(acc, r) => ({ entrees: acc.entrees + r.entrees, sorties: acc.sorties + r.sorties, solde: acc.solde + r.solde }),
|
'</div>' +
|
||||||
{ entrees: 0, sorties: 0, solde: 0 }
|
'</div>',
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const App = {
|
const App = {
|
||||||
|
components: { HBarChart, ColumnChart: VBarChart, MiniTrend },
|
||||||
data() {
|
data() {
|
||||||
return { loading: true, error: null, tables: [], lineCount: 0 };
|
return { loading: true, error: null, stats: null };
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
caParAnneeItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.annees.map((y) => ({ label: y, value: this.stats.ca_par_annee[y] || 0 }));
|
||||||
|
},
|
||||||
|
soldeParCompteItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return Object.entries(this.stats.solde_par_compte)
|
||||||
|
.map(([label, value]) => ({ label, value }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
|
typeItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return Object.entries(this.stats.total_par_type)
|
||||||
|
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
|
topClientsItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca }));
|
||||||
|
},
|
||||||
|
// Comptes ordered by all-time solde (richest first) -- same order
|
||||||
|
// as soldeParCompteItems, so the trend grid below reads as a
|
||||||
|
// continuation of the bar chart above it rather than an unrelated
|
||||||
|
// shuffle.
|
||||||
|
comptesOrdonnes() {
|
||||||
|
return this.soldeParCompteItems.map((i) => i.label);
|
||||||
|
},
|
||||||
|
totalCA() {
|
||||||
|
if (!this.stats) return 0;
|
||||||
|
return Object.values(this.stats.ca_par_annee).reduce((a, b) => a + b, 0);
|
||||||
|
},
|
||||||
|
caAnneeEnCours() {
|
||||||
|
if (!this.stats || !this.stats.annees.length) return null;
|
||||||
|
const derniere = this.stats.annees[this.stats.annees.length - 1];
|
||||||
|
return { annee: derniere, value: this.stats.ca_par_annee[derniere] || 0 };
|
||||||
|
},
|
||||||
|
totalActivite() {
|
||||||
|
if (!this.stats) return 0;
|
||||||
|
return Object.values(this.stats.total_par_type).reduce((a, b) => a + b, 0);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatEur(v) {
|
formatEur(v) {
|
||||||
return EUR.format(v);
|
return EUR.format(v);
|
||||||
},
|
},
|
||||||
rowClass(solde) {
|
formatEurRound(v) {
|
||||||
if (solde > 0.5) return 'positive';
|
return EUR_ROUND.format(v);
|
||||||
if (solde < -0.5) return 'negative';
|
},
|
||||||
return '';
|
trendValues(compte) {
|
||||||
|
return this.stats.annees.map((y) => {
|
||||||
|
const parAnnee = this.stats.solde_par_compte_par_annee[y];
|
||||||
|
return parAnnee && parAnnee[compte] !== undefined ? parAnnee[compte] : null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
typeColor(item) {
|
||||||
|
return TYPE_COLORS[item.type] || '#6b7280';
|
||||||
},
|
},
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const { data, includedMap } = await fetchAllLignes();
|
this.stats = await fetchStats();
|
||||||
this.lineCount = data.length;
|
|
||||||
const { parComptes, parClients } = computeAggregations(data, includedMap);
|
|
||||||
const comptesRows = mapToRows(parComptes);
|
|
||||||
const clientsRows = mapToRows(parClients);
|
|
||||||
this.tables = [
|
|
||||||
{ title: 'Solde par compte', note: this.lineCount + ' lignes comptables chargées.', rows: comptesRows, totals: totalsOf(comptesRows) },
|
|
||||||
{ title: 'Solde par client', note: 'Entrées créditées par client vs. montants sortis (versements, achats, charges) sur les lignes rattachées à ce client.', rows: clientsRows, totals: totalsOf(clientsRows) },
|
|
||||||
];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = err.message;
|
this.error = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ class DashboardController extends ControllerBase {
|
|||||||
return [
|
return [
|
||||||
'#theme' => 'figli_compta_home',
|
'#theme' => 'figli_compta_home',
|
||||||
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
|
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
|
||||||
|
'#current_route' => 'figli_compta_ledger.home',
|
||||||
'#attached' => [
|
'#attached' => [
|
||||||
'library' => ['figli_compta_ledger/home'],
|
'library' => ['figli_compta_ledger/home'],
|
||||||
],
|
],
|
||||||
@@ -25,15 +26,33 @@ class DashboardController extends ControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Secondary page: aggregate solde par compte / par client.
|
* Secondary page: charts and aggregate totals (par compte, par année,
|
||||||
|
* par client, par type).
|
||||||
*/
|
*/
|
||||||
public function view() {
|
public function view() {
|
||||||
return [
|
return [
|
||||||
'#theme' => 'figli_compta_dashboard',
|
'#theme' => 'figli_compta_dashboard',
|
||||||
|
'#current_route' => 'figli_compta_ledger.dashboard',
|
||||||
'#attached' => [
|
'#attached' => [
|
||||||
'library' => ['figli_compta_ledger/dashboard'],
|
'library' => ['figli_compta_ledger/dashboard'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Third page: one compte associé (freelance) at a time -- entrées client
|
||||||
|
* vs versements freelance, and above all which entrées haven't been
|
||||||
|
* (fully) paid out yet. Complements the aggregate /dashboard above,
|
||||||
|
* which mixes every compte and every type together.
|
||||||
|
*/
|
||||||
|
public function compteView() {
|
||||||
|
return [
|
||||||
|
'#theme' => 'figli_compta_dashboard_compte',
|
||||||
|
'#current_route' => 'figli_compta_ledger.dashboard_compte',
|
||||||
|
'#attached' => [
|
||||||
|
'library' => ['figli_compta_ledger/dashboard_compte'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single aggregate endpoint backing the charts on /dashboard. Plain SQL
|
||||||
|
* (Database API), not Entity API -- with 1500+ ligne_comptable nodes,
|
||||||
|
* loading full entities the way the old dashboard.js did (fetch every node
|
||||||
|
* via JSON:API, aggregate client-side) is the exact performance problem
|
||||||
|
* the /lignes sliding window was built to avoid; a handful of GROUP BY
|
||||||
|
* queries answers every chart in one page load instead.
|
||||||
|
*/
|
||||||
|
class DashboardStatsController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same reasoning as home.js's MIN_LOADABLE_DATE/MAX_LOADABLE_DATE:
|
||||||
|
* comfortably before the earliest migrated year (2021) and well past
|
||||||
|
* any plausible future-dated entry, wide enough to never need updating.
|
||||||
|
*/
|
||||||
|
const MIN_ANNEE = '2020';
|
||||||
|
const MAX_ANNEE = '2030';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a year is real enough to appear in a per-year chart -- see
|
||||||
|
* MIN_ANNEE/MAX_ANNEE. Doesn't affect all-time totals, which count
|
||||||
|
* every line regardless of its date.
|
||||||
|
*/
|
||||||
|
private function isAnneeValide(string $annee): bool {
|
||||||
|
return $annee >= self::MIN_ANNEE && $annee < self::MAX_ANNEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/api/stats.
|
||||||
|
*/
|
||||||
|
public function stats() {
|
||||||
|
$connection = \Drupal::database();
|
||||||
|
|
||||||
|
// Node-level aggregate: one row per ligne_comptable (annee, type,
|
||||||
|
// client, montant_ht) -- backs chiffre d'affaires, per-type, and
|
||||||
|
// per-client breakdowns. Joining field_repartition here would
|
||||||
|
// multiply each node by its répartition row count and inflate
|
||||||
|
// montant_ht sums, so it's deliberately kept separate from the
|
||||||
|
// répartition-level query below.
|
||||||
|
$nodeQuery = $connection->select('node__field_date_ligne', 'd');
|
||||||
|
$nodeQuery->innerJoin('node__field_type_ligne', 't', 't.entity_id = d.entity_id');
|
||||||
|
$nodeQuery->leftJoin('node__field_client', 'ncl', 'ncl.entity_id = d.entity_id');
|
||||||
|
$nodeQuery->leftJoin('taxonomy_term_field_data', 'cl', 'cl.tid = ncl.field_client_target_id');
|
||||||
|
$nodeQuery->leftJoin('node__field_montant_ht', 'mh', 'mh.entity_id = d.entity_id');
|
||||||
|
$nodeQuery->condition('d.bundle', 'ligne_comptable');
|
||||||
|
$nodeQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||||
|
$nodeQuery->addField('t', 'field_type_ligne_value', 'type');
|
||||||
|
$nodeQuery->addField('cl', 'name', 'client');
|
||||||
|
$nodeQuery->addField('mh', 'field_montant_ht_value', 'montant_ht');
|
||||||
|
$nodeRows = $nodeQuery->execute()->fetchAll();
|
||||||
|
|
||||||
|
// Répartition-level aggregate: one row per (node, compte) répartition
|
||||||
|
// share, pre-summed per (annee, compte, type) in SQL -- backs solde
|
||||||
|
// par compte, both all-time and per-year (each year's own total
|
||||||
|
// already includes that year's ouverture line, so it *is* that
|
||||||
|
// year's closing balance -- same logic as
|
||||||
|
// LedgerStatsController::totauxAnnee()), and the per-compte type
|
||||||
|
// breakdown used by /dashboard/compte.
|
||||||
|
$compteQuery = $connection->select('node__field_date_ligne', 'd');
|
||||||
|
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
|
||||||
|
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
|
||||||
|
$compteQuery->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id');
|
||||||
|
$compteQuery->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
|
||||||
|
$compteQuery->innerJoin('taxonomy_term_field_data', 'tc', 'tc.tid = c.field_compte_target_id');
|
||||||
|
$compteQuery->condition('d.bundle', 'ligne_comptable');
|
||||||
|
$compteQuery->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||||
|
$compteQuery->addField('tc', 'name', 'compte');
|
||||||
|
$compteQuery->addField('t2', 'field_type_ligne_value', 'type');
|
||||||
|
$compteQuery->addExpression('SUM(m.field_montant_value)', 'total');
|
||||||
|
$compteQuery->groupBy('annee');
|
||||||
|
$compteQuery->groupBy('compte');
|
||||||
|
$compteQuery->groupBy('type');
|
||||||
|
$compteRows = $compteQuery->execute()->fetchAll();
|
||||||
|
|
||||||
|
// --- Aggregate the node-level rows in PHP. ---
|
||||||
|
$caParAnnee = [];
|
||||||
|
$totalParType = [];
|
||||||
|
$caParClient = [];
|
||||||
|
$annees = [];
|
||||||
|
foreach ($nodeRows as $row) {
|
||||||
|
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
|
||||||
|
|
||||||
|
// All-time totals (type breakdown, client ranking) count every line
|
||||||
|
// regardless of date -- a mistyped date doesn't make the money any
|
||||||
|
// less real. Only the per-year buckets below need a sane year.
|
||||||
|
if ($row->type !== 'ouverture') {
|
||||||
|
$totalParType[$row->type] = ($totalParType[$row->type] ?? 0) + abs($montant);
|
||||||
|
}
|
||||||
|
if ($row->type === 'entree') {
|
||||||
|
$client = $row->client ?: '(sans client)';
|
||||||
|
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isAnneeValide($row->annee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$annees[$row->annee] = TRUE;
|
||||||
|
if ($row->type === 'entree') {
|
||||||
|
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arsort($caParClient);
|
||||||
|
$topClients = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($caParClient as $client => $total) {
|
||||||
|
if ($i++ >= 12) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aggregate the répartition-level rows in PHP. ---
|
||||||
|
$soldeParCompte = [];
|
||||||
|
$soldeParCompteParAnnee = [];
|
||||||
|
$totalParTypeParCompte = [];
|
||||||
|
$totalParTypeParCompteParAnnee = [];
|
||||||
|
foreach ($compteRows as $row) {
|
||||||
|
$total = (float) $row->total;
|
||||||
|
// Same reasoning: the all-time balance includes every line; the
|
||||||
|
// per-year trend only makes sense for a real year.
|
||||||
|
$soldeParCompte[$row->compte] = ($soldeParCompte[$row->compte] ?? 0) + $total;
|
||||||
|
|
||||||
|
// Per-compte équivalent of $totalParType above -- every type
|
||||||
|
// counts here (not just entree/versement), same "hors ouverture,
|
||||||
|
// valeur absolue" convention.
|
||||||
|
if ($row->type !== 'ouverture') {
|
||||||
|
$totalParTypeParCompte[$row->compte][$row->type] =
|
||||||
|
($totalParTypeParCompte[$row->compte][$row->type] ?? 0) + abs($total);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isAnneeValide($row->annee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$annees[$row->annee] = TRUE;
|
||||||
|
// compteRows now has one row per (année, compte, type) -- several
|
||||||
|
// types can share the same (année, compte), so this has to
|
||||||
|
// accumulate, not overwrite, or only the last type processed for
|
||||||
|
// that year+compte would survive.
|
||||||
|
$soldeParCompteParAnnee[$row->annee][$row->compte] =
|
||||||
|
round(($soldeParCompteParAnnee[$row->annee][$row->compte] ?? 0) + $total, 2);
|
||||||
|
|
||||||
|
// Per-année version of $totalParTypeParCompte above, for the
|
||||||
|
// "par année" small multiples on /dashboard/compte -- same rows,
|
||||||
|
// no extra query.
|
||||||
|
if ($row->type !== 'ouverture') {
|
||||||
|
$totalParTypeParCompteParAnnee[$row->annee][$row->compte][$row->type] =
|
||||||
|
($totalParTypeParCompteParAnnee[$row->annee][$row->compte][$row->type] ?? 0) + abs($total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// array_keys() alone would leak PHP's array-key int-casting here: a
|
||||||
|
// key that looks like a canonical integer ("2021") is silently stored
|
||||||
|
// as an int, not a string, and json_encode() then emits it as a bare
|
||||||
|
// JSON number in this *list* -- unlike object keys (ca_par_annee,
|
||||||
|
// solde_par_compte_par_annee below), which JSON always stringifies
|
||||||
|
// regardless of the PHP source type. The frontend expects every year
|
||||||
|
// as a string throughout, so cast explicitly.
|
||||||
|
$anneesList = array_map('strval', array_keys($annees));
|
||||||
|
sort($anneesList);
|
||||||
|
|
||||||
|
// Chronological order per year, not insertion order -- PHP's array
|
||||||
|
// key order for soldeParCompteParAnnee follows first-seen compte per
|
||||||
|
// year, which can differ year to year.
|
||||||
|
ksort($soldeParCompteParAnnee);
|
||||||
|
ksort($totalParTypeParCompteParAnnee);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'annees' => $anneesList,
|
||||||
|
'ca_par_annee' => array_map(fn ($v) => round($v, 2), $caParAnnee),
|
||||||
|
'total_par_type' => array_map(fn ($v) => round($v, 2), $totalParType),
|
||||||
|
'top_clients' => $topClients,
|
||||||
|
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
|
||||||
|
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
|
||||||
|
'total_par_type_par_compte' => array_map(
|
||||||
|
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
||||||
|
$totalParTypeParCompte
|
||||||
|
),
|
||||||
|
'total_par_type_par_compte_par_annee' => array_map(
|
||||||
|
fn ($parCompte) => array_map(
|
||||||
|
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
||||||
|
$parCompte
|
||||||
|
),
|
||||||
|
$totalParTypeParCompteParAnnee
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\Core\Entity\EntityStorageException;
|
||||||
|
use Drupal\node\NodeInterface;
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small write endpoints backing inline (no-modal) edits from the /lignes
|
||||||
|
* table. Each goes through the normal node save() lifecycle -- same as the
|
||||||
|
* full edit form -- so figli_compta_ledger_node_presave() still forces a
|
||||||
|
* proper revision. The répartition invariant check is deliberately
|
||||||
|
* skipped for these saves though (same as the type-change endpoint
|
||||||
|
* below): none of client/facture/libellé/type touch montant_ht or
|
||||||
|
* field_repartition, so skipping can never *introduce* a mismatch, only
|
||||||
|
* leave a pre-existing historical one exactly as it was -- see each
|
||||||
|
* method's own comment.
|
||||||
|
*/
|
||||||
|
class LedgerActionsController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields editable inline from /lignes without opening the full node
|
||||||
|
* edit form -- keys are the short names the frontend sends; values are
|
||||||
|
* the real field machine names.
|
||||||
|
*/
|
||||||
|
const INLINE_EDITABLE_FIELDS = [
|
||||||
|
'client' => 'field_client',
|
||||||
|
'facture' => 'field_numero_facture',
|
||||||
|
'libelle' => 'field_notes',
|
||||||
|
'flag' => 'field_flag',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every value field_type_ligne actually allows (see the field's
|
||||||
|
* allowed_values in config) -- validated against here rather than
|
||||||
|
* trusting the client.
|
||||||
|
*/
|
||||||
|
const ALLOWED_TYPES = ['entree', 'charge', 'versement', 'achat', 'hebergement', 'autre', 'ouverture', 'sous_traitant', 'salaire_stage', 'charges_local_pro'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types field_entree_liee is meaningful for -- mirrors the #states
|
||||||
|
* visibility rule in figli_compta_ledger_form_alter(). Sous-traitant
|
||||||
|
* pays out against a client's work just like versement/achat; a
|
||||||
|
* salaire/stage or charges local pro is a structural cost like charge,
|
||||||
|
* not tied to one client's invoice.
|
||||||
|
*/
|
||||||
|
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /lignes/{node}/type -- change field_type_ligne without opening
|
||||||
|
* the full edit form, for clicking the type badge directly in the
|
||||||
|
* table. Body: {"type": "charge"}.
|
||||||
|
*/
|
||||||
|
public function updateType(Request $request, NodeInterface $node) {
|
||||||
|
if ($node->bundle() !== 'ligne_comptable') {
|
||||||
|
return new JsonResponse(['error' => 'Type de contenu invalide.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped to CsrfRequestHeaderAccessCheck::TOKEN_KEY -- the same value
|
||||||
|
// core's own /session/token controller generates against, which is
|
||||||
|
// what the frontend fetches this token from.
|
||||||
|
$csrfToken = $request->headers->get('X-CSRF-Token', '');
|
||||||
|
if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) {
|
||||||
|
return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), TRUE);
|
||||||
|
$type = is_array($data) ? ($data['type'] ?? NULL) : NULL;
|
||||||
|
if (!in_array($type, self::ALLOWED_TYPES, TRUE)) {
|
||||||
|
return new JsonResponse(['error' => 'Type de ligne invalide.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conflict = $this->checkConflict($request, $node)) {
|
||||||
|
return $conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
$node->set('field_type_ligne', $type);
|
||||||
|
|
||||||
|
// A type that's no longer linkable shouldn't keep a stale
|
||||||
|
// field_entree_liee reference around (mirrors the form's #states:
|
||||||
|
// charge/autre/ouverture/entree don't expose that field at all).
|
||||||
|
if (!in_array($type, self::LINKABLE_TYPES, TRUE)
|
||||||
|
&& $node->hasField('field_entree_liee')
|
||||||
|
&& !$node->get('field_entree_liee')->isEmpty()) {
|
||||||
|
$node->set('field_entree_liee', NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only field_type_ligne (and possibly field_entree_liee) changes here
|
||||||
|
// -- montant_ht and field_repartition are untouched, so this can never
|
||||||
|
// *introduce* a répartition mismatch, only leave a pre-existing one
|
||||||
|
// (from historical data, never corrected -- see figli_compta_ledger's
|
||||||
|
// module docblock) exactly as it was. The présave check exists to
|
||||||
|
// catch new inconsistent entries, not to block relabeling the type of
|
||||||
|
// an already-migrated line, so skip it for this save only. try/finally
|
||||||
|
// guarantees the global flag clears even if save() throws for an
|
||||||
|
// unrelated reason -- leaving it on would silently skip validation on
|
||||||
|
// every other save on the site.
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||||
|
try {
|
||||||
|
$node->save();
|
||||||
|
}
|
||||||
|
catch (EntityStorageException $e) {
|
||||||
|
return new JsonResponse(['error' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => TRUE,
|
||||||
|
'type' => $type,
|
||||||
|
'entree_liee_cleared' => !in_array($type, self::LINKABLE_TYPES, TRUE),
|
||||||
|
'changed' => date(DATE_ATOM, $node->getChangedTime()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /lignes/{node}/champ -- change client/facture/libellé/signalement
|
||||||
|
* inline, for clicking directly on those cells in the table. Body:
|
||||||
|
* {"field": "client", "value": "EPAU / POPSU"}. An empty value clears
|
||||||
|
* the field (e.g. a structural charge with no client).
|
||||||
|
*/
|
||||||
|
public function updateField(Request $request, NodeInterface $node) {
|
||||||
|
if ($node->bundle() !== 'ligne_comptable') {
|
||||||
|
return new JsonResponse(['error' => 'Type de contenu invalide.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$csrfToken = $request->headers->get('X-CSRF-Token', '');
|
||||||
|
if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) {
|
||||||
|
return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), TRUE);
|
||||||
|
$field = is_array($data) ? ($data['field'] ?? NULL) : NULL;
|
||||||
|
$value = trim((string) (is_array($data) ? ($data['value'] ?? '') : ''));
|
||||||
|
if (!isset(self::INLINE_EDITABLE_FIELDS[$field])) {
|
||||||
|
return new JsonResponse(['error' => 'Champ invalide.'], 400);
|
||||||
|
}
|
||||||
|
$fieldName = self::INLINE_EDITABLE_FIELDS[$field];
|
||||||
|
|
||||||
|
if ($conflict = $this->checkConflict($request, $node)) {
|
||||||
|
return $conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'client') {
|
||||||
|
$node->set('field_client', $value === '' ? NULL : $this->findOrCreateTerm('client', $value)->id());
|
||||||
|
}
|
||||||
|
elseif ($field === 'flag') {
|
||||||
|
// Comma-separated like a native "tags" widget -- one or more
|
||||||
|
// free-form tags, each matched against an existing term or
|
||||||
|
// auto-created (same reasoning as client above). Order/dedup
|
||||||
|
// doesn't matter here, this is a display list, not a répartition.
|
||||||
|
$names = array_unique(array_filter(array_map('trim', explode(',', $value)), fn ($n) => $n !== ''));
|
||||||
|
$tids = array_map(fn ($name) => $this->findOrCreateTerm('flag', $name)->id(), $names);
|
||||||
|
$node->set('field_flag', $tids);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$node->set($fieldName, $value !== '' ? $value : NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same reasoning as updateType() above: only client/facture/libellé/
|
||||||
|
// signalement changes here, montant_ht and field_repartition are
|
||||||
|
// untouched, so skipping the répartition check for this save can
|
||||||
|
// never introduce a mismatch -- it can only leave a pre-existing
|
||||||
|
// historical one exactly as it was.
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||||
|
try {
|
||||||
|
$node->save();
|
||||||
|
}
|
||||||
|
catch (EntityStorageException $e) {
|
||||||
|
return new JsonResponse(['error' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'client') {
|
||||||
|
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
|
||||||
|
}
|
||||||
|
elseif ($field === 'flag') {
|
||||||
|
$newValue = array_map(fn ($item) => $item->entity ? $item->entity->label() : NULL, iterator_to_array($node->get('field_flag')));
|
||||||
|
$newValue = array_values(array_filter($newValue));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$newValue = $node->get($fieldName)->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => TRUE,
|
||||||
|
'field' => $field,
|
||||||
|
'value' => $newValue,
|
||||||
|
'changed' => date(DATE_ATOM, $node->getChangedTime()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an existing term by name in $vid, or creates one -- shared by
|
||||||
|
* the client and flag (signalement) inline-edit cases above. Same
|
||||||
|
* "autocreate" behavior as a standard Drupal entity reference
|
||||||
|
* autocomplete/tags widget: the front-end's datalist only *suggests*
|
||||||
|
* known names, it doesn't restrict input to them.
|
||||||
|
*/
|
||||||
|
private function findOrCreateTerm(string $vid, string $name): Term {
|
||||||
|
$terms = $this->entityTypeManager()->getStorage('taxonomy_term')
|
||||||
|
->loadByProperties(['vid' => $vid, 'name' => $name]);
|
||||||
|
if ($terms) {
|
||||||
|
return reset($terms);
|
||||||
|
}
|
||||||
|
$term = Term::create(['vid' => $vid, 'name' => $name]);
|
||||||
|
$term->save();
|
||||||
|
return $term;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic-locking guard shared by both endpoints above: the
|
||||||
|
* frontend sends the `changed` timestamp of the row it last saw (see
|
||||||
|
* buildRows() in home.js), captured at load/last-successful-save
|
||||||
|
* time. If the node's *actual* changed time has since moved on --
|
||||||
|
* someone else saved this same line in between -- the two won't
|
||||||
|
* match, and we reject rather than silently overwrite whatever that
|
||||||
|
* other save touched. Absent entirely (older cached frontend, or a
|
||||||
|
* request that genuinely doesn't know it, e.g. a fresh row from
|
||||||
|
* autocreate) skips the check rather than blocking on a false
|
||||||
|
* mismatch -- the check is a safety net for the common case, not a
|
||||||
|
* hard requirement of the API contract.
|
||||||
|
*/
|
||||||
|
private function checkConflict(Request $request, NodeInterface $node) {
|
||||||
|
$data = json_decode($request->getContent(), TRUE);
|
||||||
|
$clientChanged = is_array($data) ? ($data['changed'] ?? NULL) : NULL;
|
||||||
|
if ($clientChanged === NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
$clientChangedTs = strtotime($clientChanged);
|
||||||
|
if ($clientChangedTs !== FALSE && $clientChangedTs !== (int) $node->getChangedTime()) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'error' => 'Cette ligne a été modifiée par quelqu\'un d\'autre entre-temps. Rechargez la page pour voir les dernières modifications.',
|
||||||
|
'conflict' => TRUE,
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\node\NodeInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small aggregate endpoints backing the /lignes sliding window: the
|
||||||
|
* row-level JSON:API fetch only ever covers a date range (see home.js), so
|
||||||
|
* neither the totals footer nor the "Année" dropdown can be computed from
|
||||||
|
* whatever's currently loaded -- they need their own always-accurate
|
||||||
|
* queries, decoupled from the row window.
|
||||||
|
*/
|
||||||
|
class LedgerStatsController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /lignes/api/totaux?annee=2023 -- per-compte répartition sums (plus
|
||||||
|
* montant HT/TTC/écart totals) for every ligne_comptable dated that
|
||||||
|
* year, including ouverture lines: the footer is meant to read as the
|
||||||
|
* actual account balance (solde) for the year, i.e. the same "clôture
|
||||||
|
* calculée" (ouverture + every movement dated within the year) that
|
||||||
|
* reconciliationOuverture() compares the *next* year's ouverture
|
||||||
|
* against. Excluding ouverture here would make this a net-movement
|
||||||
|
* figure instead, which never matches what the reconciliation badge's
|
||||||
|
* tooltip cites for the same year.
|
||||||
|
*/
|
||||||
|
public function totauxAnnee(Request $request) {
|
||||||
|
$annee = $request->query->get('annee');
|
||||||
|
if (!$annee || !preg_match('/^\d{4}$/', $annee)) {
|
||||||
|
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = $this->entityTypeManager()->getStorage('node');
|
||||||
|
$nids = $storage->getQuery()
|
||||||
|
->accessCheck(TRUE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->condition('field_date_ligne', $annee . '-01-01', '>=')
|
||||||
|
->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<')
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$par_compte = [];
|
||||||
|
$montant_ht = 0.0;
|
||||||
|
$montant_ttc = 0.0;
|
||||||
|
$ecart = 0.0;
|
||||||
|
foreach ($storage->loadMultiple($nids) as $node) {
|
||||||
|
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
|
||||||
|
? (float) $node->get('field_montant_ht')->value : 0.0;
|
||||||
|
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
|
||||||
|
? (float) $node->get('field_montant_ttc')->value : 0.0;
|
||||||
|
$montant_ht += $ht;
|
||||||
|
$montant_ttc += $ttc;
|
||||||
|
|
||||||
|
$somme = 0.0;
|
||||||
|
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
|
||||||
|
if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$montant = (float) $paragraph->get('field_montant')->value;
|
||||||
|
$compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : NULL;
|
||||||
|
if ($compte) {
|
||||||
|
$par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant;
|
||||||
|
}
|
||||||
|
$somme += $montant;
|
||||||
|
}
|
||||||
|
$ecart += round($ht - $somme, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'annee' => $annee,
|
||||||
|
'montant_ht' => round($montant_ht, 2),
|
||||||
|
'montant_ttc' => round($montant_ttc, 2),
|
||||||
|
'ecart' => round($ecart, 2),
|
||||||
|
'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /lignes/api/annees -- distinct years, most recent first, with at
|
||||||
|
* least 5 lines. The threshold exists specifically to keep the handful
|
||||||
|
* of mistyped historical dates (preserved as-is -- e.g. a "0213" typo
|
||||||
|
* for "2023") from polluting the year filter with bogus one-line
|
||||||
|
* "years". A plain SQL aggregate, not Entity API: this only needs the
|
||||||
|
* date column, not full node loads.
|
||||||
|
*/
|
||||||
|
public function annees() {
|
||||||
|
$connection = \Drupal::database();
|
||||||
|
$query = $connection->select('node__field_date_ligne', 'd');
|
||||||
|
$query->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||||
|
$query->addExpression('COUNT(*)', 'total');
|
||||||
|
$query->condition('d.bundle', 'ligne_comptable');
|
||||||
|
$query->groupBy('annee');
|
||||||
|
$query->having('COUNT(*) >= 5');
|
||||||
|
$results = $query->execute()->fetchCol();
|
||||||
|
|
||||||
|
$annees = array_values($results);
|
||||||
|
rsort($annees);
|
||||||
|
|
||||||
|
return new JsonResponse(['annees' => $annees]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /lignes/api/reconciliation-ouverture -- per compte, checks that
|
||||||
|
* each year's "ouverture" (opening balance) matches the *calculated*
|
||||||
|
* closing balance of the previous year (that year's own ouverture plus
|
||||||
|
* every movement dated within it). The historical spreadsheets carried
|
||||||
|
* real year-to-year gaps that were preserved as-is during migration
|
||||||
|
* (never corrected) -- this surfaces them instead of hiding them, same
|
||||||
|
* principle as the per-ligne écart column.
|
||||||
|
*
|
||||||
|
* Response: { "2023": { "Bachir": -12.34, "EXT.WEB": 5 }, ... } -- only
|
||||||
|
* years with at least one compte off by more than a cent, and never the
|
||||||
|
* very first year on record (nothing to compare it against).
|
||||||
|
*/
|
||||||
|
public function reconciliationOuverture() {
|
||||||
|
$connection = \Drupal::database();
|
||||||
|
$query = $connection->select('node__field_date_ligne', 'd');
|
||||||
|
$query->innerJoin('node__field_type_ligne', 't', 't.entity_id = d.entity_id');
|
||||||
|
$query->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
|
||||||
|
$query->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id');
|
||||||
|
$query->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id');
|
||||||
|
$query->innerJoin('taxonomy_term_field_data', 'tc', 'tc.tid = c.field_compte_target_id');
|
||||||
|
$query->condition('d.bundle', 'ligne_comptable');
|
||||||
|
$query->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee');
|
||||||
|
$query->addExpression("CASE WHEN t.field_type_ligne_value = 'ouverture' THEN 1 ELSE 0 END", 'is_ouverture');
|
||||||
|
$query->addField('tc', 'name', 'compte');
|
||||||
|
$query->addExpression('SUM(m.field_montant_value)', 'total');
|
||||||
|
$query->groupBy('annee');
|
||||||
|
$query->groupBy('is_ouverture');
|
||||||
|
$query->groupBy('compte');
|
||||||
|
$rows = $query->execute()->fetchAll();
|
||||||
|
|
||||||
|
$ouverture = [];
|
||||||
|
$mouvement = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row->is_ouverture) {
|
||||||
|
$ouverture[$row->annee][$row->compte] = ($ouverture[$row->annee][$row->compte] ?? 0) + (float) $row->total;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$mouvement[$row->annee][$row->compte] = ($mouvement[$row->annee][$row->compte] ?? 0) + (float) $row->total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$annees = array_unique(array_merge(array_keys($ouverture), array_keys($mouvement)));
|
||||||
|
sort($annees);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($annees as $i => $annee) {
|
||||||
|
if ($i === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$precedente = (string) ((int) $annee - 1);
|
||||||
|
if (empty($ouverture[$annee]) || (!isset($ouverture[$precedente]) && !isset($mouvement[$precedente]))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$comptes = array_unique(array_merge(
|
||||||
|
array_keys($ouverture[$annee]),
|
||||||
|
array_keys($ouverture[$precedente] ?? []),
|
||||||
|
array_keys($mouvement[$precedente] ?? [])
|
||||||
|
));
|
||||||
|
$ecarts = [];
|
||||||
|
foreach ($comptes as $compte) {
|
||||||
|
$ouvertureReelle = $ouverture[$annee][$compte] ?? 0;
|
||||||
|
$clotureCalculee = ($ouverture[$precedente][$compte] ?? 0) + ($mouvement[$precedente][$compte] ?? 0);
|
||||||
|
$ecart = round($ouvertureReelle - $clotureCalculee, 2);
|
||||||
|
if (abs($ecart) > 0.01) {
|
||||||
|
$ecarts[$compte] = $ecart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($ecarts) {
|
||||||
|
$result[$annee] = $ecarts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /lignes/api/groupe/{node} -- the full transitive closure of
|
||||||
|
* entrée/sortie nodes connected via field_entree_liee, following links
|
||||||
|
* in *both* directions (this node's own field_entree_liee targets, and
|
||||||
|
* any other node that references it). The client's sliding window only
|
||||||
|
* ever holds a date-range slice of `rows` -- a versement can link to
|
||||||
|
* (or be linked from) an entrée dated years earlier or later, so
|
||||||
|
* finding the complete group from whatever happens to be loaded is not
|
||||||
|
* possible client-side; the reverse direction in particular ("which
|
||||||
|
* sorties reference this entrée") needs a real query, not a scan of
|
||||||
|
* already-loaded rows. Returns node ids only (not full ligne data) --
|
||||||
|
* the client re-fetches those specific ids via JSON:API, reusing its
|
||||||
|
* existing row-building/reconciliation code for the result.
|
||||||
|
*/
|
||||||
|
public function groupeEntree(NodeInterface $node) {
|
||||||
|
$storage = $this->entityTypeManager()->getStorage('node');
|
||||||
|
$ids = [(int) $node->id() => TRUE];
|
||||||
|
$queue = [(int) $node->id()];
|
||||||
|
while ($queue) {
|
||||||
|
$current_id = array_shift($queue);
|
||||||
|
$current = $storage->load($current_id);
|
||||||
|
if (!$current || !$current->hasField('field_entree_liee')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($current->get('field_entree_liee')->referencedEntities() as $entree) {
|
||||||
|
$eid = (int) $entree->id();
|
||||||
|
if (!isset($ids[$eid])) {
|
||||||
|
$ids[$eid] = TRUE;
|
||||||
|
$queue[] = $eid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$referencing = $storage->getQuery()
|
||||||
|
->accessCheck(TRUE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->condition('field_entree_liee', $current_id)
|
||||||
|
->execute();
|
||||||
|
foreach ($referencing as $nid) {
|
||||||
|
$nid = (int) $nid;
|
||||||
|
if (!isset($ids[$nid])) {
|
||||||
|
$ids[$nid] = TRUE;
|
||||||
|
$queue[] = $nid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new JsonResponse(['nids' => array_keys($ids)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -13,7 +13,17 @@ use Drupal\node\NodeInterface;
|
|||||||
* Quick-link form: sets field_entree_liee on a single sortie line without
|
* Quick-link form: sets field_entree_liee on a single sortie line without
|
||||||
* opening the full ligne_comptable edit form -- the associates only ever
|
* 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
|
* need to touch this one field to link a versement/achat/hébergement to
|
||||||
* the entrée client it pays out against.
|
* the entrée client(s) it pays out against. field_entree_liee is
|
||||||
|
* multi-value (cardinality unlimited) since one payment sometimes covers
|
||||||
|
* several client invoices at once; one autocomplete field per linked
|
||||||
|
* entrée plus an "Ajouter une référence" button, mirroring the classic
|
||||||
|
* Drupal multi-value widget (e.g. the ligne_comptable form's own
|
||||||
|
* Répartition paragraphs) rather than a single comma-separated field.
|
||||||
|
* Clearing a field's text (rather than a dedicated "remove" button)
|
||||||
|
* drops that link -- see submitForm() -- since a "remove" button would
|
||||||
|
* need to shift every later delta, which fights Drupal's own
|
||||||
|
* value-restoration-on-AJAX-rebuild (that only works cleanly for pure
|
||||||
|
* appends at a stable delta, which is all addItem() below ever does).
|
||||||
*/
|
*/
|
||||||
class LinkEntreeForm extends FormBase {
|
class LinkEntreeForm extends FormBase {
|
||||||
|
|
||||||
@@ -30,19 +40,50 @@ class LinkEntreeForm extends FormBase {
|
|||||||
public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL) {
|
public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL) {
|
||||||
$form_state->set('node', $node);
|
$form_state->set('node', $node);
|
||||||
|
|
||||||
$form['field_entree_liee'] = [
|
// Number of entrée-liée fields to render -- seeded from the node's
|
||||||
'#type' => 'entity_autocomplete',
|
// current values on first build, then only grown by addItem() across
|
||||||
'#title' => $this->t('Entrée client liée'),
|
// AJAX rebuilds. At least one field even when nothing is linked yet.
|
||||||
'#target_type' => 'node',
|
if ($form_state->get('items_count') === NULL) {
|
||||||
'#selection_handler' => 'figli_compta_ledger:entree_client',
|
$existing_count = $node->get('field_entree_liee')->count();
|
||||||
'#selection_settings' => [
|
$form_state->set('items_count', max($existing_count, 1));
|
||||||
'target_bundles' => ['ligne_comptable' => 'ligne_comptable'],
|
}
|
||||||
// Narrows the autocomplete to the same client as this sortie --
|
$existing = $node->get('field_entree_liee')->referencedEntities();
|
||||||
// read by EntreeClientSelection::buildEntityQuery().
|
|
||||||
'entity' => $node,
|
$form['#tree'] = TRUE;
|
||||||
],
|
$form['items'] = [
|
||||||
'#default_value' => $node->get('field_entree_liee')->entity,
|
'#type' => 'container',
|
||||||
'#description' => $this->t('Laisser vide pour retirer le lien.'),
|
'#attributes' => ['id' => 'figli-entree-liee-items'],
|
||||||
|
];
|
||||||
|
for ($delta = 0; $delta < $form_state->get('items_count'); $delta++) {
|
||||||
|
$form['items'][$delta] = [
|
||||||
|
'target' => [
|
||||||
|
'#type' => 'entity_autocomplete',
|
||||||
|
'#title' => $this->t('Entrée cliente liée'),
|
||||||
|
'#title_display' => 'invisible',
|
||||||
|
'#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' => $existing[$delta] ?? NULL,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$form['add'] = [
|
||||||
|
'#type' => 'submit',
|
||||||
|
'#value' => $this->t('+ Ajouter une référence'),
|
||||||
|
'#submit' => ['::addItem'],
|
||||||
|
'#ajax' => ['callback' => '::ajaxRefreshItems', 'wrapper' => 'figli-entree-liee-items'],
|
||||||
|
'#limit_validation_errors' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$form['description'] = [
|
||||||
|
'#weight' => -10,
|
||||||
|
'#markup' => '<p class="description">' . $this->t('Plusieurs entrées possibles (paiement en plusieurs fois) : une référence par champ, ajoutez-en avec le bouton ci-dessus. Vider un champ retire ce lien.') . '</p>',
|
||||||
];
|
];
|
||||||
|
|
||||||
$form['actions'] = ['#type' => 'actions'];
|
$form['actions'] = ['#type' => 'actions'];
|
||||||
@@ -57,13 +98,35 @@ class LinkEntreeForm extends FormBase {
|
|||||||
return $form;
|
return $form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #submit for "+ Ajouter une référence".
|
||||||
|
*/
|
||||||
|
public function addItem(array &$form, FormStateInterface $form_state) {
|
||||||
|
$form_state->set('items_count', $form_state->get('items_count') + 1);
|
||||||
|
$form_state->setRebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #ajax callback for "Ajouter" -- replaces just the fields container,
|
||||||
|
* leaving the rest of the (still-open) modal alone.
|
||||||
|
*/
|
||||||
|
public function ajaxRefreshItems(array &$form, FormStateInterface $form_state) {
|
||||||
|
return $form['items'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
public function submitForm(array &$form, FormStateInterface $form_state) {
|
public function submitForm(array &$form, FormStateInterface $form_state) {
|
||||||
/** @var \Drupal\node\NodeInterface $node */
|
/** @var \Drupal\node\NodeInterface $node */
|
||||||
$node = $form_state->get('node');
|
$node = $form_state->get('node');
|
||||||
$node->set('field_entree_liee', $form_state->getValue('field_entree_liee') ?: NULL);
|
$values = [];
|
||||||
|
foreach ($form_state->getValue('items') as $item) {
|
||||||
|
if (!empty($item['target'])) {
|
||||||
|
$values[] = ['target_id' => $item['target']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$node->set('field_entree_liee', $values);
|
||||||
$node->save();
|
$node->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -23,9 +23,16 @@ class EntreeClientSelection extends NodeSelection {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
|
*
|
||||||
|
* Matches on title *or* field_numero_facture -- several entrées for the
|
||||||
|
* same client often share the same title format ("EPAU - 2026-01-05"),
|
||||||
|
* so being able to type the invoice number itself ("F2549") is what
|
||||||
|
* actually finds the right one quickly. $match is withheld from the
|
||||||
|
* parent call (it would otherwise add its own title-only condition)
|
||||||
|
* and applied manually below as an OR across both fields instead.
|
||||||
*/
|
*/
|
||||||
protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
|
protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
|
||||||
$query = parent::buildEntityQuery($match, $match_operator);
|
$query = parent::buildEntityQuery(NULL, $match_operator);
|
||||||
$query->condition('field_type_ligne', 'entree');
|
$query->condition('field_type_ligne', 'entree');
|
||||||
|
|
||||||
$entity = $this->configuration['entity'] ?? NULL;
|
$entity = $this->configuration['entity'] ?? NULL;
|
||||||
@@ -33,6 +40,14 @@ class EntreeClientSelection extends NodeSelection {
|
|||||||
$query->condition('field_client', $entity->get('field_client')->target_id);
|
$query->condition('field_client', $entity->get('field_client')->target_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($match)) {
|
||||||
|
$query->condition(
|
||||||
|
$query->orConditionGroup()
|
||||||
|
->condition('title', $match, $match_operator)
|
||||||
|
->condition('field_numero_facture', $match, $match_operator)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
{#
|
||||||
|
Dashboard par compte associé (freelance) : un compte à la fois, choisi
|
||||||
|
dans le sélecteur. Se concentre sur entrée client / versement freelance
|
||||||
|
uniquement -- l'objectif principal est de faire ressortir les versements
|
||||||
|
pas (encore) compensés par une entrée, pas de donner une vue comptable
|
||||||
|
complète (voir /dashboard pour ça).
|
||||||
|
|
||||||
|
{% verbatim %} ci-dessous : syntaxe Vue, pas Twig -- voir
|
||||||
|
figli-compta-dashboard.html.twig pour la même remarque.
|
||||||
|
#}
|
||||||
|
<nav class="figli-page-nav">
|
||||||
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
|
</nav>
|
||||||
|
{% verbatim %}
|
||||||
|
<div id="figli-dashboard-app">
|
||||||
|
<p v-if="loading">Chargement des données…</p>
|
||||||
|
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<div class="figli-compte-selector">
|
||||||
|
<label for="figli-compte-select">Compte associé</label>
|
||||||
|
<select id="figli-compte-select" :value="selectedCompte" @change="selectCompte($event.target.value)">
|
||||||
|
<option v-for="c in comptes" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="figli-summary-row">
|
||||||
|
<div class="figli-summary-card">
|
||||||
|
<div class="figli-summary-label">Solde actuel</div>
|
||||||
|
<div class="figli-summary-value" :class="soldeActuel < 0 ? 'is-negative' : 'is-positive'">{{ formatEurRound(soldeActuel) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="figli-summary-card" :class="{'is-alert': totalResteAVerser > 0.01}">
|
||||||
|
<div class="figli-summary-label">Reste à verser</div>
|
||||||
|
<div class="figli-summary-value" :class="totalResteAVerser > 0.01 ? 'is-negative' : ''">{{ formatEurRound(totalResteAVerser) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="figli-summary-card" v-if="totalSurVerse > 0.01">
|
||||||
|
<div class="figli-summary-label">Sur-versé</div>
|
||||||
|
<div class="figli-summary-value is-negative">{{ formatEurRound(totalSurVerse) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="figli-summary-card" :class="{'is-alert': totalNonLies > 0.01}" v-if="versementsNonLies.length">
|
||||||
|
<div class="figli-summary-label">Versements non liés</div>
|
||||||
|
<div class="figli-summary-value is-negative">{{ formatEurRound(totalNonLies) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Reste à verser -- entrées non (entièrement) compensées</h2>
|
||||||
|
<p class="figli-note">Pour {{ selectedCompte }} : entrées client dont la part attribuée n'a pas encore été entièrement reversée. Triées de la plus ancienne à la plus récente.</p>
|
||||||
|
<table v-if="resteAVerserPositif.length" class="figli-recon-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Attribué</th><th>Déjà versé</th><th>Reste à verser</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in resteAVerserPositif" :key="r.entree.id" :class="{'figli-flag-row': r.entree.hasFlag}">
|
||||||
|
<td>{{ formatDate(r.entree.date) }}</td>
|
||||||
|
<td><a :href="ligneHref(r.entree.client)">{{ r.entree.client || '—' }}</a></td>
|
||||||
|
<td>{{ r.entree.libelle || '—' }}<span v-for="f in r.entree.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
|
||||||
|
<td>{{ formatEur(r.montantAttribue) }}</td>
|
||||||
|
<td>{{ formatEur(r.dejaVerse) }}</td>
|
||||||
|
<td class="is-negative">{{ formatEur(r.residual) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colspan="5">Total</td><td class="is-negative">{{ formatEurRound(totalResteAVerser) }}</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
<p v-else class="figli-note">Rien en attente -- toutes les entrées de ce compte sont compensées.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="surVerseRows.length">
|
||||||
|
<h2>Entrées sur-versées</h2>
|
||||||
|
<p class="figli-note">Le montant versé pour ces entrées dépasse la part attribuée à {{ selectedCompte }} -- à vérifier.</p>
|
||||||
|
<table class="figli-recon-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Attribué</th><th>Versé</th><th>Sur-versé</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in surVerseRows" :key="r.entree.id" :class="{'figli-flag-row': r.entree.hasFlag}">
|
||||||
|
<td>{{ formatDate(r.entree.date) }}</td>
|
||||||
|
<td><a :href="ligneHref(r.entree.client)">{{ r.entree.client || '—' }}</a></td>
|
||||||
|
<td>{{ r.entree.libelle || '—' }}<span v-for="f in r.entree.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
|
||||||
|
<td>{{ formatEur(r.montantAttribue) }}</td>
|
||||||
|
<td>{{ formatEur(r.dejaVerse) }}</td>
|
||||||
|
<td class="is-negative">{{ formatEur(-r.residual) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colspan="5">Total</td><td class="is-negative">{{ formatEurRound(totalSurVerse) }}</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="versementsNonLies.length">
|
||||||
|
<h2>Versements sans entrée liée</h2>
|
||||||
|
<p class="figli-note">Versements de {{ selectedCompte }} qui ne pointent vers aucune entrée client -- ni "reste à verser" ni "sur-versé" ci-dessus ne les couvre.</p>
|
||||||
|
<table class="figli-recon-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Client</th><th>Libellé</th><th>Montant</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in versementsNonLies" :key="r.id" :class="{'figli-flag-row': r.hasFlag}">
|
||||||
|
<td>{{ formatDate(r.date) }}</td>
|
||||||
|
<td><a :href="ligneHref(r.client)">{{ r.client || '—' }}</a></td>
|
||||||
|
<td>{{ r.libelle || '—' }}<span v-for="f in r.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
|
||||||
|
<td class="is-negative">{{ formatEur(r.parCompte[selectedCompte]) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colspan="3">Total</td><td class="is-negative">{{ formatEurRound(-totalNonLies) }}</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="lignesSignalees.length">
|
||||||
|
<h2>Lignes signalées</h2>
|
||||||
|
<p class="figli-note">Entrées et versements de {{ selectedCompte }} portant un signalement (voir la colonne "Signalement" dans le grand livre) -- indépendant du solde, une ligne déjà réglée peut quand même être signalée pour une autre raison.</p>
|
||||||
|
<label>Signalement
|
||||||
|
<span class="figli-filter-row">
|
||||||
|
<div class="figli-multiselect">
|
||||||
|
<div class="figli-multiselect-trigger">{{ flagFilter.length ? flagFilter.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
|
||||||
|
<div class="figli-multiselect-panel">
|
||||||
|
<label v-for="f in flagsDisponibles" :key="f" class="figli-multiselect-option">
|
||||||
|
<input type="checkbox" :value="f" v-model="flagFilter" /> {{ f }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="flagFilter.length" type="button" class="figli-filter-clear" @click="flagFilter = []" title="Effacer ce filtre">✕</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<table class="figli-recon-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Type</th><th>Client</th><th>Libellé</th><th>Signalement</th><th>Montant</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in lignesSignaleesFiltrees" :key="r.id" class="figli-flag-row">
|
||||||
|
<td>{{ formatDate(r.date) }}</td>
|
||||||
|
<td>{{ r.type === 'entree' ? 'Entrée' : 'Versement' }}</td>
|
||||||
|
<td><a :href="ligneHref(r.client)">{{ r.client || '—' }}</a></td>
|
||||||
|
<td>{{ r.libelle || '—' }}</td>
|
||||||
|
<td><span v-for="f in r.flags" :key="f" class="figli-flag-badge">{{ f }}</span></td>
|
||||||
|
<td :class="r.parCompte[selectedCompte] < 0 ? 'is-negative' : ''">{{ formatEur(r.parCompte[selectedCompte]) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!lignesSignaleesFiltrees.length"><td colspan="6" class="figli-note">Aucune ligne pour ce signalement.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">Solde</td>
|
||||||
|
<td :class="totalLignesSignalees < 0 ? 'is-negative' : ''">{{ formatEur(totalLignesSignalees) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Entrées vs versements par année</h2>
|
||||||
|
<p class="figli-note">Part de {{ selectedCompte }} dans les entrées client (vert) et ses versements (rouge), année par année.</p>
|
||||||
|
<year-bars-chart :years="entreeVsVersementYears" :format-value="formatEurRound"></year-bars-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Évolution du solde</h2>
|
||||||
|
<p class="figli-note">Solde de clôture de {{ selectedCompte }}, année par année (ouverture comprise).</p>
|
||||||
|
<year-bars-chart :years="evolutionSoldeYears" :format-value="formatEurRound"></year-bars-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="typeItems.length">
|
||||||
|
<h2>Répartition de l'activité par type</h2>
|
||||||
|
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne pour {{ selectedCompte }}, hors ouvertures -- contrairement aux tableaux ci-dessus, tous les types comptent ici (pas seulement entrée/versement).</p>
|
||||||
|
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||||
|
<div class="figli-year-hbar-grid">
|
||||||
|
<div class="figli-year-hbar-card" v-for="y in typeItemsParAnnee" :key="y.annee">
|
||||||
|
<div class="figli-year-hbar-title">{{ y.annee }}</div>
|
||||||
|
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section" v-if="topClientsItems.length">
|
||||||
|
<h2>Top clients</h2>
|
||||||
|
<p class="figli-note">Clients ayant généré le plus d'entrées attribuées à {{ selectedCompte }}, toutes années confondues.</p>
|
||||||
|
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
|
||||||
|
<div class="figli-year-hbar-grid">
|
||||||
|
<div class="figli-year-hbar-card" v-for="y in topClientsParAnnee" :key="y.annee">
|
||||||
|
<div class="figli-year-hbar-title">{{ y.annee }}</div>
|
||||||
|
<h-bar-chart :items="y.items" :format-value="formatEurRound" compact></h-bar-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endverbatim %}
|
||||||
@@ -1,36 +1,70 @@
|
|||||||
{#
|
{#
|
||||||
Aggregate view: solde par compte / solde par client. The spreadsheet-like
|
Charts and aggregate totals: solde par compte, chiffre d'affaires par
|
||||||
|
année, répartition par type, top clients. The spreadsheet-like
|
||||||
line-by-line view is the site's home page (figli-compta-home.html.twig).
|
line-by-line view is the site's home page (figli-compta-home.html.twig).
|
||||||
|
|
||||||
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
||||||
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
||||||
in the browser.
|
in the browser.
|
||||||
#}
|
#}
|
||||||
|
<nav class="figli-page-nav">
|
||||||
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-dashboard-app">
|
<div id="figli-dashboard-app">
|
||||||
<p v-if="loading">Chargement des données…</p>
|
<p v-if="loading">Chargement des données…</p>
|
||||||
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
|
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section v-for="table in tables" :key="table.title">
|
<div class="figli-summary-row">
|
||||||
<h2>{{ table.title }}</h2>
|
<div class="figli-summary-card">
|
||||||
<p class="figli-note" v-if="table.note">{{ table.note }}</p>
|
<div class="figli-summary-label">Chiffre d'affaires total</div>
|
||||||
<table>
|
<div class="figli-summary-value">{{ formatEurRound(totalCA) }}</div>
|
||||||
<thead><tr><th></th><th>Entrées (+)</th><th>Sorties (-)</th><th>Solde</th></tr></thead>
|
</div>
|
||||||
<tbody>
|
<div class="figli-summary-card" v-if="caAnneeEnCours">
|
||||||
<tr v-for="row in table.rows" :key="row.name" :class="rowClass(row.solde)">
|
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
|
||||||
<td>{{ row.name }}</td>
|
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
|
||||||
<td class="amount">{{ formatEur(row.entrees) }}</td>
|
</div>
|
||||||
<td class="amount">{{ formatEur(row.sorties) }}</td>
|
<div class="figli-summary-card" v-for="item in soldeParCompteItems.slice(0, 1)" :key="'top-' + item.label">
|
||||||
<td class="amount">{{ formatEur(row.solde) }}</td>
|
<div class="figli-summary-label">Meilleur solde</div>
|
||||||
</tr>
|
<div class="figli-summary-value" :class="item.value < 0 ? 'is-negative' : 'is-positive'">{{ item.label }} · {{ formatEurRound(item.value) }}</div>
|
||||||
<tr class="total">
|
</div>
|
||||||
<td>TOTAL</td>
|
</div>
|
||||||
<td class="amount">{{ formatEur(table.totals.entrees) }}</td>
|
|
||||||
<td class="amount">{{ formatEur(table.totals.sorties) }}</td>
|
<section class="figli-chart-section">
|
||||||
<td class="amount">{{ formatEur(table.totals.solde) }}</td>
|
<h2>Chiffre d'affaires par année</h2>
|
||||||
</tr>
|
<p class="figli-note">Total des entrées client (montant HT) facturées chaque année.</p>
|
||||||
</tbody>
|
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
|
||||||
</table>
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Solde par compte</h2>
|
||||||
|
<p class="figli-note">Solde cumulé de chaque compte depuis l'origine (ouverture comprise) -- vert = créditeur, rouge = débiteur.</p>
|
||||||
|
<h-bar-chart :items="soldeParCompteItems" :format-value="formatEurRound"></h-bar-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Évolution du solde par compte</h2>
|
||||||
|
<p class="figli-note">Solde de clôture de chaque compte, année par année.</p>
|
||||||
|
<div class="figli-trend-grid">
|
||||||
|
<div class="figli-trend-card" v-for="compte in comptesOrdonnes" :key="compte">
|
||||||
|
<div class="figli-trend-title">{{ compte }}</div>
|
||||||
|
<mini-trend :annees="stats.annees" :values="trendValues(compte)" :format-value="formatEurRound"></mini-trend>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Répartition de l'activité par type</h2>
|
||||||
|
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p>
|
||||||
|
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Top clients par chiffre d'affaires</h2>
|
||||||
|
<p class="figli-note">Les 12 clients ayant généré le plus de chiffre d'affaires, toutes années confondues.</p>
|
||||||
|
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
"Ajouter une ligne" opens the real Drupal node form in a modal
|
"Ajouter une ligne" opens the real Drupal node form in a modal
|
||||||
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
||||||
#}
|
#}
|
||||||
|
<nav class="figli-page-nav">
|
||||||
|
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
|
||||||
|
</nav>
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
<div id="figli-home-app">
|
<div id="figli-home-app">
|
||||||
<div class="figli-toolbar">
|
<div class="figli-toolbar">
|
||||||
@@ -16,30 +21,72 @@
|
|||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
|
|
||||||
<label>Compte
|
<label>Compte
|
||||||
<select v-model="filterCompte">
|
<span class="figli-filter-row">
|
||||||
<option value="">Tous</option>
|
<div class="figli-multiselect">
|
||||||
<option v-for="c in allComptes" :key="c" :value="c">{{ c }}</option>
|
<div class="figli-multiselect-trigger">{{ filterCompte.length ? filterCompte.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
|
||||||
</select>
|
<div class="figli-multiselect-panel">
|
||||||
|
<label v-for="c in allComptes" :key="c" class="figli-multiselect-option">
|
||||||
|
<input type="checkbox" :value="c" v-model="filterCompte" /> {{ c }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="filterCompte.length" type="button" class="figli-filter-clear" @click="filterCompte = []" title="Effacer ce filtre">✕</button>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>Client
|
<label>Client
|
||||||
<select v-model="filterClient">
|
<span class="figli-filter-row">
|
||||||
<option value="">Tous</option>
|
<input type="text" v-model.lazy="filterClient" list="figli-client-datalist" placeholder="Tous" autocomplete="off" class="figli-client-input" />
|
||||||
<option v-for="c in allClients" :key="c" :value="c">{{ c }}</option>
|
<datalist id="figli-client-datalist">
|
||||||
</select>
|
<option v-for="c in allClientsList" :key="c" :value="c"></option>
|
||||||
|
</datalist>
|
||||||
|
<button v-if="filterClient" type="button" class="figli-filter-clear" @click="filterClient = ''" title="Effacer ce filtre">✕</button>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>Type
|
<label>Type
|
||||||
<select v-model="filterType">
|
<span class="figli-filter-row">
|
||||||
<option value="">Tous</option>
|
<div class="figli-multiselect">
|
||||||
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
<div class="figli-multiselect-trigger">{{ filterType.length ? filterType.map(typeLabel).join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
|
||||||
</select>
|
<div class="figli-multiselect-panel">
|
||||||
|
<label v-for="t in allTypes" :key="t.value" class="figli-multiselect-option">
|
||||||
|
<input type="checkbox" :value="t.value" v-model="filterType" /> {{ t.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="filterType.length" type="button" class="figli-filter-clear" @click="filterType = []" title="Effacer ce filtre">✕</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Signalement
|
||||||
|
<span class="figli-filter-row">
|
||||||
|
<div class="figli-multiselect">
|
||||||
|
<div class="figli-multiselect-trigger">{{ filterFlag.length ? filterFlag.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
|
||||||
|
<div class="figli-multiselect-panel">
|
||||||
|
<label v-for="f in allFlagsList" :key="f" class="figli-multiselect-option">
|
||||||
|
<input type="checkbox" :value="f" v-model="filterFlag" /> {{ f }}
|
||||||
|
</label>
|
||||||
|
<p v-if="!allFlagsList.length" class="figli-note">Aucun signalement existant.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="filterFlag.length" type="button" class="figli-filter-clear" @click="filterFlag = []" title="Effacer ce filtre">✕</button>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>Année
|
<label>Année
|
||||||
<select v-model="filterYear">
|
<span class="figli-filter-row">
|
||||||
<option value="">Toutes</option>
|
<select v-model="filterYear">
|
||||||
<option v-for="y in allYears" :key="y" :value="y">{{ y }}</option>
|
<option value="">Toutes</option>
|
||||||
|
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
|
||||||
|
</select>
|
||||||
|
<button v-if="filterYear" type="button" class="figli-filter-clear" @click="filterYear = ''" title="Effacer ce filtre">✕</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Aller à
|
||||||
|
<select v-model="jumpYearValue" @change="onJumpYearChange">
|
||||||
|
<option value="">Aller à…</option>
|
||||||
|
<option v-for="y in allYearsList" :key="y" :value="y">{{ y }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -55,23 +102,32 @@
|
|||||||
<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>
|
<label class="figli-checkbox">
|
||||||
|
<input type="checkbox" v-model="onlyFlagged" /> Signalées uniquement
|
||||||
|
</label>
|
||||||
|
|
||||||
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
<datalist id="figli-flag-datalist">
|
||||||
|
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="typeUpdateError" class="figli-error figli-inline-error">Erreur : {{ typeUpdateError }} <button type="button" class="figli-clear-drilldown" @click="typeUpdateError = null">✕</button></p>
|
||||||
|
|
||||||
<p v-if="loading">Chargement des données…</p>
|
<p v-if="loading">Chargement des données…</p>
|
||||||
<p v-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
|
<p v-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
|
||||||
<div v-else class="figli-table-wrap">
|
<div v-else ref="tableWrap" class="figli-table-wrap">
|
||||||
<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 class="actions-col"></th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
|
<th>Facture</th>
|
||||||
<th>Libellé / Détail</th>
|
<th>Libellé / Détail</th>
|
||||||
|
<th>Signalement</th>
|
||||||
<th class="amount">Montant HT</th>
|
<th class="amount">Montant HT</th>
|
||||||
<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>
|
||||||
@@ -79,26 +135,16 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr ref="topSentinel" class="figli-sentinel-row">
|
||||||
|
<td :colspan="7 + allComptes.length + 3">
|
||||||
|
<span v-if="loadingOlder">Chargement des mois précédents…</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<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" :data-year="item.year">
|
||||||
<td :colspan="6 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
<td :colspan="7 + 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-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
|
||||||
<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">
|
<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">
|
||||||
@@ -107,17 +153,91 @@
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDate(item.date) }}</td>
|
<td>{{ formatDate(item.date) }}</td>
|
||||||
<td>{{ item.client || '—' }}</td>
|
<td>
|
||||||
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
|
<input
|
||||||
<td class="figli-libelle">
|
v-if="isEditingCell(item, 'client')"
|
||||||
{{ item.libelle }}
|
v-focus
|
||||||
|
type="text"
|
||||||
|
class="figli-inline-input"
|
||||||
|
list="figli-client-datalist"
|
||||||
|
:value="item.client"
|
||||||
|
@change="saveCell(item, 'client', $event)"
|
||||||
|
@blur="editingCell = null"
|
||||||
|
@keyup.enter="$event.target.blur()"
|
||||||
|
/>
|
||||||
|
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'client')">{{ item.client || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
v-if="editingTypeId === item.id"
|
||||||
|
v-focus
|
||||||
|
class="figli-type-select"
|
||||||
|
:value="item.type"
|
||||||
|
@change="saveType(item, $event)"
|
||||||
|
@blur="editingTypeId = null"
|
||||||
|
>
|
||||||
|
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
<span
|
<span
|
||||||
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
|
v-else
|
||||||
class="figli-recon-badge"
|
class="figli-badge figli-badge-editable"
|
||||||
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
|
:class="'type-' + item.type"
|
||||||
:title="reconciliationByEntree.get(item.id).detail"
|
title="Cliquer pour changer le type"
|
||||||
@click="toggleEntreeFilter(item.id)"
|
@click="startEditType(item)"
|
||||||
>{{ 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>
|
>{{ typeLabel(item.type) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-if="isEditingCell(item, 'facture')"
|
||||||
|
v-focus
|
||||||
|
type="text"
|
||||||
|
class="figli-inline-input"
|
||||||
|
:value="item.facture"
|
||||||
|
@change="saveCell(item, 'facture', $event)"
|
||||||
|
@blur="editingCell = null"
|
||||||
|
@keyup.enter="$event.target.blur()"
|
||||||
|
/>
|
||||||
|
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'facture')">{{ item.facture || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="figli-libelle">
|
||||||
|
<input
|
||||||
|
v-if="isEditingCell(item, 'libelle')"
|
||||||
|
v-focus
|
||||||
|
type="text"
|
||||||
|
class="figli-inline-input"
|
||||||
|
:value="item.libelle"
|
||||||
|
@change="saveCell(item, 'libelle', $event)"
|
||||||
|
@blur="editingCell = null"
|
||||||
|
@keyup.enter="$event.target.blur()"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<span class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'libelle')">{{ item.libelle || '—' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
|
||||||
|
class="figli-recon-badge is-clickable"
|
||||||
|
: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>
|
||||||
|
<span
|
||||||
|
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
|
||||||
|
class="figli-recon-badge is-anomalie"
|
||||||
|
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
|
||||||
|
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
|
||||||
|
<span
|
||||||
|
v-if="linkStatus(item) && item.type !== 'hebergement'"
|
||||||
|
class="figli-recon-badge is-clickable"
|
||||||
|
:class="linkStatusClasses(linkStatus(item).kind)"
|
||||||
|
:title="item.entreeLieeIds.length ? linkStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : linkStatus(item).detail + ' -- cliquer pour lier une entrée client'"
|
||||||
|
@click="item.entreeLieeIds.length ? toggleEntreeFilter(item.entreeLieeIds[0], item.nid) : openLinkForm(item.nid)"
|
||||||
|
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item)) }}</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="figli-flag-cell">
|
||||||
|
<span class="figli-editable-cell" title="Cliquer pour gérer le signalement" @click="openFlagModal(item)">
|
||||||
|
<template v-if="item.flags.length"><span v-for="f in item.flags" :key="f" class="figli-flag-badge">{{ f }}</span></template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
|
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
|
||||||
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
||||||
@@ -125,19 +245,122 @@
|
|||||||
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
<tr ref="bottomSentinel" class="figli-sentinel-row">
|
||||||
|
<td :colspan="7 + allComptes.length + 3">
|
||||||
|
<span v-if="loadingNewer">Chargement des mois suivants…</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</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 class="actions-col"></td>
|
<td colspan="6">
|
||||||
<td colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td>
|
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
||||||
<td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td>
|
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
||||||
<td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td>
|
</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">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
|
||||||
<td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
|
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td>
|
||||||
|
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td>
|
||||||
|
<td class="amount" :class="currentYearTotals ? soldeClass(currentYearTotals.ecart) : ''">{{ currentYearTotals ? formatEur(currentYearTotals.ecart) : '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filterEntreeId" class="figli-modal-backdrop" @click.self="closeDrilldown">
|
||||||
|
<div class="figli-modal">
|
||||||
|
<div class="figli-modal-header">
|
||||||
|
<h3>Entrées et sorties liées <span v-if="groupLoading" class="figli-note">vérification…</span></h3>
|
||||||
|
<button type="button" class="figli-modal-close" @click="closeDrilldown">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="figli-modal-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="actions-col"></th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Facture</th>
|
||||||
|
<th>Libellé / Détail</th>
|
||||||
|
<th class="amount">Montant HT</th>
|
||||||
|
<th class="amount">Montant TTC</th>
|
||||||
|
<th v-for="c in modalComptes" :key="c" class="amount compte-col">{{ c }}</th>
|
||||||
|
<th class="amount">Écart</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in filterEntreeGroup" :key="item.id">
|
||||||
|
<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">
|
||||||
|
<path d="M13.5 3.5l3 3L6 17l-3.5.5.5-3.5L13.5 3.5z" />
|
||||||
|
</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>{{ item.facture }}</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}"
|
||||||
|
>{{ 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>
|
||||||
|
<span
|
||||||
|
v-if="linkStatus(item) && item.type !== 'hebergement'"
|
||||||
|
class="figli-recon-badge"
|
||||||
|
:class="linkStatusClasses(linkStatus(item).kind)"
|
||||||
|
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item)) }}</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 modalComptes" :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>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="figli-totals-row">
|
||||||
|
<td class="actions-col"></td>
|
||||||
|
<td colspan="5">Solde entrées et sorties liées (créditeur / débiteur)</td>
|
||||||
|
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
|
||||||
|
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
|
||||||
|
<td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>
|
||||||
|
<td class="amount" :class="drilldownTotals ? soldeClass(drilldownTotals.ecart) : ''">{{ drilldownTotals ? formatEur(drilldownTotals.ecart) : '' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="flagModalItem" class="figli-modal-backdrop" @click.self="closeFlagModal">
|
||||||
|
<div class="figli-modal figli-flag-modal">
|
||||||
|
<div class="figli-modal-header">
|
||||||
|
<h3>Signalement -- {{ formatDate(flagModalItem.date) }}{{ flagModalItem.client ? ' · ' + flagModalItem.client : '' }}</h3>
|
||||||
|
<button type="button" class="figli-modal-close" @click="closeFlagModal">✕ Fermer</button>
|
||||||
|
</div>
|
||||||
|
<div class="figli-flag-modal-body">
|
||||||
|
<p v-if="!flagModalItem.flags.length" class="figli-note">Aucun signalement pour cette ligne.</p>
|
||||||
|
<div v-for="f in flagModalItem.flags" :key="f" class="figli-flag-modal-row">
|
||||||
|
<span class="figli-flag-badge">{{ f }}</span>
|
||||||
|
<button type="button" class="figli-flag-remove" title="Retirer ce signalement" @click="removeFlagTag(f)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="figli-flag-modal-add">
|
||||||
|
<input
|
||||||
|
v-focus
|
||||||
|
type="text"
|
||||||
|
list="figli-flag-datalist"
|
||||||
|
placeholder="Ajouter un signalement…"
|
||||||
|
v-model="flagModalNewTag"
|
||||||
|
@keyup.enter="addFlagTag"
|
||||||
|
/>
|
||||||
|
<button type="button" class="button" @click="addFlagTag">Ajouter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endverbatim %}
|
{% endverbatim %}
|
||||||
|
|||||||
Reference in New Issue
Block a user