Compare commits
105
Commits
2ebfa3b413
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e1c421e5b | ||
|
|
5eca3804bf | ||
|
|
d2f3179f01 | ||
|
|
3688bddba8 | ||
|
|
af36591d70 | ||
|
|
74d8eb2aa2 | ||
|
|
96bd9502d2 | ||
|
|
cb2a0fcdaa | ||
|
|
612269ec6d | ||
|
|
d17a3e2e33 | ||
|
|
692f7f3ea3 | ||
|
|
8dfb4af98a | ||
|
|
43817dcdce | ||
|
|
3552b5a79d | ||
|
|
c927771795 | ||
|
|
f7e7ae3265 | ||
|
|
7bcf65d25b | ||
|
|
00956825fa | ||
|
|
74ee8ab3b9 | ||
|
|
6576b35c07 | ||
|
|
0baa03855b | ||
|
|
6c33bc0098 | ||
|
|
134c97ac9d | ||
|
|
897d3c208e | ||
|
|
e117089be5 | ||
|
|
f0927bac88 | ||
|
|
5aa00b2ffd | ||
|
|
b7490eb76a | ||
|
|
f00f680143 | ||
|
|
4ce1b12099 | ||
|
|
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 | ||
|
|
4636fc3522 | ||
|
|
8eb92c613d | ||
|
|
13461d0cb1 | ||
|
|
5f4ec4dbee | ||
|
|
f3ce872b02 | ||
|
|
0e36274d51 | ||
|
|
d04d0e8824 | ||
|
|
27a799e937 | ||
|
|
384b283547 | ||
|
|
994c7c7aad | ||
|
|
1f357e5f1c | ||
|
|
e7796754ab | ||
|
|
a86875c03c | ||
|
|
f8ae2d6a9e | ||
|
|
8c63c19619 | ||
|
|
a5af59a349 | ||
|
|
f2a8f469b3 |
@@ -5,4 +5,6 @@
|
|||||||
/web/libraries/
|
/web/libraries/
|
||||||
/web/sites/*/files/
|
/web/sites/*/files/
|
||||||
/web/sites/*/settings.local.php
|
/web/sites/*/settings.local.php
|
||||||
|
/web/sites/*/settings.php
|
||||||
|
/private/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Deny all requests from Apache 2.4+.
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Deny all requests from Apache 2.0-2.2.
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Turn off all options we don't need.
|
||||||
|
Options -Indexes -ExecCGI -Includes -MultiViews
|
||||||
|
|
||||||
|
# Set the catch-all handler to prevent scripts from being executed.
|
||||||
|
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006
|
||||||
|
<Files *>
|
||||||
|
# Override the handler again if we're run later in the evaluation list.
|
||||||
|
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2013_003
|
||||||
|
</Files>
|
||||||
|
|
||||||
|
# If we know how to do it safely, disable the PHP engine entirely.
|
||||||
|
<IfModule mod_php.c>
|
||||||
|
php_flag engine off
|
||||||
|
</IfModule>
|
||||||
@@ -4,11 +4,18 @@ status: true
|
|||||||
dependencies:
|
dependencies:
|
||||||
config:
|
config:
|
||||||
- field.field.node.ligne_comptable.field_client
|
- field.field.node.ligne_comptable.field_client
|
||||||
|
- field.field.node.ligne_comptable.field_cotisation_active
|
||||||
|
- field.field.node.ligne_comptable.field_cotisation_urssaf
|
||||||
- field.field.node.ligne_comptable.field_date_ligne
|
- field.field.node.ligne_comptable.field_date_ligne
|
||||||
|
- field.field.node.ligne_comptable.field_ecart
|
||||||
|
- 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_tva
|
||||||
- field.field.node.ligne_comptable.field_type_ligne
|
- field.field.node.ligne_comptable.field_type_ligne
|
||||||
- node.type.ligne_comptable
|
- node.type.ligne_comptable
|
||||||
module:
|
module:
|
||||||
@@ -36,37 +43,79 @@ content:
|
|||||||
size: 60
|
size: 60
|
||||||
placeholder: ''
|
placeholder: ''
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
|
field_cotisation_active:
|
||||||
|
type: boolean_checkbox
|
||||||
|
weight: 4
|
||||||
|
region: content
|
||||||
|
settings:
|
||||||
|
display_label: true
|
||||||
|
third_party_settings: { }
|
||||||
|
field_cotisation_urssaf:
|
||||||
|
type: number
|
||||||
|
weight: 6
|
||||||
|
region: content
|
||||||
|
settings:
|
||||||
|
placeholder: ''
|
||||||
|
third_party_settings: { }
|
||||||
field_date_ligne:
|
field_date_ligne:
|
||||||
type: datetime_default
|
type: datetime_default
|
||||||
weight: 0
|
weight: 0
|
||||||
region: content
|
region: content
|
||||||
settings: { }
|
settings: { }
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
|
field_entree_liee:
|
||||||
|
type: entity_reference_autocomplete
|
||||||
|
weight: 4
|
||||||
|
region: content
|
||||||
|
settings:
|
||||||
|
match_operator: CONTAINS
|
||||||
|
match_limit: 10
|
||||||
|
size: 60
|
||||||
|
placeholder: ''
|
||||||
|
third_party_settings: { }
|
||||||
|
field_flag:
|
||||||
|
type: entity_reference_autocomplete_tags
|
||||||
|
weight: 12
|
||||||
|
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: 5
|
||||||
region: content
|
region: content
|
||||||
settings:
|
settings:
|
||||||
placeholder: ''
|
placeholder: ''
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
field_montant_ttc:
|
field_montant_ttc:
|
||||||
type: number
|
type: number
|
||||||
weight: 4
|
weight: 8
|
||||||
region: content
|
region: content
|
||||||
settings:
|
settings:
|
||||||
placeholder: ''
|
placeholder: ''
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
field_notes:
|
field_notes:
|
||||||
type: string_textarea
|
type: string_textarea
|
||||||
weight: 6
|
weight: 11
|
||||||
region: content
|
region: content
|
||||||
settings:
|
settings:
|
||||||
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: 10
|
||||||
region: content
|
region: content
|
||||||
settings:
|
settings:
|
||||||
title: Répartition
|
title: Répartition
|
||||||
@@ -82,6 +131,13 @@ content:
|
|||||||
collapse_edit_all: collapse_edit_all
|
collapse_edit_all: collapse_edit_all
|
||||||
duplicate: duplicate
|
duplicate: duplicate
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
|
field_tva:
|
||||||
|
type: number
|
||||||
|
weight: 9
|
||||||
|
region: content
|
||||||
|
settings:
|
||||||
|
placeholder: ''
|
||||||
|
third_party_settings: { }
|
||||||
field_type_ligne:
|
field_type_ligne:
|
||||||
type: options_select
|
type: options_select
|
||||||
weight: 1
|
weight: 1
|
||||||
@@ -120,5 +176,6 @@ content:
|
|||||||
placeholder: ''
|
placeholder: ''
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
hidden:
|
hidden:
|
||||||
|
field_ecart: true
|
||||||
promote: true
|
promote: true
|
||||||
sticky: true
|
sticky: true
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ status: true
|
|||||||
dependencies:
|
dependencies:
|
||||||
config:
|
config:
|
||||||
- field.field.node.ligne_comptable.field_client
|
- field.field.node.ligne_comptable.field_client
|
||||||
|
- field.field.node.ligne_comptable.field_cotisation_active
|
||||||
|
- field.field.node.ligne_comptable.field_cotisation_urssaf
|
||||||
- field.field.node.ligne_comptable.field_date_ligne
|
- field.field.node.ligne_comptable.field_date_ligne
|
||||||
|
- field.field.node.ligne_comptable.field_ecart
|
||||||
|
- 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_tva
|
||||||
- field.field.node.ligne_comptable.field_type_ligne
|
- field.field.node.ligne_comptable.field_type_ligne
|
||||||
- node.type.ligne_comptable
|
- node.type.ligne_comptable
|
||||||
module:
|
module:
|
||||||
@@ -29,6 +36,27 @@ content:
|
|||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 2
|
weight: 2
|
||||||
region: content
|
region: content
|
||||||
|
field_cotisation_active:
|
||||||
|
type: boolean
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
format: default
|
||||||
|
format_custom_false: ''
|
||||||
|
format_custom_true: ''
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 4
|
||||||
|
region: content
|
||||||
|
field_cotisation_urssaf:
|
||||||
|
type: number_decimal
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
thousand_separator: ''
|
||||||
|
decimal_separator: .
|
||||||
|
scale: 2
|
||||||
|
prefix_suffix: true
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 6
|
||||||
|
region: content
|
||||||
field_date_ligne:
|
field_date_ligne:
|
||||||
type: datetime_default
|
type: datetime_default
|
||||||
label: above
|
label: above
|
||||||
@@ -38,6 +66,33 @@ content:
|
|||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 0
|
weight: 0
|
||||||
region: content
|
region: content
|
||||||
|
field_ecart:
|
||||||
|
type: number_decimal
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
thousand_separator: ''
|
||||||
|
decimal_separator: .
|
||||||
|
scale: 2
|
||||||
|
prefix_suffix: true
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 13
|
||||||
|
region: content
|
||||||
|
field_entree_liee:
|
||||||
|
type: entity_reference_label
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
link: true
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 2
|
||||||
|
region: content
|
||||||
|
field_flag:
|
||||||
|
type: entity_reference_label
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
link: true
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 12
|
||||||
|
region: content
|
||||||
field_montant_ht:
|
field_montant_ht:
|
||||||
type: number_decimal
|
type: number_decimal
|
||||||
label: above
|
label: above
|
||||||
@@ -47,7 +102,7 @@ content:
|
|||||||
scale: 2
|
scale: 2
|
||||||
prefix_suffix: true
|
prefix_suffix: true
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 3
|
weight: 5
|
||||||
region: content
|
region: content
|
||||||
field_montant_ttc:
|
field_montant_ttc:
|
||||||
type: number_decimal
|
type: number_decimal
|
||||||
@@ -58,14 +113,23 @@ content:
|
|||||||
scale: 2
|
scale: 2
|
||||||
prefix_suffix: true
|
prefix_suffix: true
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 4
|
weight: 8
|
||||||
region: content
|
region: content
|
||||||
field_notes:
|
field_notes:
|
||||||
type: basic_string
|
type: basic_string
|
||||||
label: above
|
label: above
|
||||||
settings: { }
|
settings: { }
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 6
|
weight: 11
|
||||||
|
region: content
|
||||||
|
field_numero_facture:
|
||||||
|
type: string
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
link_to_entity: false
|
||||||
|
link_rel: canonical
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 3
|
||||||
region: content
|
region: content
|
||||||
field_repartition:
|
field_repartition:
|
||||||
type: entity_reference_revisions_entity_view
|
type: entity_reference_revisions_entity_view
|
||||||
@@ -74,7 +138,18 @@ content:
|
|||||||
view_mode: default
|
view_mode: default
|
||||||
link: ''
|
link: ''
|
||||||
third_party_settings: { }
|
third_party_settings: { }
|
||||||
weight: 5
|
weight: 10
|
||||||
|
region: content
|
||||||
|
field_tva:
|
||||||
|
type: number_decimal
|
||||||
|
label: above
|
||||||
|
settings:
|
||||||
|
thousand_separator: ''
|
||||||
|
decimal_separator: .
|
||||||
|
scale: 2
|
||||||
|
prefix_suffix: true
|
||||||
|
third_party_settings: { }
|
||||||
|
weight: 9
|
||||||
region: content
|
region: content
|
||||||
field_type_ligne:
|
field_type_ligne:
|
||||||
type: list_default
|
type: list_default
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
uuid: 3defc3d6-42b6-4340-a7cf-fecadb919934
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- field.storage.node.field_cotisation_active
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
id: node.ligne_comptable.field_cotisation_active
|
||||||
|
field_name: field_cotisation_active
|
||||||
|
entity_type: node
|
||||||
|
bundle: ligne_comptable
|
||||||
|
label: 'Cotisation diffuseur URSSAF (1,1%)'
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
|
translatable: true
|
||||||
|
default_value:
|
||||||
|
-
|
||||||
|
value: 1
|
||||||
|
default_value_callback: ''
|
||||||
|
settings:
|
||||||
|
on_label: 'On'
|
||||||
|
off_label: 'Off'
|
||||||
|
field_type: boolean
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
uuid: 309c0b94-be32-42a3-b990-cb04858afb8d
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- field.storage.node.field_cotisation_urssaf
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
id: node.ligne_comptable.field_cotisation_urssaf
|
||||||
|
field_name: field_cotisation_urssaf
|
||||||
|
entity_type: node
|
||||||
|
bundle: ligne_comptable
|
||||||
|
label: '1,1%'
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
|
translatable: true
|
||||||
|
default_value: { }
|
||||||
|
default_value_callback: ''
|
||||||
|
settings:
|
||||||
|
min: null
|
||||||
|
max: null
|
||||||
|
prefix: ''
|
||||||
|
suffix: ''
|
||||||
|
field_type: decimal
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
uuid: 79bd7ee6-1e84-4edd-8065-775929c45fcb
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- field.storage.node.field_ecart
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
id: node.ligne_comptable.field_ecart
|
||||||
|
field_name: field_ecart
|
||||||
|
entity_type: node
|
||||||
|
bundle: ligne_comptable
|
||||||
|
label: Écart
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
|
translatable: true
|
||||||
|
default_value: { }
|
||||||
|
default_value_callback: ''
|
||||||
|
settings:
|
||||||
|
min: null
|
||||||
|
max: null
|
||||||
|
prefix: ''
|
||||||
|
suffix: ''
|
||||||
|
field_type: decimal
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
uuid: c2d68287-8e24-4529-a035-39395acca4d3
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- field.storage.node.field_entree_liee
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
id: node.ligne_comptable.field_entree_liee
|
||||||
|
field_name: field_entree_liee
|
||||||
|
entity_type: node
|
||||||
|
bundle: ligne_comptable
|
||||||
|
label: 'Entrée client liée'
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
|
translatable: true
|
||||||
|
default_value: { }
|
||||||
|
default_value_callback: ''
|
||||||
|
settings:
|
||||||
|
handler: 'figli_compta_ledger:entree_client'
|
||||||
|
handler_settings:
|
||||||
|
target_bundles:
|
||||||
|
ligne_comptable: ligne_comptable
|
||||||
|
field_type: entity_reference
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
uuid: 9e9fc15c-9f7b-466a-bab0-fc6fe802a22f
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- field.storage.node.field_tva
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
id: node.ligne_comptable.field_tva
|
||||||
|
field_name: field_tva
|
||||||
|
entity_type: node
|
||||||
|
bundle: ligne_comptable
|
||||||
|
label: 'TVA (%)'
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
|
translatable: true
|
||||||
|
default_value:
|
||||||
|
-
|
||||||
|
value: 10.0
|
||||||
|
default_value_callback: ''
|
||||||
|
settings:
|
||||||
|
min: null
|
||||||
|
max: null
|
||||||
|
prefix: ''
|
||||||
|
suffix: ''
|
||||||
|
field_type: decimal
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
uuid: 28c4ae5a-011c-4f31-bca5-36874c6c182b
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
module:
|
||||||
|
- node
|
||||||
|
id: node.field_cotisation_active
|
||||||
|
field_name: field_cotisation_active
|
||||||
|
entity_type: node
|
||||||
|
type: boolean
|
||||||
|
settings: { }
|
||||||
|
module: core
|
||||||
|
locked: false
|
||||||
|
cardinality: 1
|
||||||
|
translatable: true
|
||||||
|
indexes: { }
|
||||||
|
persist_with_no_fields: false
|
||||||
|
custom_storage: false
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
uuid: 486554c0-7686-4c52-ab33-eb09b5573be5
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
module:
|
||||||
|
- node
|
||||||
|
id: node.field_cotisation_urssaf
|
||||||
|
field_name: field_cotisation_urssaf
|
||||||
|
entity_type: node
|
||||||
|
type: decimal
|
||||||
|
settings:
|
||||||
|
precision: 12
|
||||||
|
scale: 2
|
||||||
|
module: core
|
||||||
|
locked: false
|
||||||
|
cardinality: 1
|
||||||
|
translatable: true
|
||||||
|
indexes: { }
|
||||||
|
persist_with_no_fields: false
|
||||||
|
custom_storage: false
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
uuid: 95ae1d5b-db18-4ebf-93a4-34d6522a32d6
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
module:
|
||||||
|
- node
|
||||||
|
id: node.field_ecart
|
||||||
|
field_name: field_ecart
|
||||||
|
entity_type: node
|
||||||
|
type: decimal
|
||||||
|
settings:
|
||||||
|
precision: 12
|
||||||
|
scale: 2
|
||||||
|
module: core
|
||||||
|
locked: false
|
||||||
|
cardinality: 1
|
||||||
|
translatable: true
|
||||||
|
indexes: { }
|
||||||
|
persist_with_no_fields: false
|
||||||
|
custom_storage: false
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
uuid: d7148451-7a96-4515-b0b5-42ad9fecbdce
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
module:
|
||||||
|
- node
|
||||||
|
id: node.field_entree_liee
|
||||||
|
field_name: field_entree_liee
|
||||||
|
entity_type: node
|
||||||
|
type: entity_reference
|
||||||
|
settings:
|
||||||
|
target_type: node
|
||||||
|
module: core
|
||||||
|
locked: false
|
||||||
|
cardinality: -1
|
||||||
|
translatable: true
|
||||||
|
indexes: { }
|
||||||
|
persist_with_no_fields: false
|
||||||
|
custom_storage: false
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
uuid: 92ad1651-ab19-4c60-ad8c-4824fd97ecc3
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
module:
|
||||||
|
- node
|
||||||
|
id: node.field_tva
|
||||||
|
field_name: field_tva
|
||||||
|
entity_type: node
|
||||||
|
type: decimal
|
||||||
|
settings:
|
||||||
|
precision: 8
|
||||||
|
scale: 4
|
||||||
|
module: core
|
||||||
|
locked: false
|
||||||
|
cardinality: 1
|
||||||
|
translatable: true
|
||||||
|
indexes: { }
|
||||||
|
persist_with_no_fields: false
|
||||||
|
custom_storage: false
|
||||||
@@ -29,6 +29,18 @@ settings:
|
|||||||
-
|
-
|
||||||
value: autre
|
value: autre
|
||||||
label: Autre
|
label: Autre
|
||||||
|
-
|
||||||
|
value: hebergement
|
||||||
|
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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ secondary_toolbar_frontend: true
|
|||||||
high_contrast_mode: false
|
high_contrast_mode: false
|
||||||
accent_color: ''
|
accent_color: ''
|
||||||
focus_color: ''
|
focus_color: ''
|
||||||
layout_density: default
|
layout_density: small
|
||||||
show_description_toggle: false
|
show_description_toggle: false
|
||||||
show_user_theme_settings: false
|
show_user_theme_settings: false
|
||||||
sticky_action_buttons: false
|
sticky_action_buttons: false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
_core:
|
_core:
|
||||||
default_config_hash: FeJ38-AShWZUh_NwJprQueefcE06zSnUa3cw1lOdjTY
|
default_config_hash: FeJ38-AShWZUh_NwJprQueefcE06zSnUa3cw1lOdjTY
|
||||||
logo:
|
logo:
|
||||||
provider: default
|
provider: hide
|
||||||
path: ''
|
path: ''
|
||||||
max:
|
max:
|
||||||
filesize: 1048576
|
filesize: 1048576
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
uuid: 581c4991-a2c0-4767-87ca-2405fb63c3a1
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- user.role.admin
|
||||||
|
module:
|
||||||
|
- user
|
||||||
|
id: user_add_role_action.admin
|
||||||
|
label: 'Add the Admin role to the selected user(s)'
|
||||||
|
type: user
|
||||||
|
plugin: user_add_role_action
|
||||||
|
configuration:
|
||||||
|
rid: admin
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
uuid: 87547109-3fbc-412f-8785-4d29926994fb
|
|
||||||
langcode: en
|
|
||||||
status: true
|
|
||||||
dependencies:
|
|
||||||
config:
|
|
||||||
- user.role.administrator
|
|
||||||
module:
|
|
||||||
- user
|
|
||||||
id: user_add_role_action.administrator
|
|
||||||
label: 'Add the Administrator role to the selected user(s)'
|
|
||||||
type: user
|
|
||||||
plugin: user_add_role_action
|
|
||||||
configuration:
|
|
||||||
rid: administrator
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
uuid: 8aac7eb3-58a1-4cd4-8e41-5822449c0717
|
|
||||||
langcode: en
|
|
||||||
status: true
|
|
||||||
dependencies:
|
|
||||||
config:
|
|
||||||
- user.role.content_editor
|
|
||||||
module:
|
|
||||||
- user
|
|
||||||
id: user_add_role_action.content_editor
|
|
||||||
label: 'Add the Content editor role to the selected user(s)'
|
|
||||||
type: user
|
|
||||||
plugin: user_add_role_action
|
|
||||||
configuration:
|
|
||||||
rid: content_editor
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
uuid: c2b7ee51-1550-4fc0-b43c-32e11e5cf00b
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- user.role.editeur
|
||||||
|
module:
|
||||||
|
- user
|
||||||
|
id: user_add_role_action.editeur
|
||||||
|
label: 'Add the Éditeur role to the selected user(s)'
|
||||||
|
type: user
|
||||||
|
plugin: user_add_role_action
|
||||||
|
configuration:
|
||||||
|
rid: editeur
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
uuid: 24f21f8a-6568-46c9-84ee-d7480458882a
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- user.role.user
|
||||||
|
module:
|
||||||
|
- user
|
||||||
|
id: user_add_role_action.user
|
||||||
|
label: 'Add the Utilisateur (lecture seule) role to the selected user(s)'
|
||||||
|
type: user
|
||||||
|
plugin: user_add_role_action
|
||||||
|
configuration:
|
||||||
|
rid: user
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
uuid: cdf63fcb-1d6a-46f8-baaf-1aad7dea8199
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- user.role.admin
|
||||||
|
module:
|
||||||
|
- user
|
||||||
|
id: user_remove_role_action.admin
|
||||||
|
label: 'Remove the Admin role from the selected user(s)'
|
||||||
|
type: user
|
||||||
|
plugin: user_remove_role_action
|
||||||
|
configuration:
|
||||||
|
rid: admin
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
uuid: cf27d858-625c-4fd0-a566-e77e97ff6f03
|
|
||||||
langcode: en
|
|
||||||
status: true
|
|
||||||
dependencies:
|
|
||||||
config:
|
|
||||||
- user.role.administrator
|
|
||||||
module:
|
|
||||||
- user
|
|
||||||
id: user_remove_role_action.administrator
|
|
||||||
label: 'Remove the Administrator role from the selected user(s)'
|
|
||||||
type: user
|
|
||||||
plugin: user_remove_role_action
|
|
||||||
configuration:
|
|
||||||
rid: administrator
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
uuid: c03c3800-42b0-4ccf-b440-e97fbf345174
|
|
||||||
langcode: en
|
|
||||||
status: true
|
|
||||||
dependencies:
|
|
||||||
config:
|
|
||||||
- user.role.content_editor
|
|
||||||
module:
|
|
||||||
- user
|
|
||||||
id: user_remove_role_action.content_editor
|
|
||||||
label: 'Remove the Content editor role from the selected user(s)'
|
|
||||||
type: user
|
|
||||||
plugin: user_remove_role_action
|
|
||||||
configuration:
|
|
||||||
rid: content_editor
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
uuid: d14de2f6-99a6-4c9a-8eed-c95f83b56607
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- user.role.editeur
|
||||||
|
module:
|
||||||
|
- user
|
||||||
|
id: user_remove_role_action.editeur
|
||||||
|
label: 'Remove the Éditeur role from the selected user(s)'
|
||||||
|
type: user
|
||||||
|
plugin: user_remove_role_action
|
||||||
|
configuration:
|
||||||
|
rid: editeur
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
uuid: 7198a84f-b1a1-4eae-adf4-f3a9de6cd79f
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- user.role.user
|
||||||
|
module:
|
||||||
|
- user
|
||||||
|
id: user_remove_role_action.user
|
||||||
|
label: 'Remove the Utilisateur (lecture seule) role from the selected user(s)'
|
||||||
|
type: user
|
||||||
|
plugin: user_remove_role_action
|
||||||
|
configuration:
|
||||||
|
rid: user
|
||||||
@@ -6,7 +6,7 @@ name: 'Figures Libres - Compta'
|
|||||||
mail: admin@example.com
|
mail: admin@example.com
|
||||||
slogan: ''
|
slogan: ''
|
||||||
page:
|
page:
|
||||||
403: ''
|
403: /user/login
|
||||||
404: ''
|
404: ''
|
||||||
front: /lignes
|
front: /lignes
|
||||||
admin_compact_mode: false
|
admin_compact_mode: 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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
uuid: 45d15bb6-7222-481f-8306-58f0d207fa97
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
module:
|
||||||
|
- figli_compta_ledger
|
||||||
|
- node
|
||||||
|
- system
|
||||||
|
id: admin
|
||||||
|
label: Admin
|
||||||
|
weight: 6
|
||||||
|
is_admin: false
|
||||||
|
permissions:
|
||||||
|
- 'access content'
|
||||||
|
- 'access figli ledger'
|
||||||
|
- 'create ligne_comptable content'
|
||||||
|
- 'delete any ligne_comptable content'
|
||||||
|
- 'edit any ligne_comptable content'
|
||||||
|
- 'import ligne_comptable releve'
|
||||||
|
- 'view ligne_comptable revisions'
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
uuid: 6af2113b-ad25-4982-bc8f-360b9af97998
|
|
||||||
langcode: en
|
|
||||||
status: true
|
|
||||||
dependencies: { }
|
|
||||||
_core:
|
|
||||||
default_config_hash: OeKGIkmZA_c-t6QLH81WNQx8gDCc1MRmxaTuQgxBByU
|
|
||||||
id: administrator
|
|
||||||
label: Administrator
|
|
||||||
weight: 3
|
|
||||||
is_admin: true
|
|
||||||
permissions: { }
|
|
||||||
@@ -6,7 +6,6 @@ dependencies:
|
|||||||
- filter.format.restricted_html
|
- filter.format.restricted_html
|
||||||
module:
|
module:
|
||||||
- filter
|
- filter
|
||||||
- system
|
|
||||||
_core:
|
_core:
|
||||||
default_config_hash: Tus1ZTNXKIOnrennR1hnTbc7wUPBXHTUm5UdCpEhJSk
|
default_config_hash: Tus1ZTNXKIOnrennR1hnTbc7wUPBXHTUm5UdCpEhJSk
|
||||||
id: anonymous
|
id: anonymous
|
||||||
@@ -14,5 +13,4 @@ label: 'Anonymous user'
|
|||||||
weight: 0
|
weight: 0
|
||||||
is_admin: false
|
is_admin: false
|
||||||
permissions:
|
permissions:
|
||||||
- 'access content'
|
|
||||||
- 'use text format restricted_html'
|
- 'use text format restricted_html'
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ dependencies:
|
|||||||
module:
|
module:
|
||||||
- file
|
- file
|
||||||
- filter
|
- filter
|
||||||
- system
|
|
||||||
_core:
|
_core:
|
||||||
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
|
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
|
||||||
id: authenticated
|
id: authenticated
|
||||||
@@ -15,6 +14,5 @@ label: 'Authenticated user'
|
|||||||
weight: 1
|
weight: 1
|
||||||
is_admin: false
|
is_admin: false
|
||||||
permissions:
|
permissions:
|
||||||
- 'access content'
|
|
||||||
- 'delete own files'
|
- 'delete own files'
|
||||||
- 'use text format basic_html'
|
- 'use text format basic_html'
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
uuid: 6e5d280b-fa39-470d-a3db-7ac6d47d5c2a
|
|
||||||
langcode: en
|
|
||||||
status: true
|
|
||||||
dependencies:
|
|
||||||
config:
|
|
||||||
- taxonomy.vocabulary.tags
|
|
||||||
module:
|
|
||||||
- contextual
|
|
||||||
- file
|
|
||||||
- navigation
|
|
||||||
- node
|
|
||||||
- path
|
|
||||||
- system
|
|
||||||
- taxonomy
|
|
||||||
_core:
|
|
||||||
default_config_hash: og55Zu0F-q-X8zG0Ohrls49zws6IrmmosZS-30Es8vw
|
|
||||||
id: content_editor
|
|
||||||
label: 'Content editor'
|
|
||||||
weight: 2
|
|
||||||
is_admin: false
|
|
||||||
permissions:
|
|
||||||
- 'access administration pages'
|
|
||||||
- 'access content overview'
|
|
||||||
- 'access contextual links'
|
|
||||||
- 'access files overview'
|
|
||||||
- 'access navigation'
|
|
||||||
- 'administer url aliases'
|
|
||||||
- 'create terms in tags'
|
|
||||||
- 'create url aliases'
|
|
||||||
- 'delete own files'
|
|
||||||
- 'edit terms in tags'
|
|
||||||
- 'revert all revisions'
|
|
||||||
- 'view all revisions'
|
|
||||||
- 'view own unpublished content'
|
|
||||||
- 'view the administration theme'
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
uuid: ab5bea78-990d-4ca0-af28-d209c60a248e
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
module:
|
||||||
|
- figli_compta_ledger
|
||||||
|
- node
|
||||||
|
- system
|
||||||
|
id: editeur
|
||||||
|
label: Éditeur
|
||||||
|
weight: 5
|
||||||
|
is_admin: false
|
||||||
|
permissions:
|
||||||
|
- 'access content'
|
||||||
|
- 'access figli ledger'
|
||||||
|
- 'create ligne_comptable content'
|
||||||
|
- 'delete any ligne_comptable content'
|
||||||
|
- 'edit any ligne_comptable content'
|
||||||
|
- 'import ligne_comptable releve'
|
||||||
|
- 'view ligne_comptable revisions'
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
uuid: 24f4e46d-c714-486d-b000-f092eb727470
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies:
|
||||||
|
config:
|
||||||
|
- node.type.ligne_comptable
|
||||||
|
module:
|
||||||
|
- figli_compta_ledger
|
||||||
|
- node
|
||||||
|
- system
|
||||||
|
id: user
|
||||||
|
label: 'Utilisateur (lecture seule)'
|
||||||
|
weight: 4
|
||||||
|
is_admin: false
|
||||||
|
permissions:
|
||||||
|
- 'access content'
|
||||||
|
- 'access figli ledger'
|
||||||
|
- 'view ligne_comptable revisions'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# EntreeClientSelection only adds query conditions on top of NodeSelection
|
||||||
|
# (itself a DefaultSelection subclass) -- no new handler_settings keys, so
|
||||||
|
# it uses the same schema shape as the core 'default' handler.
|
||||||
|
entity_reference_selection.figli_compta_ledger:
|
||||||
|
type: entity_reference_selection.default
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/* Grand livre / Dashboard switcher, top-right of both pages. Only present
|
||||||
|
on those two custom routes, but attached here rather than duplicated
|
||||||
|
between home.css and dashboard.css -- and defined with its own explicit
|
||||||
|
colors rather than relying on either page's #figli-*-app-scoped custom
|
||||||
|
properties, since this <nav> sits outside both of those elements.
|
||||||
|
|
||||||
|
Fixed rather than in normal flow: this <nav> and the page's <h1>
|
||||||
|
("Grand livre - SAS Figures Libres" etc.) come from two different
|
||||||
|
Drupal block regions (the title from Gin's sticky top-bar header,
|
||||||
|
this nav from the page content below it) with no shared flex/grid
|
||||||
|
container to align them side by side declaratively -- in normal flow
|
||||||
|
it rendered as its own full-width row *under* the title, costing
|
||||||
|
~75px of vertical space the table below could otherwise use before
|
||||||
|
the whole page (not just the table's own internal scroll) needed to
|
||||||
|
scroll too. Pulling it out of flow and pinning it next to the title
|
||||||
|
reclaims that space; top/right below are tuned to that title's actual
|
||||||
|
position, not derived from it, so they'd need revisiting if Gin's
|
||||||
|
header height or padding ever changes. */
|
||||||
|
.figli-page-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.65rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Lier à une entrée client" modal (LinkEntreeForm): one autocomplete
|
||||||
|
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.
|
||||||
|
z-index 100000: the region is lifted to a direct <body> child by
|
||||||
|
admin-chrome.js (Gin's layout stacking contexts would otherwise bury
|
||||||
|
it under the modal overlay), and 100000 puts it above the jQuery UI
|
||||||
|
dialog itself (~100, .ui-front) and its overlay (dialog - 1) -- and
|
||||||
|
unreachable: jQuery UI only ever raises a dialog above .ui-front
|
||||||
|
siblings (_moveToTop), which the messages wrapper is not. Messages
|
||||||
|
stay readable on top of everything while a modal is open. */
|
||||||
|
[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: 100000;
|
||||||
|
}
|
||||||
|
[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,467 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Total line under an h-bar-chart (currently just Charges structurelles
|
||||||
|
par client, main chart and each per-année card) -- separate from the
|
||||||
|
chart component itself, plain right-aligned text matching
|
||||||
|
.figli-hbar-value's alignment/tabular-nums so the total lines up
|
||||||
|
visually with the bars' own value column above it. */
|
||||||
|
#figli-dashboard-app .figli-chart-total {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--figli-border);
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
#figli-dashboard-app .figli-chart-total.is-compact {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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,47 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gin's default 80px margin-bottom on <main>, meant as breathing room
|
||||||
|
below a normal admin content page -- on this app's tight,
|
||||||
|
single-viewport layout (page nav pinned next to the title, table
|
||||||
|
scrolling internally, see .figli-page-nav in admin-chrome.css) it was
|
||||||
|
the last remaining chunk of an *outer* page scroll on top of the
|
||||||
|
table's own, since max-height: 75vh on .figli-table-wrap already
|
||||||
|
accounts for everything else above/below it but not this. */
|
||||||
|
main.page-content {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gin's default 48px left/right margin on .layout-container -- fine for
|
||||||
|
a normal admin content page with a narrower reading measure, but this
|
||||||
|
app's table wants the width. Cut to a third (16px). */
|
||||||
|
.layout-container {
|
||||||
|
margin-left: 16px !important;
|
||||||
|
margin-right: 16px !important;
|
||||||
|
}
|
||||||
@@ -9,9 +9,15 @@
|
|||||||
--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);
|
||||||
|
|
||||||
font-family: Inter, -apple-system, sans-serif;
|
font-family: Inter, -apple-system, sans-serif;
|
||||||
margin: 1rem 0;
|
/* Bottom cut to a third (top left alone) -- with the outer page
|
||||||
|
scroll already eliminated (see hide-admin-chrome.css's main.page-
|
||||||
|
content override), the full 1rem below the table was just dead
|
||||||
|
space past the last visible row. */
|
||||||
|
margin: 1rem 0 0.33rem;
|
||||||
color: var(--figli-text);
|
color: var(--figli-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +29,8 @@ 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-home-app .figli-toolbar {
|
#figli-home-app .figli-toolbar {
|
||||||
@@ -56,6 +64,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;
|
||||||
@@ -70,24 +163,57 @@ 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;
|
/* Not a flat 75vh -- title/nav + toolbar (with its filter row) above
|
||||||
|
this add up to a roughly fixed ~16rem regardless of viewport
|
||||||
|
height, so a percentage-of-viewport max-height only fits by
|
||||||
|
coincidence at one particular window size and silently reintroduces
|
||||||
|
the outer page scroll (on top of this element's own) at any other,
|
||||||
|
as toolbar filters were added over time and pushed that fixed part
|
||||||
|
past what 75vh left room for. Subtracting the fixed part directly
|
||||||
|
keeps this sized to exactly the remaining space instead. */
|
||||||
|
max-height: calc(100vh - 16rem);
|
||||||
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);
|
||||||
@@ -112,10 +238,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 {
|
||||||
@@ -130,6 +262,10 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
#figli-home-app tfoot {
|
#figli-home-app tfoot {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
/* Higher than thead (2) and the sticky group-row headers (1) -- without
|
||||||
|
this, tfoot had no explicit z-index (auto), so a group-row header
|
||||||
|
scrolling underneath it painted on top instead of behind. */
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
#figli-home-app tr.figli-totals-row td {
|
#figli-home-app tr.figli-totals-row td {
|
||||||
@@ -140,30 +276,99 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "+ Ajouter une ligne" + "Importer un relevé" live in the sticky footer
|
||||||
|
now (they used to lead the toolbar and crowd its filter row). Compact
|
||||||
|
overrides for Gin's .button, which is sized for full admin forms --
|
||||||
|
way too big inside a dense totals row. Size-only overrides (no
|
||||||
|
colors): Gin's own light/dark button palettes keep applying. */
|
||||||
|
#figli-home-app tr.figli-totals-row .button {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0.4rem 0 0;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
#figli-home-app td.figli-solde-crediteur {
|
#figli-home-app td.figli-solde-crediteur {
|
||||||
color: var(--figli-positive);
|
color: var(--figli-positive);
|
||||||
}
|
}
|
||||||
#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 +
|
||||||
/* Error rows: a thin red outline around the row, not a background fill --
|
td beats a lone td.class) which otherwise overrides the solde colors
|
||||||
easier to read, doesn't fight with dark mode. */
|
above -- repeat them scoped to the row so red/green still wins there. */
|
||||||
#figli-home-app tr.figli-error-row td {
|
#figli-home-app tr.figli-totals-row td.figli-solde-crediteur {
|
||||||
border-top: 1px solid var(--figli-error);
|
color: var(--figli-positive);
|
||||||
border-bottom: 1px solid var(--figli-error);
|
|
||||||
}
|
}
|
||||||
#figli-home-app tr.figli-error-row td:first-child {
|
#figli-home-app tr.figli-totals-row td.figli-solde-debiteur {
|
||||||
border-left: 1px solid var(--figli-error);
|
color: var(--figli-error);
|
||||||
}
|
|
||||||
#figli-home-app tr.figli-error-row td:last-child {
|
|
||||||
border-right: 1px solid var(--figli-error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Single colored + bold anchor per row (Montant HT only) so entrées vs
|
||||||
|
sorties are scannable at a glance -- everything else in the row (TTC,
|
||||||
|
the 8 compte columns) stays neutral, not a red/green garland. */
|
||||||
|
#figli-home-app td.figli-montant-positif {
|
||||||
|
color: var(--figli-positive);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
#figli-home-app td.figli-montant-negatif {
|
||||||
|
color: var(--figli-error);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Écart is called out by the Écart column itself (bold, red or green
|
||||||
|
depending on sign -- see below) -- no separate row-level treatment
|
||||||
|
needed. Répartition > Montant HT (positive écart) means the accounts
|
||||||
|
were credited more than what's actually owed for this line, the same
|
||||||
|
"more money than expected" sense .figli-montant-positif already
|
||||||
|
marks green elsewhere; the reverse (negative) stays the usual
|
||||||
|
red/error color. */
|
||||||
#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;
|
||||||
}
|
}
|
||||||
|
#figli-home-app td.figli-ecart-positif {
|
||||||
|
color: var(--figli-positive);
|
||||||
|
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);
|
||||||
|
/* The Signalement column is narrow (6%) -- a badge must wrap inside
|
||||||
|
the cell instead of overflowing into the neighboring column. The
|
||||||
|
cell itself already allows wrapping (.figli-flag-cell); this makes
|
||||||
|
the badge wrap too, including single long tokens (anywhere) and
|
||||||
|
within its own padding box (max-width + border-box). */
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
whatever the cell already has -- sticky header/footer backgrounds,
|
||||||
|
error-row outlines -- instead of overwriting them. */
|
||||||
|
#figli-home-app td.figli-col-hover,
|
||||||
|
#figli-home-app th.figli-col-hover {
|
||||||
|
box-shadow: inset 0 0 0 9999px var(--figli-col-hover);
|
||||||
|
}
|
||||||
|
|
||||||
#figli-home-app .figli-badge {
|
#figli-home-app .figli-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -177,15 +382,372 @@ html.gin--dark-mode #figli-home-app {
|
|||||||
#figli-home-app .type-charge { background: #6b72801a; color: var(--figli-text-light); }
|
#figli-home-app .type-charge { background: #6b72801a; color: var(--figli-text-light); }
|
||||||
#figli-home-app .type-versement { background: #d97a0a1a; color: #d97a0a; }
|
#figli-home-app .type-versement { background: #d97a0a1a; color: #d97a0a; }
|
||||||
#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-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;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One column per icon (link, edit) rather than one shared cell -- with a
|
||||||
|
shared cell, text-align: center puts a lone button (rows with no link
|
||||||
|
icon) in a different spot than a row with both, so the edit pencil
|
||||||
|
never lined up from row to row. Separate columns line up by
|
||||||
|
construction, and get a tight, icon-sized width instead of the
|
||||||
|
table's default 0.6rem horizontal cell padding. */
|
||||||
|
#figli-home-app td.actions-col,
|
||||||
|
#figli-home-app th.actions-col {
|
||||||
|
width: 3%;
|
||||||
|
padding: 0.2rem 0.1rem;
|
||||||
|
text-align: center;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
/* HT/TTC narrower than the general .amount rule above (still used by
|
||||||
|
Écart) -- shorter header text (HT/TTC vs. the old Montant HT/Montant
|
||||||
|
TTC) and TVA splitting the row between them means neither needs as
|
||||||
|
much room. TVA itself narrower still: "12,64 %" is the longest
|
||||||
|
realistic value (see figli_compta_ledger_update_8005()'s docblock on
|
||||||
|
backfilled non-round rates), well short of an 8-figure amount.
|
||||||
|
`.amount` doubled up in the selector (not just .figli-ht-col alone):
|
||||||
|
:not(.compte-col) in the rule above counts toward specificity same as
|
||||||
|
a real class, so a single-class selector here would lose to it. */
|
||||||
|
#figli-home-app .amount.figli-ht-col,
|
||||||
|
#figli-home-app .amount.figli-ttc-col {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
/* Cotisation diffuseur URSSAF (1,1%) -- blank for every non-"Entrée
|
||||||
|
client" row (see figli_compta_ledger_node_presave()), same width
|
||||||
|
budget as HT/TTC since it's a real € amount, not a short rate like
|
||||||
|
TVA. */
|
||||||
|
#figli-home-app .amount.figli-cotisation-col {
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
#figli-home-app .amount.figli-tva-col {
|
||||||
|
width: 3.5%;
|
||||||
|
}
|
||||||
|
/* Non-official TVA rate marker -- a thick red underline on the cell
|
||||||
|
itself rather than a small badge/dot (tried first, not visible
|
||||||
|
enough): the column is only ~3.5% wide, barely enough for
|
||||||
|
"12,64 %" itself, so a border under the whole cell reads clearly at
|
||||||
|
a glance without needing extra horizontal room a text/icon badge
|
||||||
|
would need. Tooltip (title attribute in the twig template) carries
|
||||||
|
the actual explanation. */
|
||||||
|
#figli-home-app td.figli-tva-nonofficial {
|
||||||
|
border-bottom: 3px solid var(--figli-error);
|
||||||
|
}
|
||||||
|
#figli-home-app .amount.compte-col {
|
||||||
|
width: 4.3%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons stay inline (not flex) inside the <td> -- display: flex on a
|
||||||
|
table cell breaks the table's own column-width calculation (observed:
|
||||||
|
a ~55px gap opening up between this column and the next, and the
|
||||||
|
buttons rendering outside their cell's actual boundary). */
|
||||||
|
#figli-home-app .figli-edit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--figli-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-edit-btn:hover {
|
||||||
|
background: var(--figli-bg-alt);
|
||||||
|
border-color: var(--figli-border);
|
||||||
|
color: var(--figli-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reconciliation badge on entrée rows: répartition (money owed) vs the
|
||||||
|
combined répartition of every linked sortie (money actually paid out). */
|
||||||
|
#figli-home-app .figli-recon-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #1a7f371a;
|
||||||
|
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 {
|
||||||
|
background: #1665341a;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
#figli-home-app .figli-recon-badge.is-anomalie {
|
||||||
|
background: #c9312b1a;
|
||||||
|
color: var(--figli-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#figli-home-app .figli-clear-drilldown {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
@@ -193,3 +755,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
/* Ligne comptable add/edit form (node/add/ligne_comptable, node/X/edit --
|
||||||
|
usually seen inside the "+ Ajouter une ligne" / "Modifier" AJAX modal
|
||||||
|
from /lignes, see js/home.js's openAddForm/openEditForm). The default
|
||||||
|
Drupal/Gin admin form widgets (tabledrag drag handles, the paragraphs
|
||||||
|
"Toggle Actions" dropdown, vertical tabs rendered as an accordion) are
|
||||||
|
built for content editors managing arbitrary nodes, not for the small
|
||||||
|
numeric entry form associates fill in daily. This only restyles it
|
||||||
|
into a compact grid -- no Form API behavior, validation, or field
|
||||||
|
structure changes here (those live in figli_compta_ledger.module,
|
||||||
|
which also hides the "advanced" accordion and "Published" checkbox
|
||||||
|
entirely rather than just visually, since neither is ever used for a
|
||||||
|
ligne_comptable).
|
||||||
|
|
||||||
|
Explicit local colors rather than Gin's --gin-* custom properties --
|
||||||
|
this form isn't scoped inside #figli-home-app/#figli-dashboard-app, so
|
||||||
|
the same "don't trust Gin's vars on custom routes" lesson from
|
||||||
|
home.css/dashboard.css applies here too. */
|
||||||
|
.figli-ledger-form {
|
||||||
|
--flform-border: #dcdee2;
|
||||||
|
--flform-border-soft: #e8eaed;
|
||||||
|
--flform-label: #4b5563;
|
||||||
|
--flform-text: #1a1a1a;
|
||||||
|
--flform-bg: #ffffff;
|
||||||
|
--flform-bg-subtle: #f7f8fa;
|
||||||
|
--flform-accent: #2f6f4f;
|
||||||
|
--flform-danger: #b3261e;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.15rem 0.1rem 0.5rem;
|
||||||
|
color: var(--flform-text);
|
||||||
|
}
|
||||||
|
/* Claro gives every .form-item a large margin-block (1.5rem, meant for a
|
||||||
|
form with one field per row) -- with our own grid `gap` now doing that
|
||||||
|
job, this just doubles up the spacing between rows. */
|
||||||
|
.figli-ledger-form .form-item {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
html.gin--dark-mode .figli-ledger-form {
|
||||||
|
--flform-border: #3d3e42;
|
||||||
|
--flform-border-soft: #333438;
|
||||||
|
--flform-label: #a1a5ab;
|
||||||
|
--flform-text: #e8e9ea;
|
||||||
|
--flform-bg: #3a3b40;
|
||||||
|
--flform-bg-subtle: #2a2b2e;
|
||||||
|
--flform-accent: #5fb98a;
|
||||||
|
--flform-danger: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper divs Drupal still renders even though they're always empty
|
||||||
|
here (single-language site, path aliases handled elsewhere, status
|
||||||
|
hidden via #access in the module) -- without this they'd each still
|
||||||
|
claim an empty grid cell. */
|
||||||
|
.figli-ledger-form > .field--name-langcode,
|
||||||
|
.figli-ledger-form > .field--name-path,
|
||||||
|
.figli-ledger-form > .entity-content-form-footer,
|
||||||
|
.figli-ledger-form > .form-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row layout, fully explicit (not left to grid auto-placement) -- with
|
||||||
|
Cotisation now in the mix there are three separate row groupings
|
||||||
|
(Client + N° Facture, the checkbox on its own full-width row, then
|
||||||
|
Montant HT + Cotisation + TVA + Montant TTC as a clean four-column
|
||||||
|
row) and relying on DOM order alone to keep them from bleeding into
|
||||||
|
each other got fragile once that many financial fields entered the
|
||||||
|
picture. This must stay in sync with the field weights set in
|
||||||
|
figli_compta_ledger_update_8006()/_figli_compta_ledger_create_node_type_ligne_comptable(). */
|
||||||
|
.figli-ledger-form > .field--name-title,
|
||||||
|
.figli-ledger-form > .field--name-field-entree-liee,
|
||||||
|
.figli-ledger-form > .field--name-field-cotisation-active,
|
||||||
|
.figli-ledger-form > .field--name-field-tva,
|
||||||
|
.figli-ledger-form > .field--name-field-repartition,
|
||||||
|
.figli-ledger-form > .field--name-field-notes,
|
||||||
|
.figli-ledger-form > .field--name-field-flag {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-date-ligne {
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-type-ligne {
|
||||||
|
grid-column: 2 / 5;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-client {
|
||||||
|
grid-column: 1 / 4;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-numero-facture {
|
||||||
|
grid-column: 4 / 5;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-montant-ht {
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-cotisation-urssaf {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
}
|
||||||
|
/* The field_tva_rate select (added in figli_compta_ledger_form_alter(),
|
||||||
|
not a real field so it gets no field--name-* class) fills the third
|
||||||
|
slot of the HT/Cotisation/TVA/TTC row -- see that function's #weight
|
||||||
|
comment for why this specific ordering (weight 7, between Cotisation's
|
||||||
|
6 and TTC's 8) matters, not just matching column numbers. field_tva
|
||||||
|
itself (grouped with the other always-full-width fields above) only
|
||||||
|
appears -- full width, its own row -- when "Autre" is picked. */
|
||||||
|
.figli-ledger-form > .js-form-item-field-tva-rate {
|
||||||
|
grid-column: 3 / 4;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-montant-ttc {
|
||||||
|
grid-column: 4 / 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Montant relevé bancaire (field_montant_releve): the import's
|
||||||
|
immutable bank reference, displayed as plain text -- the widget is
|
||||||
|
replaced by a #type => 'item' in figli_compta_ledger_form_alter(),
|
||||||
|
no input box at all. Under Montant TTC in the same column so the two
|
||||||
|
amounts compare at a glance while sorting an imported line; the
|
||||||
|
"réf." label marker + tabular digits carry the "value you look at,
|
||||||
|
not one you type" convention. Hidden entirely on lines with no bank
|
||||||
|
amount (manually entered ones). */
|
||||||
|
.figli-ledger-form > .field--name-field-montant-releve {
|
||||||
|
grid-column: 4 / 5;
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-montant-releve label::after {
|
||||||
|
content: " · réf.";
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--flform-label);
|
||||||
|
}
|
||||||
|
.figli-ledger-form > .field--name-field-montant-releve .figli-releve-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field basics */
|
||||||
|
.figli-ledger-form .form-item__label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--flform-label);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .form-item__description {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--flform-label);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form input.form-element,
|
||||||
|
.figli-ledger-form select.form-element,
|
||||||
|
.figli-ledger-form textarea.form-element {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--flform-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.32rem 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--flform-text);
|
||||||
|
}
|
||||||
|
/* Not select: Claro positions its dropdown-chevron background-image via
|
||||||
|
background-position/-repeat/-size, which the `background` shorthand
|
||||||
|
below would silently reset to their initial values (top-left,
|
||||||
|
repeating) -- that turned the single right-aligned arrow into a tiled
|
||||||
|
pattern smeared across the whole field. Selects keep Claro's own
|
||||||
|
background untouched; only the border/radius/padding above apply. */
|
||||||
|
.figli-ledger-form input.form-element,
|
||||||
|
.figli-ledger-form textarea.form-element {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.figli-ledger-form input.form-element:focus,
|
||||||
|
.figli-ledger-form select.form-element:focus,
|
||||||
|
.figli-ledger-form textarea.form-element:focus {
|
||||||
|
outline: 2px solid var(--flform-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* Montant TTC: #disabled, computed from HT + TVA (see
|
||||||
|
figli_compta_ledger_node_presave()) -- a faint fill and full opacity
|
||||||
|
(overriding the browser's default half-transparent disabled look,
|
||||||
|
which would make the computed number harder to read) instead of the
|
||||||
|
plain white/transparent of an editable field. -webkit-text-fill-color
|
||||||
|
specifically: Chromium renders disabled/autofilled input text through
|
||||||
|
this property instead of `color` -- left alone, it defaults to a
|
||||||
|
fixed grey that's illegible against either theme's --flform-bg-subtle
|
||||||
|
(near-invisible dark-grey-on-dark-grey in dark mode, the actual bug
|
||||||
|
reported; same risk in light mode against a light fill). */
|
||||||
|
.figli-ledger-form input:disabled.form-element {
|
||||||
|
background: var(--flform-bg-subtle);
|
||||||
|
color: var(--flform-text);
|
||||||
|
-webkit-text-fill-color: var(--flform-text);
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.figli-ledger-form input[type="date"].form-element {
|
||||||
|
width: auto;
|
||||||
|
min-width: 9.5rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form textarea.form-element {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-value tables (Répartition, Entrée client liée): drop the drag
|
||||||
|
handle and the "Show row weights" accessibility toggle for it -- row
|
||||||
|
order never affects the répartition sum or anything displayed
|
||||||
|
downstream, so with the usual 1-3 rows this is pure clutter. Remove
|
||||||
|
isn't affected: it stays in the per-row "Toggle Actions" menu. */
|
||||||
|
.figli-ledger-form .tabledrag-toggle-weight-wrapper,
|
||||||
|
.figli-ledger-form td.field-multiple-drag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.figli-ledger-form table.field-multiple-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--flform-bg-subtle);
|
||||||
|
border: 1px solid var(--flform-border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.figli-ledger-form table.field-multiple-table thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.3rem 0.5rem 0.2rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form table.field-multiple-table tbody td {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
vertical-align: top;
|
||||||
|
border-top: 1px solid var(--flform-border-soft);
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field-multiple-table .form-item__label {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
/* Réserve le coin haut-droit (bouton Remove du paragraph-top, en
|
||||||
|
position:absolute) : les inputs ne passent plus dessous et
|
||||||
|
rétrécissent d'autant. */
|
||||||
|
padding-right: 4.4rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform > .js-form-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Le Compte (nom, souvent long) prend une part plus large que le
|
||||||
|
Montant (chiffre court) -- tous deux plus étroits qu'avant, la zone
|
||||||
|
du Remove étant réservée ci-dessus. */
|
||||||
|
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-compte {
|
||||||
|
flex: 1.6;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-montant {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons: "Ajouter Répartition" / "Add another item", and the per-row
|
||||||
|
Remove/Duplicate/Collapse actions -- all plain Drupal form-submit
|
||||||
|
buttons by default, restyled as a small, consistent set instead of
|
||||||
|
browser-default grey buttons of varying sizes. */
|
||||||
|
.figli-ledger-form input.button {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--flform-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--flform-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.figli-ledger-form input.button:hover {
|
||||||
|
border-color: var(--flform-accent);
|
||||||
|
color: var(--flform-accent);
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field-add-more-submit {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-dropdown-toggle {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--flform-label);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-dropdown-toggle:hover {
|
||||||
|
color: var(--flform-accent);
|
||||||
|
}
|
||||||
|
/* Closed by default (Claro's own paragraphs.actions.css already sets
|
||||||
|
display: none here and toggles a sibling .open class on click via
|
||||||
|
paragraphs.actions.js) -- only restyle the panel's look, and only once
|
||||||
|
Drupal's own JS has actually opened it. Getting this wrong (e.g.
|
||||||
|
forcing `display: flex` unconditionally here) makes every "Toggle
|
||||||
|
Actions" menu render permanently expanded. */
|
||||||
|
.figli-ledger-form .paragraphs-dropdown.open .paragraphs-dropdown-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border: 1px solid var(--flform-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--flform-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field group heading style (the Répartition table's own label, inside
|
||||||
|
its <th>) -- match the rest of the form's label typography instead of
|
||||||
|
Drupal's default <h4>. */
|
||||||
|
.figli-ledger-form table.field-multiple-table h4.form-item__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==== Répartition : compactage du widget (une ligne = Compte + Montant,
|
||||||
|
rien d'autre) + assist (PLAN-repartition-assistee.md) ==== */
|
||||||
|
|
||||||
|
/* Une ligne de répartition ne doit montrer que ses deux champs. Le
|
||||||
|
titre "Répartition" de chaque ligne est redondant (le titre du champ
|
||||||
|
est déjà en thead), le bouton Collapse n'a pas de sens (edit_mode:
|
||||||
|
open, les lignes sont toujours ouvertes -- et le CSS les masque de
|
||||||
|
toute façon côté JS), et paragraph-info/paragraph-summary sont rendus
|
||||||
|
vides pour ce bundle : chacun coûtait une ligne de bruit vertical. */
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-type,
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-info,
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-summary,
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraphs-icon-button-collapse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Le "paragraph-top" ne porte plus que le menu d'actions de la ligne
|
||||||
|
(Remove) : en ABSOLU dans le coin haut-droit de la ligne (td en
|
||||||
|
position:relative), aligné sur le padding horizontal de la cellule --
|
||||||
|
il n'ajoute donc AUCUNE hauteur, la ligne se réduit à ses deux
|
||||||
|
champs. */
|
||||||
|
.figli-ledger-form .field--name-field-repartition tr.paragraph-type--repartition > td {
|
||||||
|
position: relative;
|
||||||
|
/* Aucun padding vertical sur les lignes de répartition (demande
|
||||||
|
explicite) : la hauteur de ligne vient uniquement des champs, le
|
||||||
|
bordure-top reste comme séparateur. */
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-top {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.15rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition .paragraph-top .paragraphs-actions {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colonne "Order" (poids des lignes) des tables multi-valeurs : l'ordre
|
||||||
|
ne compte jamais ici (cf. handles de drag déjà masqués plus haut),
|
||||||
|
deux champs de largeur récupérés. */
|
||||||
|
.figli-ledger-form .field-multiple-table thead th:last-child,
|
||||||
|
.figli-ledger-form .field-multiple-table td.delta-order {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu "Toggle Actions" du thead : il porte le bouton "Collapse / Edit
|
||||||
|
all" (feature rétablie par update_8016) -- seul le mode "Drag & drop"
|
||||||
|
y est masqué, un réordonnancement sans objet (handles masqués plus
|
||||||
|
haut). Le menu par ligne (Remove) est hors de ce sélecteur. */
|
||||||
|
.figli-ledger-form .field--name-field-repartition thead input[name="field_repartition_dragdrop_mode"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Barre de titre du widget : titre "Répartition" À GAUCHE, écart AU
|
||||||
|
MILIEU, menu trois-points À DROITE (l'ordre DOM est titre, actions,
|
||||||
|
puis l'écart injecté par JS, d'où les order). */
|
||||||
|
.figli-ledger-form .field--name-field-repartition th.field-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition th.field-label h4 {
|
||||||
|
order: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart {
|
||||||
|
order: 1;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--flform-label);
|
||||||
|
}
|
||||||
|
.figli-ledger-form .field--name-field-repartition th.field-label .paragraphs-actions {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart-val {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart.is-ok .figli-repartition-ecart-val {
|
||||||
|
color: var(--flform-accent);
|
||||||
|
}
|
||||||
|
.figli-ledger-form .figli-repartition-ecart.is-ko .figli-repartition-ecart-val {
|
||||||
|
color: var(--flform-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Une ligne par répartition : "Compte [input] Montant (€) [input]".
|
||||||
|
Le piège : le form-item de Compte n'a PAS l'input pour enfant direct
|
||||||
|
-- claro l'emballe dans div.claro-autocomplete (le Montant, lui, a
|
||||||
|
l'input direct). Sans flex/min-width sur ce wrapper, l'input
|
||||||
|
(size=60) force sa largeur native, pousse le Montant hors de la
|
||||||
|
cellule et les deux champs disparaissent : chaque niveau doit être
|
||||||
|
flex et compressible. */
|
||||||
|
.figli-ledger-form .paragraphs-subform .form-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform .form-item__label {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.figli-ledger-form .paragraphs-subform .claro-autocomplete {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* L'input du Compte (size="60", ~420px de largeur intrinsèque) : la
|
||||||
|
chaîne flex/min-width ne suffit pas à le contenir de façon fiable à
|
||||||
|
tous les niveaux -- contrainte dure en pourcentage de son wrapper à
|
||||||
|
la place : physiquement incapable de déborder sur la colonne du
|
||||||
|
Montant, quoi que dise l'attribut size. */
|
||||||
|
.figli-ledger-form .paragraphs-subform .claro-autocomplete input.form-element {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs visibles au repos (fond opaque + bordure franche, les deux
|
||||||
|
modes via --flform-bg) et compressibles. flex:1 s'applique : au
|
||||||
|
Montant comme item direct du form-item, au Compte comme item du
|
||||||
|
claro-autocomplete flex ci-dessus. */
|
||||||
|
.figli-ledger-form .paragraphs-subform input.form-element {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
background: var(--flform-bg);
|
||||||
|
border: 1px solid var(--flform-border);
|
||||||
|
color: var(--flform-text);
|
||||||
|
-webkit-text-fill-color: var(--flform-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marqueur "figé" (ledger-form.js) : une valeur que l'assist ne
|
||||||
|
touchera plus -- saisie manuelle, ou chargée de la base. En item flex
|
||||||
|
(le form-item est en ligne ci-dessus), il suit l'input ; pointillé =
|
||||||
|
même convention que la référence bancaire, "valeur à regarder, pas à
|
||||||
|
retaper". */
|
||||||
|
.figli-ledger-form .figli-repartition-locked::after {
|
||||||
|
content: "figé";
|
||||||
|
flex: none;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.16rem 0.28rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px dashed var(--flform-border);
|
||||||
|
color: var(--flform-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A répartition row flagged by the sum-mismatch #validate error (see
|
||||||
|
figli_compta_ledger_validate_repartition()) -- kept subtle (a red
|
||||||
|
outline, not a solid fill) to match the same red-liseré convention
|
||||||
|
used for écarts in the /lignes table itself. */
|
||||||
|
.figli-ledger-form tr.error,
|
||||||
|
.figli-ledger-form .has-error {
|
||||||
|
outline: 1px solid var(--flform-danger);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form actions (Save), rendered by the AJAX dialog's own button pane
|
||||||
|
rather than here in most cases, but keep a sane baseline for the
|
||||||
|
rare full-page (non-modal) view of this form. */
|
||||||
|
.figli-ledger-form .form-actions input.button--primary {
|
||||||
|
background: var(--flform-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--flform-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
* Page de résultat d'import de relevé (templates/figli-compta-releve-import-result.html.twig).
|
||||||
|
*
|
||||||
|
* Piège #1 du CLAUDE.md respecté : le mode sombre Gin (html.gin--dark-mode)
|
||||||
|
* ne résout pas les CSS custom properties de Gin de façon fiable sur ces
|
||||||
|
* routes custom — couleurs explicites en local (--figli-*) + bloc dark mode
|
||||||
|
* dédié, jamais de fallback seul.
|
||||||
|
*/
|
||||||
|
.figli-releve-result {
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.5rem 3rem;
|
||||||
|
color: #161616;
|
||||||
|
--figli-border: #d4d4d4;
|
||||||
|
--figli-bg: #ffffff;
|
||||||
|
--figli-bg-soft: #f6f6f6;
|
||||||
|
--figli-ok: #1b5e20;
|
||||||
|
--figli-ok-bg: #e8f5e9;
|
||||||
|
--figli-alert: #b71c1c;
|
||||||
|
--figli-alert-bg: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-result h2 {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-tag .figli-flag-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #7a5c00;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stat {
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--figli-bg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #5f5f5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-totals {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-totals th,
|
||||||
|
.figli-releve-totals td {
|
||||||
|
padding: 0.4rem 0.8rem 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-amount {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-ok {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-left: 3px solid var(--figli-ok);
|
||||||
|
background: var(--figli-ok-bg);
|
||||||
|
color: var(--figli-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-alert {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-left: 3px solid var(--figli-alert);
|
||||||
|
background: var(--figli-alert-bg);
|
||||||
|
color: var(--figli-alert);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-errors li {
|
||||||
|
color: var(--figli-alert);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-dup-wrap {
|
||||||
|
max-height: 20rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--figli-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--figli-bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-dups {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-dups th,
|
||||||
|
.figli-releve-dups td {
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--figli-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figli-releve-actions {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode sombre Gin — mêmes règles, palette inversée, cf. CLAUDE.md piège #1. */
|
||||||
|
html.gin--dark-mode .figli-releve-result {
|
||||||
|
color: #e6e6e6;
|
||||||
|
--figli-border: #3a3a3a;
|
||||||
|
--figli-bg: #1c1c1c;
|
||||||
|
--figli-bg-soft: #232323;
|
||||||
|
--figli-ok: #9ee493;
|
||||||
|
--figli-ok-bg: #123016;
|
||||||
|
--figli-alert: #ff8a80;
|
||||||
|
--figli-alert-bg: #3a1212;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.gin--dark-mode .figli-releve-stat-label {
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
@@ -46,8 +46,38 @@ 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();
|
||||||
|
// Fresh installs never run hook_update_N below the current schema
|
||||||
|
// version -- the import's dedup index is created here directly, and
|
||||||
|
// existing sites get it from figli_compta_ledger_update_8012().
|
||||||
|
_figli_compta_ledger_ensure_fitid_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index on the bank statement import's dedup fingerprint column:
|
||||||
|
* ReleveUploadForm::submitForm() runs a grouped COUNT with
|
||||||
|
* WHERE field_import_fitid_value IN (...) on every upload. Negligible
|
||||||
|
* at ~1500 lines today, but that table only ever grows, and this keeps
|
||||||
|
* the lookup off a full scan without depending on the optimizer.
|
||||||
|
*/
|
||||||
|
function _figli_compta_ledger_ensure_fitid_index() {
|
||||||
|
$schema = \Drupal::database()->schema();
|
||||||
|
if ($schema->tableExists('node__field_import_fitid')
|
||||||
|
&& !$schema->indexExists('node__field_import_fitid', 'field_import_fitid_value')) {
|
||||||
|
// MySQL's addIndex() needs the column's field specification to
|
||||||
|
// normalize the index (utf8mb4 key-length check); varchar(64) stays
|
||||||
|
// under the 191-char shortening threshold, so the index covers the
|
||||||
|
// whole fingerprint column.
|
||||||
|
$schema->addIndex('node__field_import_fitid', 'field_import_fitid_value', ['field_import_fitid_value'], [
|
||||||
|
'fields' => [
|
||||||
|
'field_import_fitid_value' => ['type' => 'varchar', 'length' => 64, 'not null' => FALSE],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _figli_compta_ledger_create_vocabulary($vid, $name, array $terms) {
|
function _figli_compta_ledger_create_vocabulary($vid, $name, array $terms) {
|
||||||
@@ -87,16 +117,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 +144,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();
|
||||||
}
|
}
|
||||||
@@ -185,16 +224,56 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
|
|||||||
'charge' => 'Charge structurelle SAS',
|
'charge' => 'Charge structurelle SAS',
|
||||||
'versement' => 'Versement freelance',
|
'versement' => 'Versement freelance',
|
||||||
'achat' => 'Achat client (pass-through)',
|
'achat' => 'Achat client (pass-through)',
|
||||||
|
'hebergement' => 'Hébergement',
|
||||||
|
'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);
|
||||||
|
// Scale 4 (not 2) so that reconstructing an existing invoice's exact
|
||||||
|
// TTC from HT * (1 + TVA/100) round-trips to the centime for the
|
||||||
|
// overwhelming majority of migrated lines -- see
|
||||||
|
// figli_compta_ledger_update_8005()'s docblock for the empirical check
|
||||||
|
// behind that choice. A user typing a plain rate like "20" is
|
||||||
|
// unaffected either way.
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]);
|
||||||
|
// Cotisation diffuseur URSSAF (1,1%), entrée client lines only -- see
|
||||||
|
// figli_compta_ledger_update_8006()'s docblock for the full
|
||||||
|
// explanation. field_cotisation_active is the editable on/off toggle
|
||||||
|
// (a devis doesn't always include it); field_cotisation_urssaf is the
|
||||||
|
// computed amount (Montant HT * 1.011), never entered by hand.
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', '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_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
|
// Montant HT - somme(répartition), kept in sync at every save (see
|
||||||
|
// figli_compta_ledger_node_presave()) so /lignes' "Écarts uniquement"
|
||||||
|
// filter and its future server-side equivalent can filter on a real
|
||||||
|
// stored value instead of resolving répartition paragraphs per row.
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
|
// Bank statement import (see src/Import/): dedup fingerprint per
|
||||||
|
// transaction, and the immutable bank amount for audit. No form/display
|
||||||
|
// widget for either -- same "hidden technical field" treatment as
|
||||||
|
// field_ecart before its dashboard treatment (update_8009).
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_import_fitid', 'Empreinte import relevé', 'string', ['max_length' => 64]);
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_montant_releve', 'Montant relevé bancaire (€)', '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')) {
|
||||||
@@ -207,10 +286,36 @@ 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])
|
// Cotisation diffuseur URSSAF (1,1%) -- only "Entrée client" lines
|
||||||
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 5, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
|
// go through a client devis, the only place this cotisation is
|
||||||
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 6])
|
// ever invoiced (see figli_compta_ledger_update_8006()'s
|
||||||
|
// docblock). Optional even there: not every entrée historically
|
||||||
|
// included it, so a checkbox rather than an automatic
|
||||||
|
// type-based assumption. Weight 4, tied with field_entree_liee --
|
||||||
|
// the two types that show each are mutually exclusive
|
||||||
|
// (entrée vs. versement/achat/hébergement/sous_traitant), so
|
||||||
|
// which one wins the tie never matters.
|
||||||
|
->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]])
|
||||||
|
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
|
||||||
|
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
|
||||||
|
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
|
||||||
|
// Read-only bank reference directly under Montant TTC (the widget
|
||||||
|
// is #disabled by figli_compta_ledger_form_alter()) -- see
|
||||||
|
// update_8013/_8014 for why this lives on the form despite being
|
||||||
|
// import-written only, and why it sits at weight 9.
|
||||||
|
->setComponent('field_montant_releve', ['type' => 'number', 'weight' => 9])
|
||||||
|
// field_tva sits *after* Montant TTC (and the bank reference), not
|
||||||
|
// between Cotisation and TTC -- figli_compta_ledger_form_alter()
|
||||||
|
// inserts a non-field "field_tva_rate" select at weight 7 (a
|
||||||
|
// select of the official French VAT rates) to fill that visual
|
||||||
|
// slot instead; this real field only becomes visible (on its own
|
||||||
|
// full-width row) when "Autre" is picked there. See
|
||||||
|
// css/ledger-form.css's grid-column rules for both.
|
||||||
|
->setComponent('field_tva', ['type' => 'number', 'weight' => 10])
|
||||||
|
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 11, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button', 'features' => ['collapse_edit_all' => 'collapse_edit_all']]])
|
||||||
|
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 12])
|
||||||
|
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 13])
|
||||||
->save();
|
->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +329,647 @@ 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_cotisation_active', ['type' => 'boolean', 'weight' => 4])
|
||||||
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 5])
|
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5])
|
||||||
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 6])
|
->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6])
|
||||||
|
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 8])
|
||||||
|
->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 9])
|
||||||
|
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 10])
|
||||||
|
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 11])
|
||||||
|
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 12])
|
||||||
->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds field_tva (TVA %) to ligne_comptable and switches Montant TTC from
|
||||||
|
* manual entry to an automatically computed value (see
|
||||||
|
* figli_compta_ledger_node_presave(): TTC = round(HT * (1 + TVA/100), 2)
|
||||||
|
* from now on, whenever the line isn't a skip_validation-flagged
|
||||||
|
* migration save). Existing content is backfilled separately, see
|
||||||
|
* figli_compta_ledger_update_8005().
|
||||||
|
*
|
||||||
|
* Also reweights field_numero_facture (now next to Client, both
|
||||||
|
* identifying "which invoice/client" this line is about) and
|
||||||
|
* field_entree_liee (pushed just after it) so the HT/TVA/TTC trio can
|
||||||
|
* form its own clean three-column row afterwards -- see
|
||||||
|
* css/ledger-form.css's explicit grid-column rules, which this weight
|
||||||
|
* order must stay in sync with.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8004() {
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]);
|
||||||
|
|
||||||
|
// New lines default to 10% (taux intermédiaire) rather than blank --
|
||||||
|
// most new lines associates enter do carry VAT, so this saves a click
|
||||||
|
// on the common case instead of silently defaulting to "no VAT" for
|
||||||
|
// every line unless someone remembers to change it.
|
||||||
|
$field_tva = FieldConfig::loadByName('node', 'ligne_comptable', 'field_tva');
|
||||||
|
if ($field_tva && $field_tva->getDefaultValueLiteral() === []) {
|
||||||
|
$field_tva->setDefaultValue(10)->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weights are plain integers -- entity_display component config
|
||||||
|
// schema coerces anything else (a fractional weight silently rounds/
|
||||||
|
// truncates to its integer part on save), so the row-grouping can't be
|
||||||
|
// expressed as "insert field_tva between HT and TTC" via a fractional
|
||||||
|
// weight the way it might be elsewhere; every field in this block gets
|
||||||
|
// an explicit new integer instead.
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
$form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]);
|
||||||
|
$form_display->setComponent('field_entree_liee', ['type' => 'entity_reference_autocomplete', 'weight' => 4] + (array) $form_display->getComponent('field_entree_liee'));
|
||||||
|
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
|
||||||
|
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7] + (array) $form_display->getComponent('field_montant_ttc'));
|
||||||
|
// Weight 6 (between HT and TTC) is now the non-field
|
||||||
|
// "field_tva_rate" select figli_compta_ledger_form_alter() adds --
|
||||||
|
// field_tva itself moves after Répartition/Notes/Flag's *old*
|
||||||
|
// positions, past the ones below, so it lands on its own row when
|
||||||
|
// "Autre" reveals it instead of disrupting the HT/rate/TTC row.
|
||||||
|
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_tva'));
|
||||||
|
$form_display->setComponent('field_repartition', ['weight' => 9] + (array) $form_display->getComponent('field_repartition'));
|
||||||
|
$form_display->setComponent('field_notes', ['weight' => 10] + (array) $form_display->getComponent('field_notes'));
|
||||||
|
$form_display->setComponent('field_flag', ['weight' => 11] + (array) $form_display->getComponent('field_flag'));
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($view_display) {
|
||||||
|
$view_display->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8] + (array) $view_display->getComponent('field_tva'));
|
||||||
|
$view_display->setComponent('field_numero_facture', ['weight' => 3] + (array) $view_display->getComponent('field_numero_facture'));
|
||||||
|
$view_display->setComponent('field_montant_ht', ['weight' => 5] + (array) $view_display->getComponent('field_montant_ht'));
|
||||||
|
$view_display->setComponent('field_montant_ttc', ['weight' => 7] + (array) $view_display->getComponent('field_montant_ttc'));
|
||||||
|
$view_display->setComponent('field_repartition', ['weight' => 9] + (array) $view_display->getComponent('field_repartition'));
|
||||||
|
$view_display->setComponent('field_notes', ['weight' => 10] + (array) $view_display->getComponent('field_notes'));
|
||||||
|
$view_display->setComponent('field_flag', ['weight' => 11] + (array) $view_display->getComponent('field_flag'));
|
||||||
|
$view_display->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills field_tva for existing ligne_comptable content from the
|
||||||
|
* existing Montant HT / Montant TTC pair -- never touches Montant TTC
|
||||||
|
* itself, so every historical value stays exactly as migrated (same
|
||||||
|
* "never correct historical data" rule as everywhere else in this
|
||||||
|
* module). Left blank where there's nothing to derive from (no TTC ever
|
||||||
|
* recorded, or HT is zero).
|
||||||
|
*
|
||||||
|
* Why scale 4 on field_tva: checked empirically against all 1556
|
||||||
|
* existing lignes_comptables before picking it. Of the 946 lines with a
|
||||||
|
* real (non-zero, HT != TTC) rate, rounding the derived rate to 2
|
||||||
|
* decimals and reconstructing TTC = HT * (1 + rate/100) mismatched the
|
||||||
|
* real historical TTC (by more than a centime) for 74 of them --
|
||||||
|
* unacceptably lossy for what's supposed to be a faithful backfill. At 4
|
||||||
|
* decimals that drops to 9 (likely genuine blended/multi-rate invoices
|
||||||
|
* collapsed into a single ligne_comptable, where no single "TVA %" can
|
||||||
|
* be perfectly exact) -- an acceptable residual, and still purely
|
||||||
|
* informational since Montant TTC itself is left untouched here either
|
||||||
|
* way.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8005() {
|
||||||
|
$storage = \Drupal::entityTypeManager()->getStorage('node');
|
||||||
|
$nids = $storage->getQuery()
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$filled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
// Only field_tva is written here -- montant_ttc is read but never
|
||||||
|
// set, so this can never trip figli_compta_ledger_node_presave()'s
|
||||||
|
// sum(répartition) == montant_ht check either way. skip_validation is
|
||||||
|
// set anyway (a) for consistency with every other bulk-migration
|
||||||
|
// script in this module and (b) because it also now suppresses the
|
||||||
|
// TTC auto-computation added in the same presave function, which
|
||||||
|
// would otherwise silently overwrite the untouched historical TTC the
|
||||||
|
// moment this script calls save().
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||||
|
foreach ($storage->loadMultiple($nids) as $node) {
|
||||||
|
if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty()
|
||||||
|
|| !$node->hasField('field_montant_ttc') || $node->get('field_montant_ttc')->isEmpty()) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ht = (float) $node->get('field_montant_ht')->value;
|
||||||
|
$ttc = (float) $node->get('field_montant_ttc')->value;
|
||||||
|
if (abs($ht) < 0.0001) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rate = round(($ttc / $ht - 1) * 100, 4);
|
||||||
|
$node->set('field_tva', $rate);
|
||||||
|
$node->save();
|
||||||
|
$filled++;
|
||||||
|
}
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||||
|
|
||||||
|
return "TVA calculée pour $filled lignes, $skipped laissées vides (pas de Montant TTC renseigné ou Montant HT nul).";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the cotisation diffuseur URSSAF (1,1%) fields to ligne_comptable,
|
||||||
|
* and switches Montant TTC's computation for "Entrée client" lines when
|
||||||
|
* the cotisation applies: TTC = round(cotisation * (1 + TVA/100), 2)
|
||||||
|
* instead of straight from Montant HT.
|
||||||
|
*
|
||||||
|
* The SAS invoices clients a HT amount, then -- when the devis includes
|
||||||
|
* it, not always -- adds a 1,1% "cotisation diffuseur" the SAS owes
|
||||||
|
* URSSAF for using freelancers, *then* applies TVA on top of that
|
||||||
|
* increased amount, not on the raw HT. Only "Entrée client" lines ever
|
||||||
|
* go through a client devis at all; every other type (versement, achat,
|
||||||
|
* charge...) is an outgoing/internal line with nothing to invoice a
|
||||||
|
* cotisation on, so both fields below are #states-hidden outside that
|
||||||
|
* type (see figli_compta_ledger_form_alter()) and
|
||||||
|
* figli_compta_ledger_node_presave() never applies the cascade to them.
|
||||||
|
*
|
||||||
|
* field_tva as backfilled by figli_compta_ledger_update_8005() didn't
|
||||||
|
* know about this cascade and is wrong for every entrée line that did
|
||||||
|
* have the cotisation applied (it computed straight from HT, so what it
|
||||||
|
* stored is actually the *blended* HT->TTC rate, not the real TVA
|
||||||
|
* rate) -- corrected separately in figli_compta_ledger_update_8007(),
|
||||||
|
* which also backfills field_cotisation_active/field_cotisation_urssaf
|
||||||
|
* for existing content.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8006() {
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
|
|
||||||
|
// New entrée lines default to the cotisation being applied -- the
|
||||||
|
// associates' normal practice going forward, even though it was only
|
||||||
|
// roughly half of existing content (figli_compta_ledger_update_8007()
|
||||||
|
// detects each historical line on its own rather than assuming either
|
||||||
|
// way for what's already there).
|
||||||
|
$field_active = FieldConfig::loadByName('node', 'ligne_comptable', 'field_cotisation_active');
|
||||||
|
if ($field_active && $field_active->getDefaultValueLiteral() === []) {
|
||||||
|
$field_active->setDefaultValue(1)->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// See figli_compta_ledger_update_8004()'s comment on why every weight
|
||||||
|
// here is a plain integer (fractional weights silently truncate).
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
$form_display->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]]);
|
||||||
|
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
|
||||||
|
$form_display->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6]);
|
||||||
|
// Weight 7 (between Cotisation and Montant TTC) is the non-field
|
||||||
|
// "field_tva_rate" select figli_compta_ledger_form_alter() adds.
|
||||||
|
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_montant_ttc'));
|
||||||
|
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 9] + (array) $form_display->getComponent('field_tva'));
|
||||||
|
$form_display->setComponent('field_repartition', ['weight' => 10] + (array) $form_display->getComponent('field_repartition'));
|
||||||
|
$form_display->setComponent('field_notes', ['weight' => 11] + (array) $form_display->getComponent('field_notes'));
|
||||||
|
$form_display->setComponent('field_flag', ['weight' => 12] + (array) $form_display->getComponent('field_flag'));
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($view_display) {
|
||||||
|
$view_display->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4]);
|
||||||
|
$view_display->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6]);
|
||||||
|
$view_display->setComponent('field_montant_ttc', ['weight' => 8] + (array) $view_display->getComponent('field_montant_ttc'));
|
||||||
|
$view_display->setComponent('field_tva', ['weight' => 9] + (array) $view_display->getComponent('field_tva'));
|
||||||
|
$view_display->setComponent('field_repartition', ['weight' => 10] + (array) $view_display->getComponent('field_repartition'));
|
||||||
|
$view_display->setComponent('field_notes', ['weight' => 11] + (array) $view_display->getComponent('field_notes'));
|
||||||
|
$view_display->setComponent('field_flag', ['weight' => 12] + (array) $view_display->getComponent('field_flag'));
|
||||||
|
$view_display->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills field_cotisation_active/field_cotisation_urssaf and
|
||||||
|
* corrects field_tva for existing "Entrée client" lines, now that the
|
||||||
|
* HT -> +1,1% -> +TVA cascade explained in
|
||||||
|
* figli_compta_ledger_update_8006()'s docblock is understood. 2021 is
|
||||||
|
* left untouched entirely (explicit instruction -- that year's
|
||||||
|
* cotisation practice isn't confirmed), and Montant TTC itself is never
|
||||||
|
* written here, same "never correct historical data" rule as every
|
||||||
|
* other migration in this module.
|
||||||
|
*
|
||||||
|
* Per entrée line (2022+, both Montant HT and Montant TTC present):
|
||||||
|
* - old_rate = (TTC/HT - 1) * 100 -- what
|
||||||
|
* figli_compta_ledger_update_8005() already stored in field_tva.
|
||||||
|
* - If old_rate already lands on an official French VAT rate (0, 2.1,
|
||||||
|
* 5.5, 10, 20 -- ±0.05 points, allowing for rounding through 2-decimal
|
||||||
|
* HT/TTC storage), the cotisation was never applied to this line:
|
||||||
|
* field_tva is already correct as migrated, left alone, cotisation
|
||||||
|
* marked inactive.
|
||||||
|
* - Otherwise, new_rate = (TTC / (HT * 1.011) - 1) * 100. If *that*
|
||||||
|
* lands on an official rate, the cotisation was applied and old_rate
|
||||||
|
* was the wrong (blended HT->TTC) figure all along -- field_tva
|
||||||
|
* corrected to new_rate, cotisation marked active, field_cotisation_
|
||||||
|
* urssaf backfilled to HT * 1.011.
|
||||||
|
* - If neither lands cleanly, this is an ambiguous/blended case (a
|
||||||
|
* handful of genuinely multi-rate invoices collapsed into one line,
|
||||||
|
* same category figli_compta_ledger_update_8005() already found at 4-
|
||||||
|
* decimal precision) -- field_tva left untouched, cotisation marked
|
||||||
|
* inactive, rather than guess.
|
||||||
|
*
|
||||||
|
* Checked empirically before writing this (287 entrée lines with both
|
||||||
|
* amounts, 2022+): 90 already official (left alone), 154 corrected, 40
|
||||||
|
* still ambiguous both ways (left alone), 3 missing HT or TTC.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8007() {
|
||||||
|
$official = [0.0, 2.1, 5.5, 10.0, 20.0];
|
||||||
|
$epsilon = 0.05;
|
||||||
|
$matches_official = function ($rate) use ($official, $epsilon) {
|
||||||
|
foreach ($official as $o) {
|
||||||
|
if (abs($rate - $o) < $epsilon) {
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FALSE;
|
||||||
|
};
|
||||||
|
|
||||||
|
$storage = \Drupal::entityTypeManager()->getStorage('node');
|
||||||
|
$nids = $storage->getQuery()
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->condition('field_type_ligne', 'entree')
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$skipped2021 = 0;
|
||||||
|
$noData = 0;
|
||||||
|
$alreadyOfficial = 0;
|
||||||
|
$corrected = 0;
|
||||||
|
$stillAmbiguous = 0;
|
||||||
|
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||||
|
foreach ($storage->loadMultiple($nids) as $node) {
|
||||||
|
$date = $node->hasField('field_date_ligne') ? $node->get('field_date_ligne')->value : NULL;
|
||||||
|
if ($date && substr($date, 0, 4) === '2021') {
|
||||||
|
$skipped2021++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
|
||||||
|
? (float) $node->get('field_montant_ht')->value : NULL;
|
||||||
|
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
|
||||||
|
? (float) $node->get('field_montant_ttc')->value : NULL;
|
||||||
|
if ($ht === NULL || $ttc === NULL || abs($ht) < 0.0001) {
|
||||||
|
$node->set('field_cotisation_active', FALSE);
|
||||||
|
$node->save();
|
||||||
|
$noData++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$old_rate = round(($ttc / $ht - 1) * 100, 4);
|
||||||
|
if ($matches_official($old_rate)) {
|
||||||
|
$node->set('field_cotisation_active', FALSE);
|
||||||
|
$node->save();
|
||||||
|
$alreadyOfficial++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$new_rate = round(($ttc / ($ht * 1.011) - 1) * 100, 4);
|
||||||
|
if (!$matches_official($new_rate)) {
|
||||||
|
$node->set('field_cotisation_active', FALSE);
|
||||||
|
$node->save();
|
||||||
|
$stillAmbiguous++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$node->set('field_cotisation_active', TRUE);
|
||||||
|
$node->set('field_cotisation_urssaf', round($ht * 1.011, 2));
|
||||||
|
$node->set('field_tva', $new_rate);
|
||||||
|
$node->save();
|
||||||
|
$corrected++;
|
||||||
|
}
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||||
|
|
||||||
|
return "Cotisation/TVA : $corrected lignes corrigées (cotisation active), $alreadyOfficial déjà correctes (cotisation inactive), $stillAmbiguous laissées telles quelles (taux non standard des deux façons), $noData sans HT/TTC, $skipped2021 lignes 2021 ignorées.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects what field_cotisation_urssaf stores for the lines
|
||||||
|
* figli_compta_ledger_update_8007() marked cotisation-active: that
|
||||||
|
* migration (and figli_compta_ledger_node_presave() as first written)
|
||||||
|
* stored Montant HT + 1,1% there, but "1,1%" as a field/column label
|
||||||
|
* means just the 1,1% itself -- the delta, not the augmented total.
|
||||||
|
* TVA still applies to the augmented base (Montant HT + this field), so
|
||||||
|
* Montant TTC is untouched by this correction; only
|
||||||
|
* field_cotisation_urssaf's own value changes, from round(HT * 1.011, 2)
|
||||||
|
* to round(HT * 0.011, 2). Safe to run unconditionally on every
|
||||||
|
* cotisation-active line -- this is a bug in code from earlier the same
|
||||||
|
* day, not a judgment call about ambiguous historical data like
|
||||||
|
* figli_compta_ledger_update_8007()'s.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8008() {
|
||||||
|
$storage = \Drupal::entityTypeManager()->getStorage('node');
|
||||||
|
$nids = $storage->getQuery()
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->condition('field_cotisation_active', 1)
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$fixed = 0;
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||||
|
foreach ($storage->loadMultiple($nids) as $node) {
|
||||||
|
if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ht = (float) $node->get('field_montant_ht')->value;
|
||||||
|
$node->set('field_cotisation_urssaf', round($ht * 0.011, 2));
|
||||||
|
$node->save();
|
||||||
|
$fixed++;
|
||||||
|
}
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||||
|
|
||||||
|
return "field_cotisation_urssaf corrigé (delta seul, pas HT + delta) pour $fixed lignes.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds field_ecart (Montant HT - somme répartition) to ligne_comptable --
|
||||||
|
* kept in sync at every save from now on (see
|
||||||
|
* figli_compta_ledger_node_presave()), so the server-side filtering work
|
||||||
|
* planned for /lignes can filter on a real stored value instead of
|
||||||
|
* resolving répartition paragraphs per row on every request. Existing
|
||||||
|
* content backfilled separately, see figli_compta_ledger_update_8010().
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8009() {
|
||||||
|
// No form widget added -- purely a diagnostic/filtering value, already
|
||||||
|
// fully represented in the edit form by Montant HT + the Répartition
|
||||||
|
// paragraphs it's derived from, and already shown on /lignes' own
|
||||||
|
// Écart column. Nothing new for an associate to look at here; simply
|
||||||
|
// never calling setComponent() for it on the form display is enough
|
||||||
|
// to keep it off that form.
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
|
|
||||||
|
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($view_display && !$view_display->getComponent('field_ecart')) {
|
||||||
|
$view_display->setComponent('field_ecart', ['type' => 'number_decimal', 'weight' => 13])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills field_ecart for every existing ligne_comptable (all types,
|
||||||
|
* unlike the TVA/cotisation migrations which only ever touched "Entrée
|
||||||
|
* client" lines -- répartition applies to every line regardless of
|
||||||
|
* type). Mechanical, not a judgment call like
|
||||||
|
* figli_compta_ledger_update_8007()'s TVA correction: Montant HT and the
|
||||||
|
* répartition amounts are both already-correct historical values, this
|
||||||
|
* just computes their difference and stores it, exactly what
|
||||||
|
* figli_compta_ledger_node_presave() will do going forward.
|
||||||
|
*
|
||||||
|
* Runs under two state flags, not just the usual skip_validation:
|
||||||
|
* skip_validation avoids tripping the répartition-sum exception on the
|
||||||
|
* ~18 lines with a known, deliberately-preserved historical mismatch
|
||||||
|
* (same as every other bulk script in this module); skip_revision
|
||||||
|
* additionally suppresses the forced-revision block in
|
||||||
|
* figli_compta_ledger_node_presave() -- backfilling a purely computed
|
||||||
|
* diagnostic field onto ~1500 already-migrated lines isn't an editorial
|
||||||
|
* change worth 1500 new revisions cluttering the audit trail that
|
||||||
|
* mechanism exists to protect. Montant TTC is never touched either way
|
||||||
|
* (skip_validation already keeps it out of reach, see
|
||||||
|
* figli_compta_ledger_node_presave()).
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8010() {
|
||||||
|
$storage = \Drupal::entityTypeManager()->getStorage('node');
|
||||||
|
$nids = $storage->getQuery()
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$filled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||||
|
\Drupal::state()->set('figli_compta_ledger.skip_revision', TRUE);
|
||||||
|
foreach ($storage->loadMultiple($nids) as $node) {
|
||||||
|
if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty()
|
||||||
|
|| !$node->hasField('field_repartition')) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ht = (float) $node->get('field_montant_ht')->value;
|
||||||
|
$somme = 0.0;
|
||||||
|
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
|
||||||
|
if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) {
|
||||||
|
$somme += (float) $paragraph->get('field_montant')->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$node->set('field_ecart', round($ht - $somme, 2));
|
||||||
|
$node->save();
|
||||||
|
$filled++;
|
||||||
|
}
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||||
|
\Drupal::state()->delete('figli_compta_ledger.skip_revision');
|
||||||
|
|
||||||
|
return "Écart calculé pour $filled lignes (aucune nouvelle révision créée), $skipped laissées vides (pas de Montant HT).";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the two technical fields behind the bank statement import (see
|
||||||
|
* PLAN-import-releve-bancaire.md and src/Import/):
|
||||||
|
* - field_import_fitid (string 64): per-transaction dedup fingerprint
|
||||||
|
* ('csv:<sha1(date|montant|libellé normalisé)>'), compared count-aware
|
||||||
|
* against every line already in base, all provenances combined;
|
||||||
|
* - field_montant_releve (decimal 12,2): the real bank amount, written
|
||||||
|
* once at import and never touched again by anything -- the immutable
|
||||||
|
* audit reference field_montant_ttc (a normal, recomputed field) can
|
||||||
|
* legitimately drift away from as the associate corrects HT/TVA.
|
||||||
|
*
|
||||||
|
* Neither gets a form or display widget: purely technical, same treatment
|
||||||
|
* as field_ecart (update_8009). No data to backfill -- only the import
|
||||||
|
* itself writes these.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8011() {
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_import_fitid', 'Empreinte import relevé', 'string', ['max_length' => 64]);
|
||||||
|
_figli_field('node', 'ligne_comptable', 'field_montant_releve', 'Montant relevé bancaire (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
|
||||||
|
|
||||||
|
return 'Champs field_import_fitid + field_montant_releve ajoutés (import de relevé bancaire).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the dedup index on node__field_import_fitid(field_import_fitid_value)
|
||||||
|
* -- see _figli_compta_ledger_ensure_fitid_index(). Split from update_8011
|
||||||
|
* because that one already ran when the index need was reviewed.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8012() {
|
||||||
|
_figli_compta_ledger_ensure_fitid_index();
|
||||||
|
|
||||||
|
return "Index ajouté sur node__field_import_fitid (empreintes d'import, requête de dédoublonnage).";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds field_montant_releve to the ligne_comptable form display as a
|
||||||
|
* read-only reference: the widget is #disabled by
|
||||||
|
* figli_compta_ledger_form_alter() and nothing but the bank statement
|
||||||
|
* import ever writes the field (see update_8011), but associates need to
|
||||||
|
* SEE the bank's amount while they correct HT/TVA on an imported line --
|
||||||
|
* an écart between it and the recomputed Montant TTC is a useful signal
|
||||||
|
* (grouped invoice, partial payment), not something to hide.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8013() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display && !$form_display->getComponent('field_montant_releve')) {
|
||||||
|
$form_display->setComponent('field_montant_releve', ['type' => 'number', 'weight' => 13])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'field_montant_releve visible en lecture seule sur le formulaire (référence bancaire des lignes importées).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves field_montant_releve from the form's bottom up to weight 9,
|
||||||
|
* directly under Montant TTC (weight 8) -- the bank reference reads best
|
||||||
|
* right below the amount it gets compared against while sorting an
|
||||||
|
* imported line. Weights are plain integers (display config coerces
|
||||||
|
* fractional ones, see update_8004()'s comment), so inserting means
|
||||||
|
* renumbering the tail -- field_tva/repartition/notes/flag shift to
|
||||||
|
* 10/11/12/13, exactly the kind of renumber update_8004/_8006 did
|
||||||
|
* before. Mirrors the fresh-install weights in
|
||||||
|
* _figli_compta_ledger_create_node_type_ligne_comptable().
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8014() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
foreach ([
|
||||||
|
'field_montant_releve' => 9,
|
||||||
|
'field_tva' => 10,
|
||||||
|
'field_repartition' => 11,
|
||||||
|
'field_notes' => 12,
|
||||||
|
'field_flag' => 13,
|
||||||
|
] as $field_name => $weight) {
|
||||||
|
$component = $form_display->getComponent($field_name);
|
||||||
|
if ($component) {
|
||||||
|
$component['weight'] = $weight;
|
||||||
|
$form_display->setComponent($field_name, $component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'field_montant_releve placé sous Montant TTC (weights décalés).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empties the Paragraphs widget's "features" setting for
|
||||||
|
* field_repartition: with duplicate and collapse_edit_all off, the
|
||||||
|
* widget stops rendering the per-row Duplicate action and the thead's
|
||||||
|
* "Collapse / Edit all" -- pure noise on a 1-3 row répartition whose
|
||||||
|
* order never matters (see ledger-form.css's compacting rules for the
|
||||||
|
* rest: per-row title and Collapse button are hidden in CSS, they have
|
||||||
|
* no widget setting).
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8015() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
$component = $form_display->getComponent('field_repartition');
|
||||||
|
if ($component) {
|
||||||
|
$component['settings']['features'] = [];
|
||||||
|
$form_display->setComponent('field_repartition', $component);
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Widget Répartition compacté (features duplicate/collapse_edit_all désactivées).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enables the Paragraphs widget's collapse_edit_all feature (the
|
||||||
|
* "Collapse / Edit all" action in the widget's title cell) -- removed
|
||||||
|
* along with duplicate by update_8015, asked back by the associates
|
||||||
|
* the same day. Duplicate stays off: it has no use on a 1-3 row
|
||||||
|
* répartition whose order never matters.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_update_8016() {
|
||||||
|
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
|
||||||
|
if ($form_display) {
|
||||||
|
$component = $form_display->getComponent('field_repartition');
|
||||||
|
if ($component) {
|
||||||
|
$component['settings']['features'] = ['collapse_edit_all' => 'collapse_edit_all'];
|
||||||
|
$form_display->setComponent('field_repartition', $component);
|
||||||
|
$form_display->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Bouton "Collapse / Edit all" rétabli sur le widget Répartition (duplicate reste désactivé).';
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,3 +24,52 @@ dashboard:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- core/drupal
|
- core/drupal
|
||||||
- figli_compta_ledger/vue
|
- figli_compta_ledger/vue
|
||||||
|
|
||||||
|
dashboard_repartition:
|
||||||
|
js:
|
||||||
|
js/dashboard-repartition.js: {}
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/dashboard.css: {}
|
||||||
|
dependencies:
|
||||||
|
- core/drupal
|
||||||
|
- figli_compta_ledger/vue
|
||||||
|
|
||||||
|
dashboard_compte:
|
||||||
|
js:
|
||||||
|
js/dashboard-compte.js: {}
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/dashboard.css: {}
|
||||||
|
dependencies:
|
||||||
|
- core/drupal
|
||||||
|
- figli_compta_ledger/vue
|
||||||
|
|
||||||
|
ledger_form:
|
||||||
|
js:
|
||||||
|
js/ledger-form.js: {}
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/ledger-form.css: {}
|
||||||
|
dependencies:
|
||||||
|
- core/drupal
|
||||||
|
- core/once
|
||||||
|
|
||||||
|
releve_import:
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/releve-import.css: {}
|
||||||
|
|
||||||
|
admin_chrome:
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/admin-chrome.css: {}
|
||||||
|
js:
|
||||||
|
js/admin-chrome.js: {}
|
||||||
|
dependencies:
|
||||||
|
- core/drupal
|
||||||
|
|
||||||
|
hide_admin_chrome:
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/hide-admin-chrome.css: {}
|
||||||
|
|||||||
@@ -13,3 +13,27 @@ figli_compta_ledger.dashboard:
|
|||||||
menu_name: admin
|
menu_name: admin
|
||||||
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:
|
||||||
|
title: 'Historique du grand livre'
|
||||||
|
description: 'Toutes les révisions de toutes les lignes comptables'
|
||||||
|
route_name: figli_compta_ledger.history
|
||||||
|
menu_name: admin
|
||||||
|
parent: system.admin
|
||||||
|
weight: -8
|
||||||
|
|
||||||
|
figli_compta_ledger.releve_import:
|
||||||
|
title: 'Importer un relevé'
|
||||||
|
description: 'Créer des lignes brouillon depuis un export CSV bancaire'
|
||||||
|
route_name: figli_compta_ledger.releve_import_form
|
||||||
|
menu_name: admin
|
||||||
|
parent: system.admin
|
||||||
|
weight: -7
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @file
|
* @file
|
||||||
* Enforces the one invariant that explained most of the errors found in the
|
* Enforces two invariants for ligne_comptable: sum(répartition.montant)
|
||||||
* historical spreadsheets: sum(répartition.montant) must equal montant_ht.
|
* must equal montant_ht (explained most of the errors found in the
|
||||||
|
* historical spreadsheets), and every save must create a revision with no
|
||||||
|
* way to opt out, for an audit trail of who changed the shared ledger and
|
||||||
|
* when.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use Drupal\node\NodeInterface;
|
use Drupal\node\NodeInterface;
|
||||||
@@ -11,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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,8 +35,216 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
|
|||||||
if (!in_array($form_id, ['node_ligne_comptable_form', 'node_ligne_comptable_edit_form'], TRUE)) {
|
if (!in_array($form_id, ['node_ligne_comptable_form', 'node_ligne_comptable_edit_form'], TRUE)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The add and edit forms get different auto-generated classes
|
||||||
|
// (node-ligne-comptable-form vs. node-ligne-comptable-edit-form) --
|
||||||
|
// one shared class so css/ledger-form.css can target both without
|
||||||
|
// duplicating every selector.
|
||||||
|
$form['#attributes']['class'][] = 'figli-ledger-form';
|
||||||
$form['#validate'][] = 'figli_compta_ledger_validate_repartition';
|
$form['#validate'][] = 'figli_compta_ledger_validate_repartition';
|
||||||
|
|
||||||
|
// Every save must create a revision, unconditionally -- there's no
|
||||||
|
// legitimate reason to skip it for an accounting record, so don't let
|
||||||
|
// anyone opt out. #access = FALSE (rather than just leaving the
|
||||||
|
// #default_value alone) makes Form API discard whatever a tampered
|
||||||
|
// request might submit for this field and fall back to #default_value.
|
||||||
|
// figli_compta_ledger_node_presave() enforces the same thing for any
|
||||||
|
// save that doesn't go through this form at all.
|
||||||
|
if (isset($form['revision'])) {
|
||||||
|
$form['revision']['#default_value'] = TRUE;
|
||||||
|
$form['revision']['#access'] = FALSE;
|
||||||
|
}
|
||||||
|
if (isset($form['revision_log'])) {
|
||||||
|
$form['revision_log']['#access'] = FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// field_entree_liee only makes sense on the "sortie" types that pay out
|
||||||
|
// against a client invoice -- charge/autre/ouverture/salaire_stage
|
||||||
|
// 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'])) {
|
||||||
|
$form['field_entree_liee']['#states'] = [
|
||||||
|
'visible' => [
|
||||||
|
[':input[name="field_type_ligne"]' => ['value' => 'versement']],
|
||||||
|
'or',
|
||||||
|
[':input[name="field_type_ligne"]' => ['value' => 'achat']],
|
||||||
|
'or',
|
||||||
|
[':input[name="field_type_ligne"]' => ['value' => 'hebergement']],
|
||||||
|
'or',
|
||||||
|
[':input[name="field_type_ligne"]' => ['value' => 'sous_traitant']],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Title" is generic Drupal boilerplate that means nothing to a
|
||||||
|
// freelancer entering an accounting line -- but home.js/dashboard*.js
|
||||||
|
// do fall back to it as the displayed "Libellé" when Notes / détail is
|
||||||
|
// left empty, so it can't just be hidden. Relabel it so that
|
||||||
|
// relationship is explicit instead of a mystery required field.
|
||||||
|
if (isset($form['title']['widget'][0]['value'])) {
|
||||||
|
$form['title']['widget'][0]['value']['#title'] = t('Libellé court');
|
||||||
|
$form['title']['widget'][0]['value']['#description'] = t('Affiché dans le grand livre si "Notes / détail" ci-dessous est laissé vide.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu settings / URL alias / Authoring information: none of this is
|
||||||
|
// ever used for a ligne_comptable (no menu links, no path aliases, and
|
||||||
|
// the acting user + revision timestamp are already captured by the
|
||||||
|
// forced revision in figli_compta_ledger_node_presave() below) -- just
|
||||||
|
// noise in a form meant to be a quick numeric entry, not a full content
|
||||||
|
// editing screen. #access rather than deleting the keys: safe no-op if
|
||||||
|
// a future field ever gets #group => 'advanced'.
|
||||||
|
if (isset($form['advanced'])) {
|
||||||
|
$form['advanced']['#access'] = FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Published" is meaningless here too -- there's no draft/unpublished
|
||||||
|
// workflow for ledger lines, every one of them is expected to count
|
||||||
|
// immediately. Same pattern as the 'revision' field above: force the
|
||||||
|
// value rather than exposing a checkbox nobody should ever touch.
|
||||||
|
if (isset($form['status'])) {
|
||||||
|
$form['status']['widget']['value']['#default_value'] = TRUE;
|
||||||
|
$form['status']['#access'] = FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Montant TTC is computed from Montant HT + TVA (see
|
||||||
|
// figli_compta_ledger_node_presave()) -- genuinely not editable
|
||||||
|
// (#disabled, not just readonly: a disabled field can't be focused or
|
||||||
|
// tabbed into at all, and Form API discards whatever a tampered
|
||||||
|
// request submits for it in favor of #default_value, same protection
|
||||||
|
// already used for 'revision' above). js/ledger-form.js can still set
|
||||||
|
// its .value directly for the live preview -- #disabled only blocks
|
||||||
|
// user interaction, not script writes.
|
||||||
|
if (isset($form['field_montant_ttc']['widget'][0]['value'])) {
|
||||||
|
$form['field_montant_ttc']['widget'][0]['value']['#disabled'] = TRUE;
|
||||||
|
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Montant relevé bancaire: the bank statement import's immutable audit
|
||||||
|
// reference (see PLAN-import-releve-bancaire.md and src/Import/) --
|
||||||
|
// displayed as plain text, no input box at all: an editable-looking
|
||||||
|
// box for a value nobody may type wastes space and misleads. Replacing
|
||||||
|
// the widget with a #type => 'item' element is loss-proof by
|
||||||
|
// construction: an item submits nothing, and
|
||||||
|
// WidgetBase::extractFormValues() skips the field entirely when no
|
||||||
|
// value was submitted (its $key_exists check) -- the stored amount
|
||||||
|
// survives every save untouched, which is the whole point. Hidden
|
||||||
|
// entirely on lines that have no bank amount (manually entered ones).
|
||||||
|
if (isset($form['field_montant_releve'])) {
|
||||||
|
/** @var \Drupal\node\NodeInterface $entity */
|
||||||
|
$entity = $form_state->getFormObject()->getEntity();
|
||||||
|
if (!$entity->get('field_montant_releve')->isEmpty()) {
|
||||||
|
$montant = (float) $entity->get('field_montant_releve')->value;
|
||||||
|
// The widget's own #title can't be trusted here (Claro moves the
|
||||||
|
// label to its form-item wrapper and leaves an empty string on the
|
||||||
|
// input -- '' isn't caught by ??), so read the configured label.
|
||||||
|
$field_config = \Drupal\field\Entity\FieldConfig::loadByName('node', $entity->bundle(), 'field_montant_releve');
|
||||||
|
$form['field_montant_releve']['widget'] = [
|
||||||
|
'#type' => 'item',
|
||||||
|
'#title' => $field_config ? $field_config->getLabel() : t('Montant relevé bancaire (€)'),
|
||||||
|
'#markup' => '<strong class="figli-releve-value">' . number_format($montant, 2, ',', ' ') . ' €</strong>',
|
||||||
|
'#description' => t("Référence bancaire immuable — renseignée à l'import du relevé, jamais modifiée."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$form['field_montant_releve']['#access'] = FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
|
||||||
|
// "Entrée client" lines (the only type invoiced to a client via a
|
||||||
|
// devis; see figli_compta_ledger_update_8006()'s docblock), so both
|
||||||
|
// fields are hidden otherwise. Real #states on real fields watching
|
||||||
|
// the real field_type_ligne select -- unlike field_tva_rate below,
|
||||||
|
// this is the same working pattern already used a few fields up on
|
||||||
|
// field_entree_liee, not the custom select that #states silently
|
||||||
|
// failed to toggle.
|
||||||
|
if (isset($form['field_cotisation_active'])) {
|
||||||
|
$form['field_cotisation_active']['#states'] = [
|
||||||
|
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (isset($form['field_cotisation_urssaf']['widget'][0]['value'])) {
|
||||||
|
$form['field_cotisation_urssaf']['#states'] = [
|
||||||
|
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
|
||||||
|
];
|
||||||
|
$form['field_cotisation_urssaf']['widget'][0]['value']['#disabled'] = TRUE;
|
||||||
|
$form['field_cotisation_urssaf']['widget'][0]['value']['#description'] = t('Calculé automatiquement (1,1% du montant HT) si la cotisation ci-dessus est cochée. La TVA est ensuite appliquée sur Montant HT + ce montant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TVA: a select of the official French rates covers the overwhelming
|
||||||
|
// majority of new lines with one click, instead of associates having
|
||||||
|
// to know/type "20" or "5.5" by heart. "Autre" reveals the real
|
||||||
|
// decimal field underneath for the rest -- crucially including every
|
||||||
|
// pre-migration line, whose backfilled rate (see
|
||||||
|
// figli_compta_ledger_update_8005()) is often a blended, non-standard
|
||||||
|
// percentage that wouldn't match any of these options, and must stay
|
||||||
|
// editable/visible as the exact figure it is rather than being forced
|
||||||
|
// to snap to the nearest official rate.
|
||||||
|
if (isset($form['field_tva']['widget'][0]['value'])) {
|
||||||
|
$taux_officiels = [
|
||||||
|
'0' => t('0 % (exonéré)'),
|
||||||
|
'2.1' => t('2,1 % (taux particulier)'),
|
||||||
|
'5.5' => t('5,5 % (taux réduit)'),
|
||||||
|
'10' => t('10 % (taux intermédiaire)'),
|
||||||
|
'20' => t('20 % (taux normal)'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$tva_field = &$form['field_tva']['widget'][0]['value'];
|
||||||
|
$current = $tva_field['#default_value'] ?? NULL;
|
||||||
|
$rate_default = 'autre';
|
||||||
|
if ($current === NULL || $current === '') {
|
||||||
|
// 10 % (taux intermédiaire), not 0 -- most new lines associates
|
||||||
|
// enter do carry VAT, so this saves a click on the common case
|
||||||
|
// instead of silently defaulting to "no VAT" for every line
|
||||||
|
// unless someone remembers to change it.
|
||||||
|
$rate_default = '10';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
foreach (array_keys($taux_officiels) as $rate) {
|
||||||
|
if (abs((float) $current - (float) $rate) < 0.0001) {
|
||||||
|
$rate_default = $rate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain top-level element, deliberately *not* nested inside
|
||||||
|
// $form['field_tva'] -- Field API's WidgetBase::extractFormValues()
|
||||||
|
// reads the entire form_state value at that path as the field's own
|
||||||
|
// delta-indexed value array, and chokes on an unrelated sibling key
|
||||||
|
// mixed into it.
|
||||||
|
//
|
||||||
|
// Weight 7: the slot between field_cotisation_urssaf (6) and Montant
|
||||||
|
// TTC (8) left open in figli_compta_ledger_update_8006() -- field_tva
|
||||||
|
// itself sits at weight 9 (its own full-width row) so that this
|
||||||
|
// select forms a clean HT/Cotisation/rate/TTC four-column row
|
||||||
|
// whether or not "Autre" is currently revealing the real field_tva
|
||||||
|
// input further down. See css/ledger-form.css's grid-column rules
|
||||||
|
// for both -- the row layout there depends on this exact ordering,
|
||||||
|
// not just on matching #weight values.
|
||||||
|
$form['field_tva_rate'] = [
|
||||||
|
'#type' => 'select',
|
||||||
|
'#title' => t('TVA'),
|
||||||
|
'#options' => $taux_officiels + ['autre' => t('Autre (préciser)')],
|
||||||
|
'#default_value' => $rate_default,
|
||||||
|
'#weight' => 7,
|
||||||
|
];
|
||||||
|
$tva_field['#title'] = t('Taux exact (%)');
|
||||||
|
// Not #states: this element sits outside the field system (see the
|
||||||
|
// comment above) and, tested in this exact AJAX-loaded modal,
|
||||||
|
// #states' data-drupal-states attribute gets attached correctly but
|
||||||
|
// never actually toggles visibility here -- unclear why (a working
|
||||||
|
// #states dependency already exists a few fields up, on
|
||||||
|
// field_entree_liee, watching a real Field API select rather than
|
||||||
|
// this manually-added one), not worth chasing further when
|
||||||
|
// js/ledger-form.js already has to run custom JS on this form
|
||||||
|
// anyway for the HT/TVA -> TTC live preview. Plain JS toggle there
|
||||||
|
// instead -- see figliLedgerTvaRateToggle.
|
||||||
|
$form['#validate'][] = 'figli_compta_ledger_apply_tva_rate';
|
||||||
|
}
|
||||||
|
|
||||||
|
$form['#attached']['library'][] = 'figli_compta_ledger/ledger_form';
|
||||||
|
|
||||||
$request = \Drupal::request();
|
$request = \Drupal::request();
|
||||||
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
|
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
|
||||||
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
|
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
|
||||||
@@ -77,6 +289,28 @@ function figli_compta_ledger_validate_repartition(array &$form, FormStateInterfa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form #validate callback: when a standard French VAT rate was picked from
|
||||||
|
* the field_tva_rate select (added in figli_compta_ledger_form_alter()),
|
||||||
|
* copies it into field_tva's own submitted value -- that's the field
|
||||||
|
* figli_compta_ledger_node_presave() actually reads to compute Montant TTC.
|
||||||
|
* Left untouched when "Autre" is selected: the real field_tva input,
|
||||||
|
* shown in that case by js/ledger-form.js's figliLedgerTvaRateToggle,
|
||||||
|
* already carries whatever the associate typed there.
|
||||||
|
*
|
||||||
|
* Runs late enough to matter despite EntityForm::validateForm() building an
|
||||||
|
* interim entity earlier in the same validate phase (for constraint
|
||||||
|
* checking only) -- submitForm() rebuilds the entity fresh from
|
||||||
|
* $form_state's values right before save(), by which point every
|
||||||
|
* #validate callback, this one included, has already run.
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_apply_tva_rate(array &$form, FormStateInterface $form_state) {
|
||||||
|
$rate = $form_state->getValue('field_tva_rate');
|
||||||
|
if ($rate !== NULL && $rate !== 'autre') {
|
||||||
|
$form_state->setValue(['field_tva', 0, 'value'], $rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #ajax callback for the node form submit button: close the modal on
|
* #ajax callback for the node form submit button: close the modal on
|
||||||
* success, or re-render the form (with errors) in place on failure.
|
* success, or re-render the form (with errors) in place on failure.
|
||||||
@@ -84,10 +318,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;
|
||||||
}
|
}
|
||||||
@@ -102,22 +358,122 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa
|
|||||||
* Historical imports (drush migration scripts) deliberately preserve the
|
* Historical imports (drush migration scripts) deliberately preserve the
|
||||||
* source spreadsheets' raw data, including known répartition mismatches --
|
* source spreadsheets' raw data, including known répartition mismatches --
|
||||||
* those get surfaced as visible inconsistencies in the dashboard instead of
|
* those get surfaced as visible inconsistencies in the dashboard instead of
|
||||||
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state
|
* being silently fixed. Programmatic saves that need to bypass this check
|
||||||
* flag around such a bulk import to bypass this check; new lines entered by
|
* (bulk imports, the inline-edit endpoints, the bank statement import) wrap
|
||||||
* associates through the form are never exempted.
|
* their save() in the figli_compta_ledger.skip_validation_context service
|
||||||
|
* (request-scoped, see \Drupal\figli_compta_ledger\SkipValidationContext).
|
||||||
|
* The legacy 'figli_compta_ledger.skip_validation' *state* key still works
|
||||||
|
* for already-shipped migration scripts, but new code must use the service:
|
||||||
|
* the state key is a site-wide flag, and a concurrent normal form save
|
||||||
|
* hitting the same window would silently skip validation too (see
|
||||||
|
* AUDIT-2026-09-09.md §2.2). Either way this bypasses this check *and* the
|
||||||
|
* Montant TTC auto-computation below; new lines entered by associates
|
||||||
|
* through the form are never exempted from either. field_ecart is the one
|
||||||
|
* thing still kept in sync even under skip (see below) -- separately, a
|
||||||
|
* second state flag ('figli_compta_ledger.skip_revision') additionally
|
||||||
|
* suppresses the forced-revision block, used only by
|
||||||
|
* figli_compta_ledger_update_8010()'s field_ecart backfill.
|
||||||
*/
|
*/
|
||||||
function figli_compta_ledger_node_presave(NodeInterface $node) {
|
function figli_compta_ledger_node_presave(NodeInterface $node) {
|
||||||
if ($node->bundle() !== 'ligne_comptable') {
|
if ($node->bundle() !== 'ligne_comptable') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
|
|
||||||
return;
|
// Force a revision on every save, with no log message and the actual
|
||||||
|
// acting user as its author -- this is the audit trail for a shared
|
||||||
|
// ledger, not the répartition-sum check below, so it's never exempted
|
||||||
|
// (not even during a skip_validation import: migrated data still needs
|
||||||
|
// an honest revision history). The one deliberate exception:
|
||||||
|
// figli_compta_ledger_update_8010()'s one-off field_ecart backfill sets
|
||||||
|
// 'figli_compta_ledger.skip_revision' -- a purely computed diagnostic
|
||||||
|
// value being backfilled onto ~1500 already-migrated lines isn't an
|
||||||
|
// editorial change worth 1500 new revisions cluttering the very audit
|
||||||
|
// trail this exists to protect.
|
||||||
|
if (!\Drupal::state()->get('figli_compta_ledger.skip_revision', FALSE)) {
|
||||||
|
$node->setNewRevision(TRUE);
|
||||||
|
$node->setRevisionLogMessage('');
|
||||||
|
$node->setRevisionUserId(\Drupal::currentUser()->id());
|
||||||
|
// setNewRevision() alone doesn't refresh this -- it stays whatever it
|
||||||
|
// was on the entity as loaded (the previous revision's timestamp),
|
||||||
|
// silently mislabeling every edit with its predecessor's save time.
|
||||||
|
$node->setRevisionCreationTime(\Drupal::time()->getRequestTime());
|
||||||
}
|
}
|
||||||
if (\Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE)) {
|
|
||||||
|
if (!$node->hasField('field_montant_ht')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$skip_validation = \Drupal::service('figli_compta_ledger.skip_validation_context')->isSkipped()
|
||||||
|
|| \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE);
|
||||||
$montant_ht = (float) $node->get('field_montant_ht')->value;
|
$montant_ht = (float) $node->get('field_montant_ht')->value;
|
||||||
|
|
||||||
|
if (!$skip_validation) {
|
||||||
|
$tva = $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : 0.0;
|
||||||
|
$type = $node->hasField('field_type_ligne') ? $node->get('field_type_ligne')->value : NULL;
|
||||||
|
$cotisation_active = $node->hasField('field_cotisation_active') && !$node->get('field_cotisation_active')->isEmpty()
|
||||||
|
? (bool) $node->get('field_cotisation_active')->value : FALSE;
|
||||||
|
// Only "Entrée client" lines ever go through a client devis -- the
|
||||||
|
// only place the cotisation diffuseur is invoiced at all (see
|
||||||
|
// figli_compta_ledger_update_8006()'s docblock) -- so the checkbox is
|
||||||
|
// ignored for every other type even if it somehow ended up checked
|
||||||
|
// (e.g. a line's type changed after the fact).
|
||||||
|
$cotisation_applies = $type === 'entree' && $cotisation_active;
|
||||||
|
|
||||||
|
// Montant TTC is no longer entered by hand -- it's a pure function of
|
||||||
|
// Montant HT, TVA, and (entrée client only) the cotisation diffuseur
|
||||||
|
// URSSAF cascade (see figli_compta_ledger_update_8004()/_8006()). Only
|
||||||
|
// recompute it when HT, TVA, type, or the cotisation toggle actually
|
||||||
|
// changed, though, rather than on every save unconditionally: the TVA
|
||||||
|
// backfilled onto pre-migration lines (figli_compta_ledger_update_8005()/
|
||||||
|
// _8007()) is a best-effort reconstruction and doesn't reproduce every
|
||||||
|
// historical Montant TTC to the exact centime, so blindly recomputing
|
||||||
|
// on an unrelated edit (fixing a typo in Libellé, say) would silently
|
||||||
|
// nudge an untouched historical value -- exactly what this module's
|
||||||
|
// "never correct historical data" rule exists to prevent. A genuinely
|
||||||
|
// new/changed HT, TVA, type, or cotisation toggle has no such history
|
||||||
|
// to protect.
|
||||||
|
if ($node->hasField('field_montant_ttc')) {
|
||||||
|
$inputs_changed = $node->isNew();
|
||||||
|
if (!$inputs_changed && isset($node->original)) {
|
||||||
|
$original = $node->original;
|
||||||
|
$orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty()
|
||||||
|
? (float) $original->get('field_montant_ht')->value : NULL;
|
||||||
|
$orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty()
|
||||||
|
? (float) $original->get('field_tva')->value : 0.0;
|
||||||
|
$orig_type = $original->hasField('field_type_ligne') ? $original->get('field_type_ligne')->value : NULL;
|
||||||
|
$orig_cotisation_active = $original->hasField('field_cotisation_active') && !$original->get('field_cotisation_active')->isEmpty()
|
||||||
|
? (bool) $original->get('field_cotisation_active')->value : FALSE;
|
||||||
|
$inputs_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005
|
||||||
|
|| $orig_type !== $type || $orig_cotisation_active !== $cotisation_active;
|
||||||
|
}
|
||||||
|
if ($inputs_changed) {
|
||||||
|
if ($cotisation_applies) {
|
||||||
|
// field_cotisation_urssaf stores just the 1,1% itself (the
|
||||||
|
// delta), not Montant HT + 1,1% -- that's what "1,1%" as a
|
||||||
|
// column/field label means. TVA still applies to the augmented
|
||||||
|
// base though, so $base (used for Montant TTC just below) stays
|
||||||
|
// HT + delta; only what gets *written* to the field changes.
|
||||||
|
$delta = round($montant_ht * 0.011, 2);
|
||||||
|
$base = $montant_ht + $delta;
|
||||||
|
if ($node->hasField('field_cotisation_urssaf')) {
|
||||||
|
$node->set('field_cotisation_urssaf', $delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$base = $montant_ht;
|
||||||
|
if ($node->hasField('field_cotisation_urssaf')) {
|
||||||
|
$node->set('field_cotisation_urssaf', NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$node->set('field_montant_ttc', round($base * (1 + $tva / 100), 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$node->hasField('field_repartition')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$somme = 0.0;
|
$somme = 0.0;
|
||||||
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
|
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
|
||||||
if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) {
|
if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) {
|
||||||
@@ -126,6 +482,21 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ecart = round($montant_ht - $somme, 2);
|
$ecart = round($montant_ht - $somme, 2);
|
||||||
|
|
||||||
|
// field_ecart mirrors this sum -- kept in sync unconditionally,
|
||||||
|
// regardless of skip_validation (see figli_compta_ledger_update_8009()'s
|
||||||
|
// docblock): unlike Montant TTC, there's no historical value to protect
|
||||||
|
// here, the field's whole purpose is to reflect the *current*
|
||||||
|
// répartition state, not a preserved snapshot -- and it's what /lignes'
|
||||||
|
// "Écarts uniquement" filter will eventually filter on server-side.
|
||||||
|
if ($node->hasField('field_ecart')) {
|
||||||
|
$node->set('field_ecart', $ecart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($skip_validation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (abs($ecart) > 0.01) {
|
if (abs($ecart) > 0.01) {
|
||||||
throw new EntityStorageException(sprintf(
|
throw new EntityStorageException(sprintf(
|
||||||
"Répartition incohérente : la somme des comptes (%.2f €) ne correspond pas au montant HT (%.2f €). Écart : %.2f €. Corrigez la répartition avant d'enregistrer.",
|
"Répartition incohérente : la somme des comptes (%.2f €) ne correspond pas au montant HT (%.2f €). Écart : %.2f €. Corrigez la répartition avant d'enregistrer.",
|
||||||
@@ -142,16 +513,73 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
|
|||||||
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
|
||||||
return [
|
return [
|
||||||
'figli_compta_home' => [
|
'figli_compta_home' => [
|
||||||
'variables' => [],
|
'variables' => ['can_view_history' => FALSE, 'can_import_releve' => 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_repartition' => [
|
||||||
|
'variables' => ['current_route' => NULL],
|
||||||
|
'template' => 'figli-compta-dashboard-repartition',
|
||||||
|
],
|
||||||
|
'figli_compta_dashboard_compte' => [
|
||||||
|
'variables' => ['current_route' => NULL],
|
||||||
|
'template' => 'figli-compta-dashboard-compte',
|
||||||
|
],
|
||||||
|
'figli_compta_releve_import_result' => [
|
||||||
|
'variables' => ['summary' => [], 'lignes_url' => NULL, 'import_url' => NULL],
|
||||||
|
'template' => 'figli-compta-releve-import-result',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements hook_page_attachments().
|
||||||
|
*
|
||||||
|
* The admin top bar and sidebar (Gin/core Navigation chrome) are only
|
||||||
|
* hidden on this module's own front-end pages -- real Drupal admin pages
|
||||||
|
* (/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) {
|
||||||
|
$attachments['#attached']['library'][] = 'figli_compta_ledger/admin_chrome';
|
||||||
|
|
||||||
|
$front_end_routes = [
|
||||||
|
'figli_compta_ledger.home',
|
||||||
|
'figli_compta_ledger.dashboard',
|
||||||
|
'figli_compta_ledger.dashboard_repartition',
|
||||||
|
'figli_compta_ledger.dashboard_compte',
|
||||||
|
'figli_compta_ledger.history',
|
||||||
|
'figli_compta_ledger.link_entree',
|
||||||
|
'figli_compta_ledger.releve_import_form',
|
||||||
|
'figli_compta_ledger.releve_import_result',
|
||||||
|
];
|
||||||
|
if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
|
||||||
|
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements hook_gin_ignore_sticky_form_actions().
|
||||||
|
*
|
||||||
|
* Gin's sticky action buttons (forced on whenever the core Navigation
|
||||||
|
* module is active, as here) relocate a form's primary submit into the
|
||||||
|
* Gin chrome's sticky action bar -- the very chrome this module's
|
||||||
|
* front-end routes deliberately hide ($front_end_routes in
|
||||||
|
* figli_compta_ledger_page_attachments()). Without this opt-out, the
|
||||||
|
* full-page import form ends up with no visible button at all: Gin's
|
||||||
|
* after-build moves "Importer le relevé" into the hidden bar, leaving
|
||||||
|
* only managed_file's own inline "Remove" button. The other forms of
|
||||||
|
* this module don't need it -- node forms and LinkEntreeForm are opened
|
||||||
|
* in modals, which Gin skips by itself (isModalOrOffcanvas()).
|
||||||
|
*/
|
||||||
|
function figli_compta_ledger_gin_ignore_sticky_form_actions(): array {
|
||||||
|
return ['figli_compta_ledger_releve_upload_form'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements hook_help().
|
* Implements hook_help().
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
access figli ledger:
|
||||||
|
title: 'Accéder au grand livre'
|
||||||
|
description: 'Consulte les lignes comptables, les tableaux de bord et toutes les API du module (données financières de la SAS).'
|
||||||
|
restrict access: true
|
||||||
|
|
||||||
|
import ligne_comptable releve:
|
||||||
|
title: 'Importer un relevé bancaire'
|
||||||
|
description: 'Téléverse un export CSV bancaire et crée des lignes comptables brouillon à trier (crée du contenu).'
|
||||||
|
restrict access: true
|
||||||
@@ -4,7 +4,7 @@ figli_compta_ledger.home:
|
|||||||
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
|
||||||
_title: 'Grand livre - SAS Figures Libres'
|
_title: 'Grand livre - SAS Figures Libres'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
figli_compta_ledger.dashboard:
|
figli_compta_ledger.dashboard:
|
||||||
path: '/dashboard'
|
path: '/dashboard'
|
||||||
@@ -12,4 +12,119 @@ figli_compta_ledger.dashboard:
|
|||||||
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
|
||||||
_title: 'Tableau de bord - SAS Figures Libres'
|
_title: 'Tableau de bord - SAS Figures Libres'
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
|
figli_compta_ledger.dashboard_repartition:
|
||||||
|
path: '/dashboard/repartition'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView'
|
||||||
|
_title: 'Répartition / Soldes - SAS Figures Libres'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
|
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 figli ledger'
|
||||||
|
|
||||||
|
figli_compta_ledger.history:
|
||||||
|
path: '/lignes/historique'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\HistoryController::history'
|
||||||
|
_title: 'Historique du grand livre - SAS Figures Libres'
|
||||||
|
requirements:
|
||||||
|
_permission: 'view ligne_comptable revisions'
|
||||||
|
|
||||||
|
figli_compta_ledger.link_entree:
|
||||||
|
path: '/lignes/{node}/lier'
|
||||||
|
defaults:
|
||||||
|
_form: '\Drupal\figli_compta_ledger\Form\LinkEntreeForm'
|
||||||
|
_title: 'Lier à une entrée client'
|
||||||
|
requirements:
|
||||||
|
_entity_access: 'node.update'
|
||||||
|
options:
|
||||||
|
parameters:
|
||||||
|
node:
|
||||||
|
type: entity:node
|
||||||
|
|
||||||
|
figli_compta_ledger.api_annees:
|
||||||
|
path: '/lignes/api/annees'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
|
figli_compta_ledger.api_reconciliation_ouverture:
|
||||||
|
path: '/lignes/api/reconciliation-ouverture'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
|
figli_compta_ledger.api_groupe_entree:
|
||||||
|
path: '/lignes/api/groupe/{node}'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access figli ledger'
|
||||||
|
options:
|
||||||
|
parameters:
|
||||||
|
node:
|
||||||
|
type: entity:node
|
||||||
|
|
||||||
|
figli_compta_ledger.api_lignes:
|
||||||
|
path: '/lignes/api/lignes'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
|
figli_compta_ledger.api_dashboard_stats:
|
||||||
|
path: '/dashboard/api/stats'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access figli ledger'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
figli_compta_ledger.releve_import_form:
|
||||||
|
path: '/lignes/importer-releve'
|
||||||
|
defaults:
|
||||||
|
_form: '\Drupal\figli_compta_ledger\Form\ReleveUploadForm'
|
||||||
|
_title: 'Importer un relevé bancaire'
|
||||||
|
requirements:
|
||||||
|
_permission: 'import ligne_comptable releve'
|
||||||
|
|
||||||
|
figli_compta_ledger.releve_import_result:
|
||||||
|
path: '/lignes/importer-releve/resultat'
|
||||||
|
defaults:
|
||||||
|
_controller: '\Drupal\figli_compta_ledger\Controller\ReleveImportResultController::result'
|
||||||
|
_title: "Résultat de l'import du relevé"
|
||||||
|
requirements:
|
||||||
|
_permission: 'import ligne_comptable releve'
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
figli_compta_ledger.route_subscriber:
|
||||||
|
class: Drupal\figli_compta_ledger\EventSubscriber\RouteSubscriber
|
||||||
|
tags:
|
||||||
|
- { name: event_subscriber }
|
||||||
|
|
||||||
|
# Request-scoped répartition-check opt-out (see the class docblock: unlike
|
||||||
|
# the historical state key, a skip held here is invisible to concurrent
|
||||||
|
# requests -- audited in AUDIT-2026-09-09.md §2.2).
|
||||||
|
figli_compta_ledger.skip_validation_context:
|
||||||
|
class: Drupal\figli_compta_ledger\SkipValidationContext
|
||||||
|
|
||||||
|
figli_compta_ledger.client_matcher:
|
||||||
|
class: Drupal\figli_compta_ledger\Import\ClientMatcher
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifts the [data-drupal-messages] region to a direct <body> child.
|
||||||
|
*
|
||||||
|
* Gin renders it deep inside its layout (main.page-content > region
|
||||||
|
* highlighted), where ancestor stacking contexts neutralize the
|
||||||
|
* region's position:fixed + high z-index (admin-chrome.css): the whole
|
||||||
|
* subtree then stacks below the jQuery UI modal overlay -- a direct
|
||||||
|
* <body> child -- which is why messages inserted while a modal is open
|
||||||
|
* (MessageCommand-driven, e.g. the répartition-sum error on the
|
||||||
|
* ligne_comptable modal form) rendered unreadably *under* the overlay.
|
||||||
|
* As a <body> child, the region's z-index competes directly with the
|
||||||
|
* overlay/dialog and wins. Core itself puts the fallback wrapper at
|
||||||
|
* body level (Drupal.Message.defaultWrapper(), misc/message.js), so
|
||||||
|
* this is also where messages already land on a page without the
|
||||||
|
* region; MessageCommand re-queries the region at response time, so
|
||||||
|
* moving it after page load breaks no insertion path.
|
||||||
|
*/
|
||||||
|
function liftMessages() {
|
||||||
|
var region = document.querySelector('[data-drupal-messages]');
|
||||||
|
if (region && region.parentElement !== document.body) {
|
||||||
|
document.body.appendChild(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
liftMessages();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// A modal (or a re-rendered messages region) entering the DOM:
|
||||||
|
// re-check the lift right when it matters -- the region must
|
||||||
|
// already be body-level when the dialog's overlay and any
|
||||||
|
// MessageCommand insertions show up. matches() first because
|
||||||
|
// querySelector() never matches the node itself. liftMessages()
|
||||||
|
// is idempotent, so over-triggering is harmless.
|
||||||
|
if (node.matches('.ui-dialog, [data-drupal-messages]')
|
||||||
|
|| node.querySelector('.ui-dialog, [data-drupal-messages]')) {
|
||||||
|
liftMessages();
|
||||||
|
}
|
||||||
|
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);
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
* "Répartition / Soldes": solde par compte (all-time bar chart + one
|
||||||
|
* year-by-year trend per compte), split out of the general /dashboard so
|
||||||
|
* that page stays focused on activity/CA/type/client breakdowns. Same
|
||||||
|
* /dashboard/api/stats endpoint as dashboard.js/dashboard-compte.js
|
||||||
|
* (DashboardStatsController -- plain SQL GROUP BY, not Entity API), no
|
||||||
|
* new backend needed -- solde_par_compte/solde_par_compte_par_annee were
|
||||||
|
* already in that response, just unused on this page until now.
|
||||||
|
*
|
||||||
|
* HBarChart/MiniTrend are duplicated from dashboard.js rather than
|
||||||
|
* shared, same established convention as dashboard-compte.js's own
|
||||||
|
* copies (see dashboard.css's comment on the Signalement filter rules).
|
||||||
|
* Root element id is deliberately the same #figli-dashboard-app as the
|
||||||
|
* other two dashboard pages (not a unique id) -- dashboard.css scopes
|
||||||
|
* its CSS custom properties (--figli-bg, dark-mode overrides, etc.) to
|
||||||
|
* that selector, and reusing it is how all three dashboard pages pick
|
||||||
|
* those up without a separate stylesheet.
|
||||||
|
*/
|
||||||
|
(function (Drupal, Vue) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal bar chart -- same shape as dashboard.js's own copy,
|
||||||
|
// including the zero-centered "diverging" layout for negative values
|
||||||
|
// (a compte's solde can be a débit).
|
||||||
|
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>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small multiples: one compact zero-centered bar-per-year trend per
|
||||||
|
// compte, instead of a single 8-series line chart -- same reasoning
|
||||||
|
// and same markup as dashboard.js's own copy.
|
||||||
|
const MiniTrend = {
|
||||||
|
props: {
|
||||||
|
annees: { type: Array, required: true },
|
||||||
|
values: { type: Array, required: true },
|
||||||
|
formatValue: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
maxAbs() {
|
||||||
|
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
barHeight(v) {
|
||||||
|
if (v === null) return '0%';
|
||||||
|
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<div class="figli-mini-trend">' +
|
||||||
|
'<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>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = {
|
||||||
|
components: { HBarChart, MiniTrend },
|
||||||
|
data() {
|
||||||
|
return { loading: true, error: null, stats: null };
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatEurRound(v) {
|
||||||
|
return EUR_ROUND.format(v);
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
this.stats = await fetchStats();
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Drupal.behaviors.figliComptaDashboardRepartition = {
|
||||||
|
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,288 @@
|
|||||||
/**
|
/**
|
||||||
* @file
|
* @file
|
||||||
* Aggregate dashboard: solde par compte / solde par client, computed
|
* Dashboard: charts and aggregate totals (chiffre d'affaires par année,
|
||||||
* client-side from JSON:API. The line-by-line spreadsheet view is the
|
* répartition par type, top clients), computed server-side
|
||||||
* site's home page (home.js), not this one.
|
* (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. Solde par
|
||||||
|
* compte lives on its own page (js/dashboard-repartition.js).
|
||||||
*/
|
*/
|
||||||
(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 },
|
||||||
|
// 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. Same prop as dashboard-compte.js's own HBarChart
|
||||||
|
// copy (this project duplicates the component rather than sharing
|
||||||
|
// it between dashboard.js/dashboard-compte.js, see dashboard.css).
|
||||||
|
compact: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
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" :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" :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 },
|
||||||
function computeAggregations(data, includedMap) {
|
},
|
||||||
const parComptes = new Map();
|
computed: {
|
||||||
const parClients = new Map();
|
max() {
|
||||||
for (const node of data) {
|
return Math.max(1, ...this.items.map((i) => i.value));
|
||||||
const rels = node.relationships || {};
|
},
|
||||||
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
},
|
||||||
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
|
methods: {
|
||||||
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
barHeight(item) {
|
||||||
for (const ref of repartitionRefs) {
|
return Math.max(2, (item.value / this.max) * 100) + '%';
|
||||||
const paragraph = resolve(includedMap, ref);
|
},
|
||||||
if (!paragraph) continue;
|
},
|
||||||
const montant = parseFloat(paragraph.attributes.field_montant || 0);
|
template:
|
||||||
const compteTerm = resolve(includedMap, paragraph.relationships && paragraph.relationships.field_compte && paragraph.relationships.field_compte.data);
|
'<div class="figli-vbar-chart">' +
|
||||||
const compteName = compteTerm ? compteTerm.attributes.name : '(compte inconnu)';
|
'<div class="figli-vbar-col" v-for="item in items" :key="item.label">' +
|
||||||
addTo(parComptes, compteName, montant);
|
'<div class="figli-vbar-value">{{ formatValue(item.value) }}</div>' +
|
||||||
addTo(parClients, clientName, montant);
|
'<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>' +
|
||||||
return { parComptes, parClients };
|
'</div>',
|
||||||
}
|
};
|
||||||
|
|
||||||
function mapToRows(map) {
|
|
||||||
return Array.from(map.entries())
|
|
||||||
.map(([name, v]) => ({ name, entrees: v.entrees, sorties: v.sorties, solde: v.entrees + v.sorties }))
|
|
||||||
.sort((a, b) => a.solde - b.solde);
|
|
||||||
}
|
|
||||||
|
|
||||||
function totalsOf(rows) {
|
|
||||||
return rows.reduce(
|
|
||||||
(acc, r) => ({ entrees: acc.entrees + r.entrees, sorties: acc.sorties + r.sorties, solde: acc.solde + r.solde }),
|
|
||||||
{ entrees: 0, sorties: 0, solde: 0 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const App = {
|
const App = {
|
||||||
|
components: { HBarChart, ColumnChart: VBarChart },
|
||||||
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 }));
|
||||||
|
},
|
||||||
|
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 }));
|
||||||
|
},
|
||||||
|
// Small multiples, one per year -- same source as typeItems() above
|
||||||
|
// (total_par_type_par_annee is the same node-level SQL query, just
|
||||||
|
// also grouped by année, no extra request). Mirrors
|
||||||
|
// dashboard-compte.js's typeItemsParAnnee, but that one filters a
|
||||||
|
// full row list client-side (it has one, scoped to a single
|
||||||
|
// compte); this page never fetches full rows (see this file's
|
||||||
|
// docblock), so the per-année breakdown has to already be
|
||||||
|
// pre-aggregated server-side.
|
||||||
|
typeItemsParAnnee() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const parType = this.stats.total_par_type_par_annee[annee] || {};
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
// Same idea for topClientsItems() -- top_clients_par_annee is
|
||||||
|
// already capped to 8 per year server-side (see
|
||||||
|
// DashboardStatsController::stats()), same reasoning as
|
||||||
|
// dashboard-compte.js capping its own per-année version to 5.
|
||||||
|
topClientsParAnnee() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const items = (this.stats.top_clients_par_annee[annee] || [])
|
||||||
|
.map((c) => ({ label: c.client, value: c.ca }));
|
||||||
|
return { annee, items };
|
||||||
|
}).filter((y) => y.items.length);
|
||||||
|
},
|
||||||
|
// Charges structurelles by client (i.e. by vendor: loyer,
|
||||||
|
// assurance, hébergement, URSSAF...) -- sorted by value descending
|
||||||
|
// server-side already (no alphabetical option asked for here,
|
||||||
|
// unlike the versements-par-compte chart), no top-N cap needed
|
||||||
|
// (see DashboardStatsController::stats()'s own comment -- only a
|
||||||
|
// handful of distinct vendors ever show up on a "charge" line).
|
||||||
|
// `type: 'charge'` on every item is just so colorFor="typeColor"
|
||||||
|
// (same method the type-breakdown chart uses) paints every bar the
|
||||||
|
// same "charge" grey -- every row here is that type by definition.
|
||||||
|
chargeParClientItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.charge_par_client.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
|
||||||
|
},
|
||||||
|
chargeParClientParAnnee() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const items = (this.stats.charge_par_client_par_annee[annee] || [])
|
||||||
|
.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
|
||||||
|
return { annee, items };
|
||||||
|
}).filter((y) => y.items.length);
|
||||||
|
},
|
||||||
|
// total_par_type_par_compte is already in the stats response --
|
||||||
|
// it's the same répartition-level query /dashboard/compte uses for
|
||||||
|
// its own (single-compte) type breakdown, just never sliced down
|
||||||
|
// to one type across every compte before now. No new backend
|
||||||
|
// field needed, just pull out the "versement" entry per compte.
|
||||||
|
// Salaire/stage and sous-traitant are the two other ways money
|
||||||
|
// leaves the collective toward a person/entity outside the 6
|
||||||
|
// comptes associés -- added as two extra rows (not split per
|
||||||
|
// compte, there's only one meaningful total each) so the chart
|
||||||
|
// reads as "who/what actually got paid", not just the 6 associés.
|
||||||
|
// Sourced from total_par_type (all-time)/total_par_type_par_annee,
|
||||||
|
// same fields typeItems()/typeItemsParAnnee() above already use.
|
||||||
|
// `type` on every item is what colorFor="typeColor" keys off of
|
||||||
|
// (same typeColor() method typeItems() already feeds) -- the 6
|
||||||
|
// compte rows are coloured as "versement" (they're that same
|
||||||
|
// money, just broken down by recipient instead of summed), and
|
||||||
|
// the two extra rows keep their own type colour.
|
||||||
|
versementsParCompteItems() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
// Alphabetical among the 6 comptes, but Salaire/stage and
|
||||||
|
// Sous-traitant always pushed on afterward -- they're not
|
||||||
|
// comptes, so they stay out of that ordering and just close out
|
||||||
|
// the list, in that fixed order.
|
||||||
|
const items = Object.entries(this.stats.total_par_type_par_compte)
|
||||||
|
.filter(([, parType]) => parType.versement !== undefined)
|
||||||
|
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
|
||||||
|
const parType = this.stats.total_par_type || {};
|
||||||
|
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
|
||||||
|
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
// Small multiples, one per year -- same source as typeItemsParAnnee
|
||||||
|
// above (total_par_type_par_compte_par_annee, already there for
|
||||||
|
// /dashboard/compte's own per-année breakdown).
|
||||||
|
versementsParCompteParAnnee() {
|
||||||
|
if (!this.stats) return [];
|
||||||
|
return this.stats.annees.map((annee) => {
|
||||||
|
const parCompte = this.stats.total_par_type_par_compte_par_annee[annee] || {};
|
||||||
|
const items = Object.entries(parCompte)
|
||||||
|
.filter(([, parType]) => parType.versement !== undefined)
|
||||||
|
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
|
||||||
|
const parType = this.stats.total_par_type_par_annee[annee] || {};
|
||||||
|
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
|
||||||
|
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
|
||||||
|
return { annee, items };
|
||||||
|
}).filter((y) => y.items.length);
|
||||||
|
},
|
||||||
|
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 '';
|
typeColor(item) {
|
||||||
|
return TYPE_COLORS[item.type] || '#6b7280';
|
||||||
|
},
|
||||||
|
// Sum of an hbar chart's own items -- for the "Total" line under
|
||||||
|
// Charges structurelles par client (main chart and each per-année
|
||||||
|
// card): those items are already whatever's actually plotted, so
|
||||||
|
// summing them directly (rather than a separate stats field) keeps
|
||||||
|
// the total consistent with the bars above it by construction.
|
||||||
|
sumItems(items) {
|
||||||
|
return items.reduce((sum, i) => sum + i.value, 0);
|
||||||
},
|
},
|
||||||
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
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
* Live preview of Montant TTC = Montant HT * (1 + TVA/100) on the ligne
|
||||||
|
* comptable add/edit form -- purely cosmetic, so the associate sees the
|
||||||
|
* computed amount before saving instead of a blank/stale readonly field.
|
||||||
|
* The authoritative computation is server-side, in
|
||||||
|
* figli_compta_ledger_node_presave() (figli_compta_ledger.module) --
|
||||||
|
* whatever ends up in this input on submit is discarded and recomputed
|
||||||
|
* there regardless of whether this script even ran.
|
||||||
|
*/
|
||||||
|
(function (Drupal, once) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
Drupal.behaviors.figliLedgerTvaCalc = {
|
||||||
|
attach: function (context) {
|
||||||
|
// once()'s selector match runs through context.querySelectorAll(),
|
||||||
|
// which -- like any querySelectorAll -- never matches the context
|
||||||
|
// node itself, only its descendants. The AJAX dialog opening this
|
||||||
|
// form passes the <form class="figli-ledger-form"> element itself
|
||||||
|
// as context, so a selector of '.figli-ledger-form' silently
|
||||||
|
// matched nothing and this behavior never ran. Selecting the HT
|
||||||
|
// input directly sidesteps that: it's always a genuine descendant
|
||||||
|
// of whatever context gets passed (document on a full page load,
|
||||||
|
// the form itself from the dialog, or anything in between).
|
||||||
|
once('figli-ledger-tva-calc', '[name="field_montant_ht[0][value]"]', context).forEach(function (ht) {
|
||||||
|
var form = ht.closest('form');
|
||||||
|
var tva = form.querySelector('[name="field_tva[0][value]"]');
|
||||||
|
var rate = form.querySelector('[name="field_tva_rate"]');
|
||||||
|
var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]');
|
||||||
|
var type = form.querySelector('[name="field_type_ligne"]');
|
||||||
|
var cotisationActive = form.querySelector('[name="field_cotisation_active[value]"]');
|
||||||
|
var cotisationAmount = form.querySelector('[name="field_cotisation_urssaf[0][value]"]');
|
||||||
|
if (!tva || !ttc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rate select (when not "Autre") is the actual source of
|
||||||
|
// truth here -- figli_compta_ledger_apply_tva_rate() copies its
|
||||||
|
// value into field_tva server-side on submit, but that happens
|
||||||
|
// too late to matter for a live client-side preview.
|
||||||
|
function currentTva() {
|
||||||
|
if (rate && rate.value !== 'autre') {
|
||||||
|
return parseFloat(rate.value);
|
||||||
|
}
|
||||||
|
return parseFloat(tva.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors figli_compta_ledger_node_presave()'s $cotisation_applies:
|
||||||
|
// only "Entrée client" lines with the checkbox on ever go through
|
||||||
|
// the +1,1% step before TVA.
|
||||||
|
function cotisationApplies() {
|
||||||
|
return !!type && type.value === 'entree' && !!cotisationActive && cotisationActive.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recompute() {
|
||||||
|
var htValue = parseFloat(ht.value);
|
||||||
|
if (isNaN(htValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var tvaValue = currentTva();
|
||||||
|
if (isNaN(tvaValue)) {
|
||||||
|
tvaValue = 0;
|
||||||
|
}
|
||||||
|
var base = htValue;
|
||||||
|
if (cotisationApplies()) {
|
||||||
|
// Displayed/stored value is just the 1,1% itself (the
|
||||||
|
// delta), not HT + 1,1% -- but TVA still applies to the
|
||||||
|
// augmented base, so $base for the TTC calc below stays
|
||||||
|
// htValue + delta regardless.
|
||||||
|
var delta = Math.round(htValue * 0.011 * 100) / 100;
|
||||||
|
base = htValue + delta;
|
||||||
|
if (cotisationAmount) {
|
||||||
|
cotisationAmount.value = delta.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (cotisationAmount) {
|
||||||
|
cotisationAmount.value = '';
|
||||||
|
}
|
||||||
|
ttc.value = (base * (1 + tvaValue / 100)).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately not called once up front on an existing (edit)
|
||||||
|
// line: the stored Montant TTC may predate this computed-field
|
||||||
|
// architecture and not be perfectly reproducible from HT/TVA to
|
||||||
|
// the centime (see figli_compta_ledger_update_8005()'s
|
||||||
|
// docblock) -- showing a recomputed number the instant the
|
||||||
|
// modal opens, before the associate has touched anything, would
|
||||||
|
// misrepresent what's actually saved. Only recompute once an
|
||||||
|
// actual input happens, matching the same "only overwrite
|
||||||
|
// Montant TTC when an input actually changed" guard in
|
||||||
|
// figli_compta_ledger_node_presave(). Harmless no-op on a
|
||||||
|
// genuinely new/blank line either way (HT is empty, recompute()
|
||||||
|
// bails out above).
|
||||||
|
ht.addEventListener('input', recompute);
|
||||||
|
tva.addEventListener('input', recompute);
|
||||||
|
if (rate) {
|
||||||
|
rate.addEventListener('change', recompute);
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
type.addEventListener('change', recompute);
|
||||||
|
}
|
||||||
|
if (cotisationActive) {
|
||||||
|
cotisationActive.addEventListener('change', recompute);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows/hides the real "Taux exact (%)" field_tva input based on the
|
||||||
|
* field_tva_rate select added in figli_compta_ledger_form_alter() --
|
||||||
|
* plain JS rather than #states: tested and confirmed #states' own
|
||||||
|
* data-drupal-states attribute gets attached correctly here but never
|
||||||
|
* actually toggles visibility, for reasons not tracked down (a
|
||||||
|
* working #states dependency already exists a few fields up, on
|
||||||
|
* field_entree_liee, watching a real Field API element rather than
|
||||||
|
* this manually-added select -- that one difference is the only lead
|
||||||
|
* so far). Not worth blocking on since this form already needs custom
|
||||||
|
* JS anyway for the TTC live preview above.
|
||||||
|
*/
|
||||||
|
Drupal.behaviors.figliLedgerTvaRateToggle = {
|
||||||
|
attach: function (context) {
|
||||||
|
once('figli-ledger-tva-rate-toggle', '[name="field_tva_rate"]', context).forEach(function (select) {
|
||||||
|
var form = select.closest('form');
|
||||||
|
var exact = form.querySelector('.field--name-field-tva');
|
||||||
|
if (!exact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
exact.style.display = select.value === 'autre' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
select.addEventListener('change', toggle);
|
||||||
|
toggle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répartition assistée (see PLAN-repartition-assistee.md): auto-fills
|
||||||
|
* the répartition amounts so the associate never does the small
|
||||||
|
* arithmetic by hand. Purely client-side pre-filling -- the server
|
||||||
|
* stays the authority (figli_compta_ledger_validate_repartition() +
|
||||||
|
* figli_compta_ledger_node_presave() unchanged), same philosophy as
|
||||||
|
* the TTC live preview above: with JS off, the form behaves exactly
|
||||||
|
* as before.
|
||||||
|
*
|
||||||
|
* Rules (plan §Comportement):
|
||||||
|
* - a value loaded from the database, or typed by the user, is
|
||||||
|
* "figée" (pinned) and never moved again;
|
||||||
|
* - the remaining rows split (HT − somme(figées)) to the centime,
|
||||||
|
* rounding remainders carried by the later rows, 0 when the
|
||||||
|
* remainder is negative;
|
||||||
|
* - triggers: row added/removed (Paragraphs AJAX re-render ->
|
||||||
|
* behavior attach), HT change, manual amount edit;
|
||||||
|
* - live "Réparti / Écart" line in the widget's title cell: green when
|
||||||
|
* the écart is zero to the centime, red otherwise (a 0.01 écart is
|
||||||
|
* savable -- blocking stays at 0.01 server-side -- but is shown,
|
||||||
|
* same as /lignes does).
|
||||||
|
*
|
||||||
|
* "Manual vs assist" detection rests on a DOM property: assigning
|
||||||
|
* input.value programmatically fires no event, only a real user edit
|
||||||
|
* fires 'input'. Locks therefore survive the widget's full AJAX
|
||||||
|
* re-renders (every add/remove rebuilds the inputs) because they live
|
||||||
|
* in this closure, keyed by input name (deltas are stable -- drag
|
||||||
|
* handles are hidden, order never matters); the assisted{} memory is
|
||||||
|
* what makes a never-assisted value (i.e. anything loaded from the
|
||||||
|
* database) figée by construction, and keeps a stale lock harmless
|
||||||
|
* after a row deletion reindexes the deltas.
|
||||||
|
*/
|
||||||
|
Drupal.behaviors.figliLedgerRepartition = {
|
||||||
|
attach: function (context) {
|
||||||
|
// Behaviors attach on every AJAX response with the replaced
|
||||||
|
// fragment as context -- the form itself only once (which is when
|
||||||
|
// the delegated listener below is set up), any later attach is a
|
||||||
|
// Paragraphs re-render and just needs a refresh. Like the TVA
|
||||||
|
// behavior's comment explains, context may BE the form (modal) or
|
||||||
|
// a fragment INSIDE it (add/remove) -- collect all three cases,
|
||||||
|
// then dedupe through once().
|
||||||
|
var ctx = (context && (context.nodeType === 1 || context.nodeType === 9)) ? context : document;
|
||||||
|
var forms = [];
|
||||||
|
if (ctx.nodeType === 1 && ctx.matches('.figli-ledger-form')) {
|
||||||
|
forms.push(ctx);
|
||||||
|
}
|
||||||
|
if (ctx.querySelectorAll) {
|
||||||
|
Array.prototype.forEach.call(ctx.querySelectorAll('.figli-ledger-form'), function (f) {
|
||||||
|
forms.push(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!forms.length && ctx.nodeType === 1 && ctx.closest) {
|
||||||
|
var ancestor = ctx.closest('.figli-ledger-form');
|
||||||
|
if (ancestor) {
|
||||||
|
forms.push(ancestor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forms.forEach(function (form) {
|
||||||
|
if (once('figli-ledger-repartition', form).length) {
|
||||||
|
form.figliLedgerRepartition = initRepartitionAssist(form);
|
||||||
|
}
|
||||||
|
if (form.figliLedgerRepartition) {
|
||||||
|
form.figliLedgerRepartition.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function initRepartitionAssist(form) {
|
||||||
|
// input.name -> true (manual edit at some point, never move again).
|
||||||
|
var figees = {};
|
||||||
|
// input.name -> last value WE wrote (the assist's own writes).
|
||||||
|
var assisted = {};
|
||||||
|
|
||||||
|
var MONTANT_SEL = 'input[name$="[field_montant][0][value]"]';
|
||||||
|
var HT_NAME = 'field_montant_ht[0][value]';
|
||||||
|
|
||||||
|
function round2(x) {
|
||||||
|
return Math.round(x * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eur(x) {
|
||||||
|
return (x < 0 ? '-' : '') + Math.abs(x).toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function widget() {
|
||||||
|
return form.querySelector('.field--name-field-repartition');
|
||||||
|
}
|
||||||
|
|
||||||
|
function amounts() {
|
||||||
|
var w = widget();
|
||||||
|
if (!w) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.prototype.slice.call(w.querySelectorAll(MONTANT_SEL));
|
||||||
|
}
|
||||||
|
|
||||||
|
function htValue() {
|
||||||
|
var ht = form.querySelector('[name="' + HT_NAME + '"]');
|
||||||
|
var v = ht ? parseFloat(ht.value) : NaN;
|
||||||
|
return isNaN(v) ? NaN : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The écart lives inside the table's own th.field-label ("Répartition"
|
||||||
|
// heading cell), next to the Collapse-all menu -- requested layout:
|
||||||
|
// everything the réparation needs to know, in the table's title bar.
|
||||||
|
// That puts it INSIDE the widget's AJAX re-render zone (every
|
||||||
|
// add/remove rebuilds the table), so the element gets wiped and
|
||||||
|
// recreated on each refresh -- exactly what refresh() below does.
|
||||||
|
function ecartRow() {
|
||||||
|
var existing = form.querySelector('.figli-repartition-ecart');
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
var w = widget();
|
||||||
|
var th = w ? w.querySelector('th.field-label') : null;
|
||||||
|
if (!th) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'figli-repartition-ecart';
|
||||||
|
span.innerHTML = '<span class="figli-repartition-ecart-sum"></span> · <span class="figli-repartition-ecart-val"></span>';
|
||||||
|
th.appendChild(span);
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAssisted(input, value) {
|
||||||
|
var s = value.toFixed(2);
|
||||||
|
if (input.value !== s) {
|
||||||
|
input.value = s;
|
||||||
|
}
|
||||||
|
assisted[input.name] = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var row = ecartRow();
|
||||||
|
var inputs = amounts();
|
||||||
|
if (row) {
|
||||||
|
// Hidden until the first répartition exists, wiped with the rest
|
||||||
|
// of the table on add/remove -- re-created on the next refresh.
|
||||||
|
row.style.display = inputs.length ? '' : 'none';
|
||||||
|
}
|
||||||
|
if (!inputs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ht = htValue();
|
||||||
|
|
||||||
|
// Classify + mark figées + sum their values.
|
||||||
|
var sommeFigees = 0;
|
||||||
|
var libres = [];
|
||||||
|
inputs.forEach(function (input) {
|
||||||
|
var value = input.value.trim();
|
||||||
|
var figee = !!figees[input.name]
|
||||||
|
|| (value !== '' && String(assisted[input.name]) !== value);
|
||||||
|
var item = input.closest('.form-item');
|
||||||
|
if (item) {
|
||||||
|
item.classList.toggle('figli-repartition-locked', figee);
|
||||||
|
}
|
||||||
|
if (figee) {
|
||||||
|
var v = parseFloat(value);
|
||||||
|
if (!isNaN(v)) {
|
||||||
|
sommeFigees = round2(sommeFigees + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
libres.push(input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Distribute: sequential split, each row gets round(restant /
|
||||||
|
// restantes), remainder carried by the later rows -- sum exact to
|
||||||
|
// the centime (a naive HT/n leaves 0,03€ of écart on 3 rows).
|
||||||
|
var restant = isNaN(ht) ? NaN : round2(ht - sommeFigees);
|
||||||
|
libres.forEach(function (input, i) {
|
||||||
|
if (isNaN(restant)) {
|
||||||
|
// No HT to distribute yet: clear our own previous writes only.
|
||||||
|
if (input.value !== '' && String(assisted[input.name]) === input.value) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var n = libres.length - i;
|
||||||
|
var v = restant <= 0 ? 0 : round2(restant / n);
|
||||||
|
writeAssisted(input, v);
|
||||||
|
restant = round2(restant - v);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live écart, mirroring the server exactly (same rounding, same
|
||||||
|
// 0.01 tolerance as figli_compta_ledger_node_presave()).
|
||||||
|
var somme = 0;
|
||||||
|
inputs.forEach(function (input) {
|
||||||
|
var v = parseFloat(input.value);
|
||||||
|
if (!isNaN(v)) {
|
||||||
|
somme = round2(somme + v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var ecart = isNaN(ht) ? NaN : round2(ht - somme);
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.querySelector('.figli-repartition-ecart-sum').textContent = 'Réparti : ' + eur(somme);
|
||||||
|
var val = row.querySelector('.figli-repartition-ecart-val');
|
||||||
|
val.textContent = isNaN(ecart) ? 'Écart : —' : 'Écart : ' + eur(ecart);
|
||||||
|
// Green only when the écart is zero to the centime: a 0.01
|
||||||
|
// mismatch is still savable (the server's blocking threshold
|
||||||
|
// stays at 0.01) but it IS an écart -- signaled here in red,
|
||||||
|
// exactly as /lignes now displays it (LedgerRowsController's
|
||||||
|
// hasError). 0.005 guards against float representations of
|
||||||
|
// centime values.
|
||||||
|
row.classList.toggle('is-ok', !isNaN(ecart) && Math.abs(ecart) <= 0.005);
|
||||||
|
row.classList.toggle('is-ko', !isNaN(ecart) && Math.abs(ecart) > 0.005);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One delegated listener on the form (survives every Paragraphs
|
||||||
|
// re-render, unlike per-input listeners). Programmatic writes above
|
||||||
|
// fire no event, so any 'input' seen here IS a human edit: lock it
|
||||||
|
// -- or unlock when cleared, so an emptied row becomes free again.
|
||||||
|
form.addEventListener('input', function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (!t.matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.matches(MONTANT_SEL)) {
|
||||||
|
if (t.value.trim() === '') {
|
||||||
|
delete figees[t.name];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
figees[t.name] = true;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else if (t.name === HT_NAME) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { refresh: refresh };
|
||||||
|
}
|
||||||
|
})(Drupal, once);
|
||||||
@@ -17,6 +17,9 @@ class DashboardController extends ControllerBase {
|
|||||||
public function home() {
|
public function home() {
|
||||||
return [
|
return [
|
||||||
'#theme' => 'figli_compta_home',
|
'#theme' => 'figli_compta_home',
|
||||||
|
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
|
||||||
|
'#can_import_releve' => $this->currentUser()->hasPermission('import ligne_comptable releve'),
|
||||||
|
'#current_route' => 'figli_compta_ledger.home',
|
||||||
'#attached' => [
|
'#attached' => [
|
||||||
'library' => ['figli_compta_ledger/home'],
|
'library' => ['figli_compta_ledger/home'],
|
||||||
],
|
],
|
||||||
@@ -24,15 +27,49 @@ 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: solde par compte, all-time and year by year -- split out
|
||||||
|
* of the general /dashboard so that page stays focused on activité/CA/
|
||||||
|
* type/client rather than per-compte balances. Same
|
||||||
|
* /dashboard/api/stats data as /dashboard, just a different slice of it.
|
||||||
|
*/
|
||||||
|
public function repartitionView() {
|
||||||
|
return [
|
||||||
|
'#theme' => 'figli_compta_dashboard_repartition',
|
||||||
|
'#current_route' => 'figli_compta_ledger.dashboard_repartition',
|
||||||
|
'#attached' => [
|
||||||
|
'library' => ['figli_compta_ledger/dashboard_repartition'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fourth 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,272 @@
|
|||||||
|
<?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 home.js's
|
||||||
|
// aggregateTotals()/fetchYearTotals() use for the /lignes footer),
|
||||||
|
// 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 = [];
|
||||||
|
$totalParTypeParAnnee = [];
|
||||||
|
$caParClient = [];
|
||||||
|
$caParClientParAnnee = [];
|
||||||
|
$chargeParClient = [];
|
||||||
|
$chargeParClientParAnnee = [];
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
// Same "who does this money go to" breakdown as $caParClient above,
|
||||||
|
// but for charges structurelles instead of entrées -- field_client
|
||||||
|
// on a charge line is the vendor/organisme (loyer, assurance,
|
||||||
|
// URSSAF...), not a paying client, but it's the same field/same
|
||||||
|
// taxonomy, so the same grouping applies. Absolute value, same
|
||||||
|
// convention as $totalParType.
|
||||||
|
if ($row->type === 'charge') {
|
||||||
|
$client = $row->client ?: '(sans client)';
|
||||||
|
$chargeParClient[$client] = ($chargeParClient[$client] ?? 0) + abs($montant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isAnneeValide($row->annee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$annees[$row->annee] = TRUE;
|
||||||
|
if ($row->type === 'entree') {
|
||||||
|
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
|
||||||
|
// Per-année équivalent of $caParClient above -- backs the "Top
|
||||||
|
// clients par année" small multiples, same rows, no extra query.
|
||||||
|
$client = $row->client ?: '(sans client)';
|
||||||
|
$caParClientParAnnee[$row->annee][$client] =
|
||||||
|
($caParClientParAnnee[$row->annee][$client] ?? 0) + $montant;
|
||||||
|
}
|
||||||
|
if ($row->type === 'charge') {
|
||||||
|
$client = $row->client ?: '(sans client)';
|
||||||
|
$chargeParClientParAnnee[$row->annee][$client] =
|
||||||
|
($chargeParClientParAnnee[$row->annee][$client] ?? 0) + abs($montant);
|
||||||
|
}
|
||||||
|
// Per-année équivalent of $totalParType above -- backs the
|
||||||
|
// "Répartition de l'activité par type" small multiples.
|
||||||
|
if ($row->type !== 'ouverture') {
|
||||||
|
$totalParTypeParAnnee[$row->annee][$row->type] =
|
||||||
|
($totalParTypeParAnnee[$row->annee][$row->type] ?? 0) + abs($montant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arsort($caParClient);
|
||||||
|
$topClients = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($caParClient as $client => $total) {
|
||||||
|
if ($i++ >= 12) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top 8 (not 12 like the all-time chart above) -- one per year keeps
|
||||||
|
// the small-multiples grid readable, same reasoning as the per-compte
|
||||||
|
// équivalent on /dashboard/compte (there it's top 5, computed
|
||||||
|
// client-side from full row data; here it's top 8, computed here
|
||||||
|
// since dashboard.js only ever gets pre-aggregated SQL, never full
|
||||||
|
// rows -- see this controller's docblock).
|
||||||
|
$topClientsParAnnee = [];
|
||||||
|
foreach ($caParClientParAnnee as $annee => $parClient) {
|
||||||
|
arsort($parClient);
|
||||||
|
$topClientsParAnnee[$annee] = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($parClient as $client => $total) {
|
||||||
|
if ($i++ >= 8) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$topClientsParAnnee[$annee][] = ['client' => $client, 'ca' => round($total, 2)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No top-N cap here (unlike top clients above) -- structural charges
|
||||||
|
// only ever go to a handful of recurring vendors (loyer, assurance,
|
||||||
|
// hébergement, URSSAF...), not the 100+ distinct clients entrées can
|
||||||
|
// have, so the full list is already short.
|
||||||
|
arsort($chargeParClient);
|
||||||
|
$chargeParClientList = [];
|
||||||
|
foreach ($chargeParClient as $client => $total) {
|
||||||
|
$chargeParClientList[] = ['client' => $client, 'total' => round($total, 2)];
|
||||||
|
}
|
||||||
|
$chargeParClientParAnneeList = [];
|
||||||
|
foreach ($chargeParClientParAnnee as $annee => $parClient) {
|
||||||
|
arsort($parClient);
|
||||||
|
$chargeParClientParAnneeList[$annee] = [];
|
||||||
|
foreach ($parClient as $client => $total) {
|
||||||
|
$chargeParClientParAnneeList[$annee][] = ['client' => $client, 'total' => 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);
|
||||||
|
ksort($totalParTypeParAnnee);
|
||||||
|
ksort($topClientsParAnnee);
|
||||||
|
ksort($chargeParClientParAnneeList);
|
||||||
|
|
||||||
|
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),
|
||||||
|
'total_par_type_par_annee' => array_map(
|
||||||
|
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
|
||||||
|
$totalParTypeParAnnee
|
||||||
|
),
|
||||||
|
'top_clients' => $topClients,
|
||||||
|
'top_clients_par_annee' => $topClientsParAnnee,
|
||||||
|
'charge_par_client' => $chargeParClientList,
|
||||||
|
'charge_par_client_par_annee' => $chargeParClientParAnneeList,
|
||||||
|
'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,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\Core\Url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grand livre history: a single reverse-chronological feed across every
|
||||||
|
* ligne_comptable's revisions, rather than Drupal's default per-node
|
||||||
|
* history page -- a revision of one ligne is, conceptually, a revision of
|
||||||
|
* the grand livre as a whole.
|
||||||
|
*/
|
||||||
|
class HistoryController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists every ligne_comptable revision, most recent change first.
|
||||||
|
*/
|
||||||
|
public function history() {
|
||||||
|
$node_storage = $this->entityTypeManager()->getStorage('node');
|
||||||
|
$date_formatter = \Drupal::service('date.formatter');
|
||||||
|
|
||||||
|
$revision_ids = $this->entityTypeManager()->getStorage('node')->getQuery()
|
||||||
|
->accessCheck(TRUE)
|
||||||
|
->allRevisions()
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->sort('revision_timestamp', 'DESC')
|
||||||
|
->pager(50)
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($revision_ids as $vid => $nid) {
|
||||||
|
$revision = $node_storage->loadRevision($vid);
|
||||||
|
if (!$revision) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$author = $revision->getRevisionUser();
|
||||||
|
$rows[] = [
|
||||||
|
$date_formatter->format($revision->getRevisionCreationTime(), 'short'),
|
||||||
|
$author ? $author->getDisplayName() : $this->t('Utilisateur supprimé'),
|
||||||
|
$revision->hasField('field_date_ligne') ? $revision->get('field_date_ligne')->value : '',
|
||||||
|
$revision->label(),
|
||||||
|
$revision->hasField('field_montant_ht') && !$revision->get('field_montant_ht')->isEmpty()
|
||||||
|
? $revision->get('field_montant_ht')->value . ' €'
|
||||||
|
: '',
|
||||||
|
[
|
||||||
|
'data' => [
|
||||||
|
'#type' => 'link',
|
||||||
|
'#title' => $this->t('Voir cette version'),
|
||||||
|
'#url' => Url::fromRoute('entity.node.revision', [
|
||||||
|
'node' => $nid,
|
||||||
|
'node_revision' => $vid,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Same markup/classes as the <nav class="figli-page-nav"> block in
|
||||||
|
// figli-compta-home.html.twig et al. (styled in admin-chrome.css,
|
||||||
|
// pinned next to the page title) -- this controller has no twig
|
||||||
|
// template of its own to put a real <nav> in, but the CSS only
|
||||||
|
// ever targets the class, not the tag, so a render-array
|
||||||
|
// 'container' (<div>) here looks identical. None of the four
|
||||||
|
// links is ever "active" here since this history feed isn't one
|
||||||
|
// of them -- same as visiting it from any of the other pages'
|
||||||
|
// nav, which doesn't include a 5th "Historique" entry either.
|
||||||
|
'nav' => [
|
||||||
|
'#type' => 'container',
|
||||||
|
'#attributes' => ['class' => ['figli-page-nav']],
|
||||||
|
'home' => [
|
||||||
|
'#type' => 'link',
|
||||||
|
'#title' => $this->t('Grand livre'),
|
||||||
|
'#url' => Url::fromRoute('figli_compta_ledger.home'),
|
||||||
|
],
|
||||||
|
'dashboard' => [
|
||||||
|
'#type' => 'link',
|
||||||
|
'#title' => $this->t('SAS'),
|
||||||
|
'#url' => Url::fromRoute('figli_compta_ledger.dashboard'),
|
||||||
|
],
|
||||||
|
'dashboard_repartition' => [
|
||||||
|
'#type' => 'link',
|
||||||
|
'#title' => $this->t('Répartition/Soldes'),
|
||||||
|
'#url' => Url::fromRoute('figli_compta_ledger.dashboard_repartition'),
|
||||||
|
],
|
||||||
|
'dashboard_compte' => [
|
||||||
|
'#type' => 'link',
|
||||||
|
'#title' => $this->t('Par compte'),
|
||||||
|
'#url' => Url::fromRoute('figli_compta_ledger.dashboard_compte'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'table' => [
|
||||||
|
'#type' => 'table',
|
||||||
|
'#header' => [
|
||||||
|
$this->t('Modifié le'),
|
||||||
|
$this->t('Par'),
|
||||||
|
$this->t('Date de la ligne'),
|
||||||
|
$this->t('Ligne'),
|
||||||
|
$this->t('Montant HT'),
|
||||||
|
$this->t('Version'),
|
||||||
|
],
|
||||||
|
'#rows' => $rows,
|
||||||
|
'#empty' => $this->t('Aucune révision.'),
|
||||||
|
'#attributes' => ['class' => ['figli-historique-table']],
|
||||||
|
],
|
||||||
|
'pager' => [
|
||||||
|
'#type' => 'pager',
|
||||||
|
],
|
||||||
|
'#cache' => [
|
||||||
|
'contexts' => ['user.permissions'],
|
||||||
|
'tags' => ['node_list:ligne_comptable'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\Core\Entity\EntityStorageException;
|
||||||
|
use Drupal\figli_compta_ledger\SkipValidationContext;
|
||||||
|
use Drupal\node\NodeInterface;
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||||
|
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 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped répartition-check opt-out -- see the class docblock of
|
||||||
|
* \Drupal\figli_compta_ledger\SkipValidationContext for why this replaced
|
||||||
|
* the historical global state key here.
|
||||||
|
*
|
||||||
|
* @var \Drupal\figli_compta_ledger\SkipValidationContext
|
||||||
|
*/
|
||||||
|
protected $skipValidationContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public static function create(ContainerInterface $container) {
|
||||||
|
$instance = parent::create($container);
|
||||||
|
$instance->skipValidationContext = $container->get('figli_compta_ledger.skip_validation_context');
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. The
|
||||||
|
// SkipValidationContext service is request-scoped with a try/finally
|
||||||
|
// inside skip(), so the check is back on the instant save() returns
|
||||||
|
// or throws -- no global flag left hanging that a concurrent save
|
||||||
|
// from someone else could fall into.
|
||||||
|
try {
|
||||||
|
$this->skipValidationContext->skip(fn () => $node->save());
|
||||||
|
}
|
||||||
|
catch (EntityStorageException $e) {
|
||||||
|
return new JsonResponse(['error' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Request-scoped skip (see
|
||||||
|
// updateType()'s comment), no global flag.
|
||||||
|
try {
|
||||||
|
$this->skipValidationContext->skip(fn () => $node->save());
|
||||||
|
}
|
||||||
|
catch (EntityStorageException $e) {
|
||||||
|
return new JsonResponse(['error' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,190 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /lignes/api/lignes -- server-side filtered replacement for the old
|
||||||
|
* "load a date-range window via JSON:API, then filter client-side"
|
||||||
|
* approach in home.js. Every toolbar filter (compte, client, type,
|
||||||
|
* signalement, écarts, recherche libre) is pushed into a single Drupal
|
||||||
|
* Entity/Field Query API query -- not raw SQL, so entity access checks
|
||||||
|
* apply natively -- rather than fetching everything in range and
|
||||||
|
* discarding what doesn't match on the client. Confirmed empirically
|
||||||
|
* (drush php:eval against dev) that a condition can traverse
|
||||||
|
* field_repartition (entity_reference_revisions to paragraphs) into the
|
||||||
|
* paragraph's own field_compte (entity_reference to taxonomy_term) as a
|
||||||
|
* dotted relationship path -- that was the one open technical question
|
||||||
|
* before writing this. Date range (or "annee" in its place) stays
|
||||||
|
* required -- the sliding window itself isn't going away, only what
|
||||||
|
* populates it.
|
||||||
|
*/
|
||||||
|
class LedgerRowsController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors LedgerActionsController::LINKABLE_TYPES.
|
||||||
|
*/
|
||||||
|
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
|
||||||
|
|
||||||
|
public function index(Request $request) {
|
||||||
|
$annee = $request->query->get('annee');
|
||||||
|
if ($annee) {
|
||||||
|
if (!preg_match('/^\d{4}$/', $annee)) {
|
||||||
|
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
|
||||||
|
}
|
||||||
|
$start = $annee . '-01-01';
|
||||||
|
$end = ((int) $annee + 1) . '-01-01';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$start = $request->query->get('start');
|
||||||
|
$end = $request->query->get('end');
|
||||||
|
if (!$start || !$end || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
|
||||||
|
return new JsonResponse(['error' => 'Paramètres "start"/"end" invalides.'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = $this->entityTypeManager()->getStorage('node');
|
||||||
|
$query = $storage->getQuery()
|
||||||
|
->accessCheck(TRUE)
|
||||||
|
->condition('type', 'ligne_comptable')
|
||||||
|
->condition('field_date_ligne', $start, '>=')
|
||||||
|
->condition('field_date_ligne', $end, '<')
|
||||||
|
->sort('field_date_ligne')
|
||||||
|
->sort('nid');
|
||||||
|
|
||||||
|
$compte = array_filter(explode(',', (string) $request->query->get('compte', '')));
|
||||||
|
if ($compte) {
|
||||||
|
$tids = $this->termIdsByNames('compte', $compte);
|
||||||
|
// No matching term at all (typo, renamed compte) still runs the
|
||||||
|
// query with an impossible condition rather than short-circuiting
|
||||||
|
// to an empty response -- fails the same visible "0 rows" way as
|
||||||
|
// an ordinary empty date range, instead of a silent special case.
|
||||||
|
$query->condition('field_repartition.entity.field_compte.target_id', $tids ?: [0], 'IN');
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = trim((string) $request->query->get('client', ''));
|
||||||
|
if ($client !== '') {
|
||||||
|
$tids = $this->termIdsByNames('client', [$client]);
|
||||||
|
$query->condition('field_client', $tids ?: [0], 'IN');
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = array_filter(explode(',', (string) $request->query->get('type', '')));
|
||||||
|
if ($type) {
|
||||||
|
$query->condition('field_type_ligne', array_values($type), 'IN');
|
||||||
|
}
|
||||||
|
|
||||||
|
$flag = array_filter(explode(',', (string) $request->query->get('flag', '')));
|
||||||
|
if ($flag) {
|
||||||
|
$tids = $this->termIdsByNames('flag', $flag);
|
||||||
|
$query->condition('field_flag.target_id', $tids ?: [0], 'IN');
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = trim((string) $request->query->get('q', ''));
|
||||||
|
if ($q !== '') {
|
||||||
|
// Mirrors buildRows()'s `libelle: attrs.field_notes || attrs.title`
|
||||||
|
// fallback in home.js -- a line with no notes shows its title, so
|
||||||
|
// the search has to match either, not just field_notes.
|
||||||
|
$group = $query->orConditionGroup()
|
||||||
|
->condition('field_notes', $q, 'CONTAINS')
|
||||||
|
->condition('title', $q, 'CONTAINS');
|
||||||
|
$query->condition($group);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->query->get('ecarts') === '1') {
|
||||||
|
$query->condition('field_ecart', 0, '<>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->query->get('signale') === '1') {
|
||||||
|
$query->exists('field_flag');
|
||||||
|
}
|
||||||
|
|
||||||
|
$nids = $query->execute();
|
||||||
|
$rows = [];
|
||||||
|
foreach ($storage->loadMultiple($nids) as $node) {
|
||||||
|
$rows[] = $this->serializeRow($node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(['rows' => $rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves taxonomy term names to ids within a given vocabulary --
|
||||||
|
* shared by the compte/client/flag filters above. Silently drops names
|
||||||
|
* that don't match anything (the caller falls back to an impossible
|
||||||
|
* [0] condition rather than treating "no match" as "no filter").
|
||||||
|
*/
|
||||||
|
private function termIdsByNames(string $vid, array $names): array {
|
||||||
|
if (!$names) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$tids = $this->entityTypeManager()->getStorage('taxonomy_term')->getQuery()
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->condition('vid', $vid)
|
||||||
|
->condition('name', array_values($names), 'IN')
|
||||||
|
->execute();
|
||||||
|
return array_values($tids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same row shape as buildRows() in home.js builds client-side from
|
||||||
|
* JSON:API, so the frontend can treat rows from either source
|
||||||
|
* identically. `id` is the node's UUID (what JSON:API exposes as
|
||||||
|
* node.id and every row-matching-by-id in home.js keys on), not the
|
||||||
|
* integer nid.
|
||||||
|
*/
|
||||||
|
private function serializeRow(NodeInterface $node): array {
|
||||||
|
$parCompte = [];
|
||||||
|
$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() : '(compte inconnu)';
|
||||||
|
$parCompte[$compte] = ($parCompte[$compte] ?? 0) + $montant;
|
||||||
|
$somme += $montant;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ecart = $node->hasField('field_ecart') && !$node->get('field_ecart')->isEmpty()
|
||||||
|
? (float) $node->get('field_ecart')->value : 0.0;
|
||||||
|
|
||||||
|
$entreeLieeNodes = $node->hasField('field_entree_liee') ? $node->get('field_entree_liee')->referencedEntities() : [];
|
||||||
|
$flagTerms = $node->hasField('field_flag') ? $node->get('field_flag')->referencedEntities() : [];
|
||||||
|
$type = $node->get('field_type_ligne')->value;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $node->uuid(),
|
||||||
|
'nid' => (int) $node->id(),
|
||||||
|
'changed' => date(DATE_ATOM, $node->getChangedTime()),
|
||||||
|
'date' => $node->get('field_date_ligne')->value,
|
||||||
|
'type' => $type,
|
||||||
|
'client' => $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL,
|
||||||
|
'facture' => $node->get('field_numero_facture')->value ?: NULL,
|
||||||
|
'libelle' => $node->get('field_notes')->value ?: $node->getTitle(),
|
||||||
|
'montant_ht' => $node->get('field_montant_ht')->isEmpty() ? NULL : (float) $node->get('field_montant_ht')->value,
|
||||||
|
'cotisation' => $node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty() ? (float) $node->get('field_cotisation_urssaf')->value : NULL,
|
||||||
|
'tva' => $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : NULL,
|
||||||
|
'montant_ttc' => $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty() ? (float) $node->get('field_montant_ttc')->value : NULL,
|
||||||
|
'parCompte' => (object) $parCompte,
|
||||||
|
'somme' => $somme,
|
||||||
|
'ecart' => $ecart,
|
||||||
|
// "Écart visible" == non-zero to the centime. The presave/validation
|
||||||
|
// BLOCKING threshold stays at > 0.01 (tolerated rounding noise is
|
||||||
|
// savable), but a 0.01 écart is still a real mismatch and must be
|
||||||
|
// shown in the table -- at > 0.01 here it displayed nothing at all.
|
||||||
|
// 0.005 (not 0.0) keeps float representations of stored centimes
|
||||||
|
// from misclassifying.
|
||||||
|
'hasError' => abs($ecart) > 0.005,
|
||||||
|
'linkable' => in_array($type, self::LINKABLE_TYPES, TRUE),
|
||||||
|
'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes),
|
||||||
|
'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes),
|
||||||
|
'flags' => array_map(fn ($t) => $t->label(), $flagTerms),
|
||||||
|
'hasFlag' => count($flagTerms) > 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\node\NodeInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small aggregate endpoints backing the /lignes sliding window: the
|
||||||
|
* row-level fetch only ever covers a date range (see home.js), so the
|
||||||
|
* "Année" dropdown can't be computed from whatever's currently loaded --
|
||||||
|
* it needs its own always-accurate query, decoupled from the row window.
|
||||||
|
* The footer totals used to live here too (totauxAnnee(), unfiltered)
|
||||||
|
* until the footer needed to reflect the active toolbar filters -- it's
|
||||||
|
* now computed client-side in home.js from LedgerRowsController::index()
|
||||||
|
* rows instead, the same server-side-filtered endpoint the table itself
|
||||||
|
* uses.
|
||||||
|
*/
|
||||||
|
class LedgerStatsController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\Core\Routing\LocalRedirectResponse;
|
||||||
|
use Drupal\Core\Url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-import summary page: counts, the accounting control total
|
||||||
|
* (file total == created + duplicates, to the centime), the list of
|
||||||
|
* duplicates skipped for human review, and a deep link into /lignes
|
||||||
|
* filtered on this batch's "IMP AAMMJJ" tag.
|
||||||
|
*
|
||||||
|
* The numbers live one-shot in the private tempstore (written by
|
||||||
|
* ReleveImportBatch::finished(), read + purged here): the page is only
|
||||||
|
* meaningful right after an import, and every user sees their own.
|
||||||
|
*/
|
||||||
|
class ReleveImportResultController extends ControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the summary of the import that just ran.
|
||||||
|
*/
|
||||||
|
public function result() {
|
||||||
|
$store = \Drupal::service('tempstore.private')->get('figli_compta_ledger');
|
||||||
|
$summary = $store->get('releve_import_result');
|
||||||
|
if (!$summary) {
|
||||||
|
// Direct navigation (bookmark, back button long after the import):
|
||||||
|
// no numbers in memory anymore, send back to the form instead of
|
||||||
|
// showing an empty shell.
|
||||||
|
$this->messenger()->addWarning($this->t("Le résultat d'un import n'est disponible qu'immédiatement après l'import lui-même."));
|
||||||
|
return new LocalRedirectResponse(Url::fromRoute('figli_compta_ledger.releve_import_form')->toString());
|
||||||
|
}
|
||||||
|
$store->delete('releve_import_result');
|
||||||
|
|
||||||
|
$eur = fn ($x) => number_format((float) $x, 2, ',', ' ') . ' €';
|
||||||
|
$fr_date = fn ($iso) => preg_replace('/^(\d{4})-(\d{2})-(\d{2})$/', '$3/$2/$1', (string) $iso);
|
||||||
|
|
||||||
|
$view = [
|
||||||
|
'file_name' => $summary['file_name'],
|
||||||
|
'tag' => $summary['tag'],
|
||||||
|
'created' => (int) $summary['created'],
|
||||||
|
'duplicates' => (int) $summary['duplicates'],
|
||||||
|
'matched' => (int) $summary['matched'],
|
||||||
|
'unmatched' => (int) $summary['unmatched'],
|
||||||
|
'errors_count' => count($summary['errors']),
|
||||||
|
'errors' => array_map(fn ($e) => $e['libelle'] . ' — ' . $e['error'], $summary['errors']),
|
||||||
|
'duplicates_list' => array_map(fn ($d) => [
|
||||||
|
'date' => $fr_date($d['date']),
|
||||||
|
'montant' => $eur($d['montant']),
|
||||||
|
'libelle' => $d['libelle'],
|
||||||
|
], $summary['duplicates_list']),
|
||||||
|
'file_total' => $eur($summary['file_total']),
|
||||||
|
'created_total' => $eur($summary['created_total']),
|
||||||
|
'duplicates_total' => $eur($summary['duplicates_total']),
|
||||||
|
'totals_ok' => (bool) $summary['totals_ok'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'#theme' => 'figli_compta_releve_import_result',
|
||||||
|
'#summary' => $view,
|
||||||
|
// /lignes reads its filter state from location.hash -- the flag
|
||||||
|
// filter key is "tag" (see readHashState() in js/home.js), values
|
||||||
|
// are comma-separated flag names.
|
||||||
|
'#lignes_url' => Url::fromRoute('figli_compta_ledger.home')->toString() . '#tag=' . rawurlencode($summary['tag']),
|
||||||
|
'#import_url' => Url::fromRoute('figli_compta_ledger.releve_import_form')->toString(),
|
||||||
|
'#attached' => ['library' => ['figli_compta_ledger/releve_import']],
|
||||||
|
'#cache' => ['max-age' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\EventSubscriber;
|
||||||
|
|
||||||
|
use Drupal\Core\Routing\RouteSubscriberBase;
|
||||||
|
use Symfony\Component\Routing\RouteCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's generic entity autocomplete route ('system.entity_autocomplete')
|
||||||
|
* is '_access: TRUE' by design -- EntityQuery::accessCheck() only enforces
|
||||||
|
* node grants (published status), not the base 'access content'
|
||||||
|
* permission, so it doesn't actually block anonymous users the way every
|
||||||
|
* other route on this confidential site does. Discovered while adding
|
||||||
|
* field_entree_liee's autocomplete: verified the *existing* field_client
|
||||||
|
* (client taxonomy) autocomplete had the same gap, leaking real client
|
||||||
|
* names to anonymous requests via a plain curl call. Since every field on
|
||||||
|
* this content type is confidential, lock the route down site-wide rather
|
||||||
|
* than special-casing our own field.
|
||||||
|
*/
|
||||||
|
class RouteSubscriber extends RouteSubscriberBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
protected function alterRoutes(RouteCollection $collection) {
|
||||||
|
if ($route = $collection->get('system.entity_autocomplete')) {
|
||||||
|
// 'access figli ledger' rather than 'access content': the generic
|
||||||
|
// Authenticated role no longer holds the latter (removed 2026-09),
|
||||||
|
// and anyone entitled to see ledger autocomplete suggestions must
|
||||||
|
// be entitled to the ledger's data itself.
|
||||||
|
$route->setRequirement('_permission', 'access figli ledger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Form;
|
||||||
|
|
||||||
|
use Drupal\Core\Ajax\AjaxResponse;
|
||||||
|
use Drupal\Core\Ajax\CloseModalDialogCommand;
|
||||||
|
use Drupal\Core\Ajax\ReplaceCommand;
|
||||||
|
use Drupal\Core\Form\FormBase;
|
||||||
|
use Drupal\Core\Form\FormStateInterface;
|
||||||
|
use Drupal\node\NodeInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-link form: sets field_entree_liee on a single sortie line without
|
||||||
|
* opening the full ligne_comptable edit form -- the associates only ever
|
||||||
|
* need to touch this one field to link a versement/achat/hébergement to
|
||||||
|
* the entrée client(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 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getFormId() {
|
||||||
|
return 'figli_compta_ledger_link_entree_form';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL) {
|
||||||
|
$form_state->set('node', $node);
|
||||||
|
|
||||||
|
// Number of entrée-liée fields to render -- seeded from the node's
|
||||||
|
// current values on first build, then only grown by addItem() across
|
||||||
|
// AJAX rebuilds. At least one field even when nothing is linked yet.
|
||||||
|
if ($form_state->get('items_count') === NULL) {
|
||||||
|
$existing_count = $node->get('field_entree_liee')->count();
|
||||||
|
$form_state->set('items_count', max($existing_count, 1));
|
||||||
|
}
|
||||||
|
$existing = $node->get('field_entree_liee')->referencedEntities();
|
||||||
|
|
||||||
|
$form['#tree'] = TRUE;
|
||||||
|
$form['items'] = [
|
||||||
|
'#type' => 'container',
|
||||||
|
'#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']['submit'] = [
|
||||||
|
'#type' => 'submit',
|
||||||
|
'#value' => $this->t('Enregistrer'),
|
||||||
|
'#ajax' => [
|
||||||
|
'callback' => '::ajaxSubmit',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
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}
|
||||||
|
*/
|
||||||
|
public function submitForm(array &$form, FormStateInterface $form_state) {
|
||||||
|
/** @var \Drupal\node\NodeInterface $node */
|
||||||
|
$node = $form_state->get('node');
|
||||||
|
$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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #ajax callback: close the modal on success (the /lignes table picks up
|
||||||
|
* "dialog:afterclose" to refresh), or re-render the form in place.
|
||||||
|
*/
|
||||||
|
public function ajaxSubmit(array &$form, FormStateInterface $form_state) {
|
||||||
|
$response = new AjaxResponse();
|
||||||
|
if ($form_state->getErrors()) {
|
||||||
|
unset($form['#prefix'], $form['#suffix']);
|
||||||
|
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
$response->addCommand(new CloseModalDialogCommand());
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Form;
|
||||||
|
|
||||||
|
use Drupal\Core\Form\FormBase;
|
||||||
|
use Drupal\Core\Form\FormStateInterface;
|
||||||
|
use Drupal\file\Entity\File;
|
||||||
|
use Drupal\figli_compta_ledger\Import\CsvReleveParser;
|
||||||
|
use Drupal\figli_compta_ledger\Import\ReleveImportBatch;
|
||||||
|
use Drupal\figli_compta_ledger\Import\ReleveTransaction;
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload form for a bank statement export (CSV v1 -- see
|
||||||
|
* PLAN-import-releve-bancaire.md). Self-service for the associates: the
|
||||||
|
* file lands in the *private* filesystem (financial data), is parsed in
|
||||||
|
* memory, deduplicated count-aware against lines already in base, then
|
||||||
|
* turned into "à trier" draft lines by ReleveImportBatch. Nothing here
|
||||||
|
* writes ledger lines directly -- everything goes through the normal
|
||||||
|
* Node::save() lifecycle under SkipValidationContext.
|
||||||
|
*/
|
||||||
|
class ReleveUploadForm extends FormBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getFormId(): string {
|
||||||
|
return 'figli_compta_ledger_releve_upload_form';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function buildForm(array $form, FormStateInterface $form_state): array {
|
||||||
|
$form['releve_file'] = [
|
||||||
|
'#type' => 'managed_file',
|
||||||
|
'#title' => $this->t('Relevé bancaire (CSV)'),
|
||||||
|
'#upload_location' => 'private://releves',
|
||||||
|
// Only .csv: the OFX/CMI exports of the same account exist but
|
||||||
|
// are explicitly out of v1 scope (truncated labels / unstable
|
||||||
|
// structure -- see the plan). A clear message beats a silent
|
||||||
|
// failure for someone uploading them by mistake.
|
||||||
|
'#upload_validators' => [
|
||||||
|
'FileExtension' => ['extensions' => 'csv'],
|
||||||
|
],
|
||||||
|
'#required' => TRUE,
|
||||||
|
'#description' => $this->t('Export CSV de la banque : colonnes « Date ; Date de valeur ; Débit ; Crédit ; Libellé ; Solde » (les fichiers .ofx et .cmi ne sont pas pris en charge pour l\'instant). Chaque transaction devient une ligne « à trier » : type, répartition et HT/TVA restent à assigner à la main.'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$form['actions'] = ['#type' => 'actions'];
|
||||||
|
$form['actions']['submit'] = [
|
||||||
|
'#type' => 'submit',
|
||||||
|
'#value' => $this->t('Importer le relevé'),
|
||||||
|
'#button_type' => 'primary',
|
||||||
|
];
|
||||||
|
return $form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*
|
||||||
|
* The idiomatic home for the parse: an invalid file is a validation
|
||||||
|
* error, rejected before anything is written (the file entity is only
|
||||||
|
* promoted in submitForm()). The parsed transactions are stashed in
|
||||||
|
* $form_state so the file is never parsed twice.
|
||||||
|
*/
|
||||||
|
public function validateForm(array &$form, FormStateInterface $form_state): void {
|
||||||
|
$fids = $form_state->getValue('releve_file');
|
||||||
|
$fids = is_array($fids) ? $fids : [];
|
||||||
|
if (!$fids) {
|
||||||
|
// #required already covers the empty case.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$file = File::load(reset($fids));
|
||||||
|
if (!$file) {
|
||||||
|
$form_state->setErrorByName('releve_file', $this->t("Le fichier téléversé n'a pas pu être retrouvé."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse (pure, no writes). A clean form error -- never a crash page
|
||||||
|
// -- for anything the parser rejects.
|
||||||
|
try {
|
||||||
|
$transactions = (new CsvReleveParser())->parse($file->getFileUri());
|
||||||
|
}
|
||||||
|
catch (\RuntimeException $e) {
|
||||||
|
$form_state->setErrorByName('releve_file', $e->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form_state->set('releve_fid', (int) $file->id());
|
||||||
|
$form_state->set('releve_transactions', array_map(fn ($t) => $t->toArray(), $transactions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function submitForm(array &$form, FormStateInterface $form_state): void {
|
||||||
|
$file = File::load($form_state->get('releve_fid'));
|
||||||
|
$transactions = array_map([ReleveTransaction::class, 'fromArray'], $form_state->get('releve_transactions') ?: []);
|
||||||
|
if (!$file || !$transactions) {
|
||||||
|
// Normally unreachable -- validateForm() blocks bad files before
|
||||||
|
// submit is reached. Defensive only.
|
||||||
|
$form_state->setErrorByName('releve_file', $this->t("Rien à importer : relancez l'upload."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the uploaded statement permanently + registered as our usage:
|
||||||
|
// it's accounting source material, cron must not garbage-collect it
|
||||||
|
// after a few hours as it would a temporary file.
|
||||||
|
$file->setPermanent();
|
||||||
|
$file->save();
|
||||||
|
\Drupal::service('file.usage')->add($file, 'figli_compta_ledger', 'releve_import', (int) $file->id());
|
||||||
|
|
||||||
|
// Count-aware dedup: how many times each fingerprint appears in this
|
||||||
|
// file (k), one grouped query for how many already exist in base
|
||||||
|
// (m, all provenances combined), quota = max(0, k − m).
|
||||||
|
$counts = [];
|
||||||
|
foreach ($transactions as $t) {
|
||||||
|
$counts[$t->fitid] = ($counts[$t->fitid] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
$db_counts = [];
|
||||||
|
if ($counts) {
|
||||||
|
$select = \Drupal::database()->select('node__field_import_fitid', 'f')
|
||||||
|
->condition('f.field_import_fitid_value', array_keys($counts), 'IN');
|
||||||
|
$select->addField('f', 'field_import_fitid_value', 'fitid');
|
||||||
|
$select->addExpression('COUNT(*)', 'n');
|
||||||
|
$select->groupBy('f.field_import_fitid_value');
|
||||||
|
foreach ($select->execute()->fetchAllKeyed() as $fitid => $n) {
|
||||||
|
$db_counts[$fitid] = (int) $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$quotas = [];
|
||||||
|
foreach ($counts as $fitid => $k) {
|
||||||
|
$quotas[$fitid] = max(0, $k - ($db_counts[$fitid] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One "IMP AAMMJJ" flag term per import batch (day granularity: the
|
||||||
|
// same day's re-imports join the same lot) -- short on purpose, it
|
||||||
|
// renders as a badge in /lignes' narrow Signalement column. The
|
||||||
|
// associates sort lines through the existing signalement mechanism
|
||||||
|
// (filter + amber marker), zero new UI.
|
||||||
|
$tag = 'IMP ' . date('ymd');
|
||||||
|
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
|
||||||
|
->loadByProperties(['vid' => 'flag', 'name' => $tag]);
|
||||||
|
if ($terms) {
|
||||||
|
$term = reset($terms);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$term = Term::create(['vid' => 'flag', 'name' => $tag]);
|
||||||
|
$term->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$file_total = 0.0;
|
||||||
|
$payload = [];
|
||||||
|
foreach ($transactions as $t) {
|
||||||
|
$file_total += $t->montant;
|
||||||
|
$payload[] = $t->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
batch_set([
|
||||||
|
'title' => $this->t('Import du relevé bancaire'),
|
||||||
|
'operations' => [
|
||||||
|
[
|
||||||
|
[ReleveImportBatch::class, 'operation'],
|
||||||
|
[$payload, $quotas, [
|
||||||
|
'file_name' => $file->getFilename(),
|
||||||
|
'file_total' => round($file_total, 2),
|
||||||
|
'tag' => $tag,
|
||||||
|
'flag_tid' => (int) $term->id(),
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'finished' => [ReleveImportBatch::class, 'finished'],
|
||||||
|
'init_message' => $this->t('Import du relevé en cours…'),
|
||||||
|
'progress_message' => $this->t('@current/@total'),
|
||||||
|
'error_message' => $this->t('L\'import a rencontré une erreur inattendue.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Where the browser lands once the batch is done -- the result page
|
||||||
|
// reads its numbers from the private tempstore.
|
||||||
|
$form_state->setRedirect('figli_compta_ledger.releve_import_result');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggests which client term a bank label refers to -- conservatively:
|
||||||
|
* an empty match a human fills in beats a wrong match nobody re-checks
|
||||||
|
* (the same philosophy as the whole import feature: pre-fill, never
|
||||||
|
* decide).
|
||||||
|
*
|
||||||
|
* Matching is word-based, never raw substring: after normalization
|
||||||
|
* (uppercase, accents removed, non-alphanumerics as separators) a
|
||||||
|
* client's full name must appear as a contiguous word *sequence* in the
|
||||||
|
* label ("OVH SAS" matches "PRLV SEPA OVH SAS TWLN…" but a hypothetical
|
||||||
|
* client "AIR" would NOT match "CLAIR" -- the v0 substring draft of this
|
||||||
|
* plan had exactly that false-positive mode for short names).
|
||||||
|
*
|
||||||
|
* Pass 1: full normalized client name as contiguous word sequence. Two
|
||||||
|
* distinct clients matching is ambiguous → empty.
|
||||||
|
* Pass 2 (only if pass 1 found nothing): a single "significant" word
|
||||||
|
* (≥ 4 chars, not a legal-form filler like SAS/SARL) that belongs to
|
||||||
|
* exactly ONE client in the whole vocabulary. Several candidate clients
|
||||||
|
* → empty. Deliberately recall-biased: a generic-but-unique word (say
|
||||||
|
* "MAISON", held by a single client) can suggest the wrong client for
|
||||||
|
* an unrelated label -- acceptable because every imported line is
|
||||||
|
* flagged and manually sorted (see ReleveImportBatch), so a wrong
|
||||||
|
* suggestion gets corrected by a human rather than trusted.
|
||||||
|
*/
|
||||||
|
final class ClientMatcher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal-form filler words never significant on their own.
|
||||||
|
*/
|
||||||
|
private const STOPWORDS = ['SAS', 'SARL', 'SA', 'SASU', 'EURL', 'SCI', 'SCOP', 'ASSOCIATION', 'GMBH', 'SNC'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loaded client vocabulary, shape: [['term' => Term, 'words' => string[]]].
|
||||||
|
*
|
||||||
|
* @var array|null
|
||||||
|
*/
|
||||||
|
private ?array $clients = NULL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the client term a bank label most likely refers to, or NULL
|
||||||
|
* when nothing safe can be said. Never creates a term (unlike flag
|
||||||
|
* auto-creation) -- the client vocabulary stays curated by hand.
|
||||||
|
*/
|
||||||
|
public function match(string $libelle): ?Term {
|
||||||
|
$words = $this->words($libelle);
|
||||||
|
if (!$words) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
$clients = $this->loadClients();
|
||||||
|
if (!$clients) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: full name as a contiguous word sequence, unique candidate.
|
||||||
|
$pass1 = [];
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
if (self::containsSequence($words, $client['words'])) {
|
||||||
|
$pass1[$client['term']->id()] = $client['term'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($pass1) === 1) {
|
||||||
|
return reset($pass1);
|
||||||
|
}
|
||||||
|
if (count($pass1) > 1) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: a significant word owned by exactly one client vocabulary.
|
||||||
|
$pass2 = [];
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
foreach ($client['words'] as $word) {
|
||||||
|
if (mb_strlen($word) < 4 || in_array($word, self::STOPWORDS, TRUE)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($word, $words, TRUE)) {
|
||||||
|
$pass2[$client['term']->id()] = $client['term'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($pass2) === 1) {
|
||||||
|
return reset($pass2);
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uppercase, accent-free word tokens: "EPAU / POPSU" → ["EPAU","POPSU"].
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function words(string $text): array {
|
||||||
|
$transliterated = \Drupal::transliteration()->transliterate($text, 'fr');
|
||||||
|
$upper = mb_strtoupper($transliterated);
|
||||||
|
$words = preg_split('/[^A-Z0-9]+/', $upper, -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
return $words === FALSE ? [] : $words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads (once per request) every client term with its normalized words.
|
||||||
|
*/
|
||||||
|
private function loadClients(): array {
|
||||||
|
if ($this->clients !== NULL) {
|
||||||
|
return $this->clients;
|
||||||
|
}
|
||||||
|
$this->clients = [];
|
||||||
|
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
|
||||||
|
->loadByProperties(['vid' => 'client']);
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
$words = $this->words($term->label());
|
||||||
|
if ($words) {
|
||||||
|
$this->clients[] = ['term' => $term, 'words' => $words];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether $needle appears in $haystack as a contiguous word sequence.
|
||||||
|
*/
|
||||||
|
private static function containsSequence(array $haystack, array $needle): bool {
|
||||||
|
$n = count($needle);
|
||||||
|
$h = count($haystack);
|
||||||
|
if ($n === 0 || $n > $h) {
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
for ($i = 0; $i <= $h - $n; $i++) {
|
||||||
|
for ($j = 0; $j < $n; $j++) {
|
||||||
|
if ($haystack[$i + $j] !== $needle[$j]) {
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the CSV export of the SAS bank account into ReleveTransaction
|
||||||
|
* objects. Built from (and verified against) the real sample in
|
||||||
|
* sources-compta/extrais de comptes/00021322002.csv: `;`-separated,
|
||||||
|
* ISO-8859-1 encoded, columns "Date;Date de valeur;Débit;Crédit;Libellé;
|
||||||
|
* Solde", dates JJ/MM/AAAA, French decimal comma, exactly one of
|
||||||
|
* Débit/Crédit filled per row. The Solde column is ignored (no balance
|
||||||
|
* reconciliation in v1).
|
||||||
|
*
|
||||||
|
* Any deviation (wrong header, unparsable date/amount, empty label) throws
|
||||||
|
* a RuntimeException with a clear, user-facing French message -- the upload
|
||||||
|
* form catches it and shows a form error, never a raw crash page. Nothing
|
||||||
|
* is written to the database from here: parsing is a pure in-memory step.
|
||||||
|
*/
|
||||||
|
final class CsvReleveParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact header (after ISO-8859-1 → UTF-8 conversion) a file must
|
||||||
|
* carry to be considered a supported statement export.
|
||||||
|
*/
|
||||||
|
private const HEADER = ['Date', 'Date de valeur', 'Débit', 'Crédit', 'Libellé', 'Solde'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a file by URI (any stream wrapper, typically private://).
|
||||||
|
*
|
||||||
|
* @return \Drupal\figli_compta_ledger\Import\ReleveTransaction[]
|
||||||
|
* Every data row as a transaction, in file order.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException
|
||||||
|
* With a ready-to-display message when the file isn't a supported
|
||||||
|
* statement export.
|
||||||
|
*/
|
||||||
|
public function parse(string $uri): array {
|
||||||
|
$stream = @fopen($uri, 'r');
|
||||||
|
if ($stream === FALSE) {
|
||||||
|
throw new \RuntimeException("Le fichier téléversé n'a pas pu être relu depuis le stockage privé.");
|
||||||
|
}
|
||||||
|
// Confirmed ISO-8859-1 on the real sample: a naive UTF-8 read would
|
||||||
|
// corrupt every accented label -- the one truly silent bug risk of
|
||||||
|
// this parser. The filter converts as fgetcsv() reads.
|
||||||
|
stream_filter_append($stream, 'convert.iconv.ISO-8859-1/UTF-8');
|
||||||
|
|
||||||
|
// Explicit enclosure + empty $escape: PHP 8.4 deprecates relying on
|
||||||
|
// the default escape (backslash), whose legacy behavior would let a
|
||||||
|
// stray "\" in a bank label swallow the next character -- with ''
|
||||||
|
// the bank's own quotes stay the only special characters.
|
||||||
|
$header = fgetcsv($stream, NULL, ';', '"', '');
|
||||||
|
if ($header === FALSE) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException('Le fichier est vide.');
|
||||||
|
}
|
||||||
|
$header = array_map(fn ($h) => trim((string) $h), $header);
|
||||||
|
if ($header !== self::HEADER) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException('Format de fichier non reconnu. En-tête attendu : « ' . implode(';', self::HEADER) . ' ». Seul l\'export CSV de la banque est pris en charge pour l\'instant (.ofx et .cmi non encore).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = [];
|
||||||
|
$line = 1;
|
||||||
|
while (($row = fgetcsv($stream, NULL, ';', '"', '')) !== FALSE) {
|
||||||
|
$line++;
|
||||||
|
// Fully blank rows are just padding at the end of some exports.
|
||||||
|
if (trim(implode('', array_map('strval', $row))) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count($row) < 6) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : nombre de colonnes inattendu (" . count($row) . ", 6 attendues).");
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateRaw = trim((string) $row[0]);
|
||||||
|
if (!preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $dateRaw, $m) || !checkdate((int) $m[2], (int) $m[1], (int) $m[3])) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : date « $dateRaw » invalide (JJ/MM/AAAA attendu).");
|
||||||
|
}
|
||||||
|
$date = $m[3] . '-' . $m[2] . '-' . $m[1];
|
||||||
|
|
||||||
|
$debit = trim((string) $row[2]);
|
||||||
|
$credit = trim((string) $row[3]);
|
||||||
|
if ($debit !== '' && $credit !== '') {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : Débit et Crédit renseignés simultanément, format inattendu.");
|
||||||
|
}
|
||||||
|
// A Débit is money out whatever sign the bank exported it with
|
||||||
|
// (the sample already stores it negative; -abs() normalizes any
|
||||||
|
// sibling export that doesn't), a Crédit is money in.
|
||||||
|
if ($debit !== '') {
|
||||||
|
$montant = -abs($this->parseAmount($debit, $line));
|
||||||
|
}
|
||||||
|
elseif ($credit !== '') {
|
||||||
|
$montant = abs($this->parseAmount($credit, $line));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : ni Débit ni Crédit renseigné.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$libelle = self::normalizeLibelle((string) $row[4]);
|
||||||
|
if ($libelle === '') {
|
||||||
|
fclose($stream);
|
||||||
|
throw new \RuntimeException("Ligne $line : libellé vide, impossible de tracer la transaction.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions[] = new ReleveTransaction(
|
||||||
|
$date,
|
||||||
|
$montant,
|
||||||
|
$libelle,
|
||||||
|
self::fitid($date, $montant, $libelle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
if (!$transactions) {
|
||||||
|
throw new \RuntimeException("Aucune transaction trouvée dans le fichier (en-tête seul).");
|
||||||
|
}
|
||||||
|
return $transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint of one transaction: 'csv:' + sha1(date | signed amount to
|
||||||
|
* the centime | whitespace-normalized label). No case-folding -- two
|
||||||
|
* exports of the same account reproduce labels byte for byte, and the
|
||||||
|
* fingerprint must stay stable for the count-aware dedup to recognize
|
||||||
|
* an already-imported transaction years later.
|
||||||
|
*
|
||||||
|
* The amount goes in as a fixed 2-decimal string ("−1234.56") so no
|
||||||
|
* floating-point representation ever enters the hash.
|
||||||
|
*/
|
||||||
|
public static function fitid(string $date, float $montant, string $normalizedLibelle): string {
|
||||||
|
return 'csv:' . sha1($date . '|' . number_format($montant, 2, '.', '') . '|' . $normalizedLibelle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim + collapse internal whitespace runs to one space: stray double
|
||||||
|
* spaces would otherwise make the same transaction fingerprint
|
||||||
|
* differently across two exports of the same account.
|
||||||
|
*/
|
||||||
|
public static function normalizeLibelle(string $libelle): string {
|
||||||
|
return trim((string) preg_replace('/\s+/u', ' ', $libelle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* French decimal ("1 234,56", "-45,89") → float, with a hard format
|
||||||
|
* check -- anything unexpected rejects the whole file with the line
|
||||||
|
* number rather than being silently coerced.
|
||||||
|
*/
|
||||||
|
private function parseAmount(string $raw, int $line): float {
|
||||||
|
$clean = str_replace([' ', "\xC2\xA0"], '', $raw);
|
||||||
|
$clean = str_replace(',', '.', $clean);
|
||||||
|
if (!preg_match('/^[+-]?\d+(\.\d+)?$/', $clean)) {
|
||||||
|
throw new \RuntimeException("Ligne $line : montant « $raw » invalide.");
|
||||||
|
}
|
||||||
|
return (float) $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
use Drupal\Core\Entity\EntityStorageException;
|
||||||
|
use Drupal\node\Entity\Node;
|
||||||
|
use Drupal\taxonomy\Entity\Term;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch backend of the bank statement import: turns the parsed
|
||||||
|
* transactions into ligne_comptable nodes, ~25 per PHP-FPM request
|
||||||
|
* (several hundred transactions would blow the memory/time budget of a
|
||||||
|
* single request -- no Batch API precedent existed in this module
|
||||||
|
* before this).
|
||||||
|
*
|
||||||
|
* Count-aware dedup (see PLAN-import-releve-bancaire.md): for every
|
||||||
|
* fingerprint, the file tells how many times the transaction appears (k)
|
||||||
|
* and the database how many are already imported (m) -- the batch then
|
||||||
|
* creates max(0, k − m) lines. That imports every legitimate duplicate
|
||||||
|
* (two identical transfers the same day) while still recognizing an
|
||||||
|
* already-imported transaction across overlapping files.
|
||||||
|
*
|
||||||
|
* Every created line goes through SkipValidationContext (request-scoped,
|
||||||
|
* NOT the historical state key): répartition is deliberately empty, so
|
||||||
|
* the presave invariant sum(répartition) == montant_ht must not fire --
|
||||||
|
* field_ecart (= montant_ht) still gets computed and drives the existing
|
||||||
|
* "à trier" red marker on /lignes.
|
||||||
|
*/
|
||||||
|
final class ReleveImportBatch {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions processed per batch step.
|
||||||
|
*/
|
||||||
|
const CHUNK = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch operation -- called repeatedly by Drupal until finished.
|
||||||
|
*
|
||||||
|
* @param array $transactions
|
||||||
|
* ReleveTransaction::toArray() payloads, in file order.
|
||||||
|
* @param array $quotas
|
||||||
|
* fitid => remaining lines to create (k − m, floored at 0).
|
||||||
|
* @param array $meta
|
||||||
|
* Immutable import metadata: file_name, file_total (sum of every
|
||||||
|
* transaction's signed amount, the control total), tag (flag term
|
||||||
|
* name), flag_tid.
|
||||||
|
* @param array $context
|
||||||
|
* Batch context (sandbox holds index + mutable quotas, results hold
|
||||||
|
* the accumulators finished() assembles the summary from).
|
||||||
|
*/
|
||||||
|
public static function operation(array $transactions, array $quotas, array $meta, array &$context): void {
|
||||||
|
if (!isset($context['sandbox']['index'])) {
|
||||||
|
$context['sandbox']['index'] = 0;
|
||||||
|
$context['sandbox']['total'] = count($transactions);
|
||||||
|
$context['sandbox']['quotas'] = $quotas;
|
||||||
|
// Seed results with the immutable import metadata (no key
|
||||||
|
// collision with the accumulators) + the zeroed accumulators.
|
||||||
|
$context['results'] += $meta + [
|
||||||
|
'created' => 0,
|
||||||
|
'duplicates' => 0,
|
||||||
|
'matched' => 0,
|
||||||
|
'unmatched' => 0,
|
||||||
|
'created_total' => 0.0,
|
||||||
|
'duplicates_total' => 0.0,
|
||||||
|
'duplicates_list' => [],
|
||||||
|
'errors' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var \Drupal\figli_compta_ledger\SkipValidationContext $skip */
|
||||||
|
$skip = \Drupal::service('figli_compta_ledger.skip_validation_context');
|
||||||
|
/** @var \Drupal\figli_compta_ledger\Import\ClientMatcher $matcher */
|
||||||
|
$matcher = \Drupal::service('figli_compta_ledger.client_matcher');
|
||||||
|
|
||||||
|
$end = min($context['sandbox']['index'] + self::CHUNK, $context['sandbox']['total']);
|
||||||
|
while ($context['sandbox']['index'] < $end) {
|
||||||
|
$t = ReleveTransaction::fromArray($transactions[$context['sandbox']['index']]);
|
||||||
|
|
||||||
|
// Count-aware dedup: quota exhausted → already in base (this many
|
||||||
|
// times), skip but surface it on the result page for human review.
|
||||||
|
if (($context['sandbox']['quotas'][$t->fitid] ?? 0) <= 0) {
|
||||||
|
$context['results']['duplicates']++;
|
||||||
|
$context['results']['duplicates_total'] += $t->montant;
|
||||||
|
$context['results']['duplicates_list'][] = [
|
||||||
|
'date' => $t->date,
|
||||||
|
'montant' => $t->montant,
|
||||||
|
'libelle' => $t->libelle,
|
||||||
|
];
|
||||||
|
$context['sandbox']['index']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$context['sandbox']['quotas'][$t->fitid]--;
|
||||||
|
|
||||||
|
$client = $matcher->match($t->libelle);
|
||||||
|
// Sensible truncate for the required title field: the full label
|
||||||
|
// lives in field_notes, the title only backs it up as fallback
|
||||||
|
// (same libelle display rule as everywhere in /lignes).
|
||||||
|
$node = Node::create([
|
||||||
|
'type' => 'ligne_comptable',
|
||||||
|
'title' => mb_substr($t->libelle, 0, 255),
|
||||||
|
'uid' => \Drupal::currentUser()->id(),
|
||||||
|
'status' => 1,
|
||||||
|
'field_date_ligne' => $t->date,
|
||||||
|
'field_type_ligne' => 'autre',
|
||||||
|
// Immutable audit reference (written here, never again), plus
|
||||||
|
// the three "same value to start with" fields the associate
|
||||||
|
// corrects while sorting (see PLAN's HT vs TTC section).
|
||||||
|
'field_montant_releve' => $t->montant,
|
||||||
|
'field_montant_ht' => $t->montant,
|
||||||
|
'field_montant_ttc' => $t->montant,
|
||||||
|
'field_tva' => 0,
|
||||||
|
'field_notes' => $t->libelle,
|
||||||
|
'field_import_fitid' => $t->fitid,
|
||||||
|
'field_client' => $client ? $client->id() : NULL,
|
||||||
|
'field_flag' => [$meta['flag_tid']],
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
$skip->skip(fn () => $node->save());
|
||||||
|
$context['results']['created']++;
|
||||||
|
$context['results']['created_total'] += $t->montant;
|
||||||
|
$client ? $context['results']['matched']++ : $context['results']['unmatched']++;
|
||||||
|
}
|
||||||
|
catch (EntityStorageException $e) {
|
||||||
|
$context['results']['errors'][] = [
|
||||||
|
'libelle' => $t->libelle,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$context['sandbox']['index']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context['message'] = t('Import du relevé : @done/@total transactions', [
|
||||||
|
'@done' => $context['sandbox']['index'],
|
||||||
|
'@total' => $context['sandbox']['total'],
|
||||||
|
]);
|
||||||
|
$context['finished'] = $context['sandbox']['total'] > 0
|
||||||
|
? $context['sandbox']['index'] / $context['sandbox']['total']
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch finished callback: assembles the summary the result page
|
||||||
|
* reads -- including the accounting control total (file total must
|
||||||
|
* equal created + duplicates, to the centime; if not, a parsing bug
|
||||||
|
* silently ate a line somewhere, and the page says so loudly) -- and
|
||||||
|
* stores it in the private tempstore (per-user, request-safe), where
|
||||||
|
* ReleveImportResultController picks it up once and purges it.
|
||||||
|
*/
|
||||||
|
public static function finished(bool $success, array $results, array $operations): void {
|
||||||
|
if (!$success) {
|
||||||
|
\Drupal::messenger()->addError("L'import a échoué à mi-parcours. Les transactions déjà traitées sont enregistrées ; relancez l'import du même fichier, le dédoublonnage ne recréera que ce qui manque.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$created_total = round((float) ($results['created_total'] ?? 0.0), 2);
|
||||||
|
$duplicates_total = round((float) ($results['duplicates_total'] ?? 0.0), 2);
|
||||||
|
$file_total = round((float) ($results['file_total'] ?? 0.0), 2);
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'file_name' => (string) ($results['file_name'] ?? ''),
|
||||||
|
'tag' => (string) ($results['tag'] ?? ''),
|
||||||
|
'created' => (int) ($results['created'] ?? 0),
|
||||||
|
'duplicates' => (int) ($results['duplicates'] ?? 0),
|
||||||
|
'matched' => (int) ($results['matched'] ?? 0),
|
||||||
|
'unmatched' => (int) ($results['unmatched'] ?? 0),
|
||||||
|
'errors' => $results['errors'] ?? [],
|
||||||
|
'duplicates_list' => $results['duplicates_list'] ?? [],
|
||||||
|
'file_total' => $file_total,
|
||||||
|
'created_total' => $created_total,
|
||||||
|
'duplicates_total' => $duplicates_total,
|
||||||
|
// To the centime: every parsed transaction was either created or
|
||||||
|
// recognized as already in base. Any drift means a line vanished
|
||||||
|
// -- never expected, always announced.
|
||||||
|
'totals_ok' => abs($file_total - $created_total - $duplicates_total) < 0.005,
|
||||||
|
];
|
||||||
|
|
||||||
|
\Drupal::service('tempstore.private')->get('figli_compta_ledger')
|
||||||
|
->set('releve_import_result', $summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bank statement transaction, in the neutral shape every parser
|
||||||
|
* (CSV today, OFX/CMI maybe later) produces -- the rest of the import
|
||||||
|
* chain (dedup, client matching, node creation) only ever sees this.
|
||||||
|
*
|
||||||
|
* $date: AAAA-MM-JJ (converted from the bank format at parse time).
|
||||||
|
* $montant: signed, negative = money out (Débit), to the centime.
|
||||||
|
* $libelle: raw bank label (full, untruncated in CSV), the basis for
|
||||||
|
* client matching and the line's visible Notes.
|
||||||
|
* $fitid: dedup fingerprint, see CsvReleveParser::fitid().
|
||||||
|
*/
|
||||||
|
final class ReleveTransaction {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $date,
|
||||||
|
public readonly float $montant,
|
||||||
|
public readonly string $libelle,
|
||||||
|
public readonly string $fitid,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-array shape for Batch API serialization (operation args and
|
||||||
|
* sandbox are serialized between requests).
|
||||||
|
*/
|
||||||
|
public function toArray(): array {
|
||||||
|
return [
|
||||||
|
'date' => $this->date,
|
||||||
|
'montant' => $this->montant,
|
||||||
|
'libelle' => $this->libelle,
|
||||||
|
'fitid' => $this->fitid,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $values): self {
|
||||||
|
return new self(
|
||||||
|
(string) $values['date'],
|
||||||
|
(float) $values['montant'],
|
||||||
|
(string) $values['libelle'],
|
||||||
|
(string) $values['fitid'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger\Plugin\EntityReferenceSelection;
|
||||||
|
|
||||||
|
use Drupal\Component\Utility\Html;
|
||||||
|
use Drupal\Core\Entity\Attribute\EntityReferenceSelection;
|
||||||
|
use Drupal\Core\StringTranslation\TranslatableMarkup;
|
||||||
|
use Drupal\node\Plugin\EntityReferenceSelection\NodeSelection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricts field_entree_liee's candidates to "entrée" ligne_comptable
|
||||||
|
* nodes, scoped to the same client as the sortie being linked when one is
|
||||||
|
* already set on it.
|
||||||
|
*/
|
||||||
|
#[EntityReferenceSelection(
|
||||||
|
id: "figli_compta_ledger:entree_client",
|
||||||
|
label: new TranslatableMarkup("Entrées client (SAS Figures Libres)"),
|
||||||
|
entity_types: ["node"],
|
||||||
|
group: "figli_compta_ledger",
|
||||||
|
weight: 0,
|
||||||
|
)]
|
||||||
|
class EntreeClientSelection extends NodeSelection {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*
|
||||||
|
* 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') {
|
||||||
|
$query = parent::buildEntityQuery(NULL, $match_operator);
|
||||||
|
$query->condition('field_type_ligne', 'entree');
|
||||||
|
|
||||||
|
$entity = $this->configuration['entity'] ?? NULL;
|
||||||
|
if ($entity && $entity->hasField('field_client') && !$entity->get('field_client')->isEmpty()) {
|
||||||
|
$query->condition('field_client', $entity->get('field_client')->target_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($match)) {
|
||||||
|
$query->condition(
|
||||||
|
$query->orConditionGroup()
|
||||||
|
->condition('title', $match, $match_operator)
|
||||||
|
->condition('field_numero_facture', $match, $match_operator)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*
|
||||||
|
* Appends montant HT and the notes/détail text to the label -- several
|
||||||
|
* entrées for the same client often share the same title format ("EPAU -
|
||||||
|
* 2026-01-05"), so the amount and invoice reference are what actually let
|
||||||
|
* someone tell them apart when linking a sortie.
|
||||||
|
*/
|
||||||
|
public function getReferenceableEntities($match = NULL, $match_operator = 'CONTAINS', $limit = 0) {
|
||||||
|
$options = parent::getReferenceableEntities($match, $match_operator, $limit);
|
||||||
|
if (empty($options)) {
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($options as $items) {
|
||||||
|
$ids += array_keys($items);
|
||||||
|
}
|
||||||
|
$entities = $this->entityTypeManager->getStorage('node')->loadMultiple($ids);
|
||||||
|
|
||||||
|
foreach ($options as $bundle => &$items) {
|
||||||
|
foreach ($items as $id => $label) {
|
||||||
|
$entity = $entities[$id] ?? NULL;
|
||||||
|
if (!$entity) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$details = [];
|
||||||
|
if ($entity->hasField('field_montant_ht') && !$entity->get('field_montant_ht')->isEmpty()) {
|
||||||
|
$details[] = number_format((float) $entity->get('field_montant_ht')->value, 2, ',', ' ') . ' €';
|
||||||
|
}
|
||||||
|
if ($entity->hasField('field_notes') && !$entity->get('field_notes')->isEmpty()) {
|
||||||
|
$details[] = Html::escape($entity->get('field_notes')->value);
|
||||||
|
}
|
||||||
|
if ($details) {
|
||||||
|
$items[$id] = $label . ' — ' . implode(' — ', $details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\figli_compta_ledger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped opt-out of the répartition invariant check in
|
||||||
|
* figli_compta_ledger_node_presave().
|
||||||
|
*
|
||||||
|
* The historical state key ('figli_compta_ledger.skip_validation') is a flag
|
||||||
|
* shared by every request on the site, stored in the database: while a
|
||||||
|
* programmatic save (inline type change, bank statement import) holds it,
|
||||||
|
* a *concurrent* normal form save happening in another PHP-FPM request would
|
||||||
|
* silently skip validation too -- exactly the kind of hole an integrity
|
||||||
|
* check must never have. This service lives in the dependency injection
|
||||||
|
* container of its own request, so a skip here is physically invisible to
|
||||||
|
* every other request; the depth counter makes nested skips safe and the
|
||||||
|
* try/finally in skip() guarantees it unwinds on exceptions as well as on
|
||||||
|
* normal completion.
|
||||||
|
*
|
||||||
|
* The state key is still honored by node_presave() for backward compatibility
|
||||||
|
* with already-shipped migration scripts, but new code (batch imports,
|
||||||
|
* future migrations) must use this service instead.
|
||||||
|
*/
|
||||||
|
final class SkipValidationContext {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current skip depth (0 = validation active).
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private int $depth = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs $operation with the répartition-sum check disabled for this
|
||||||
|
* request only, restoring it afterwards whatever happens.
|
||||||
|
*
|
||||||
|
* @param callable $operation
|
||||||
|
* Typically fn () => $node->save().
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
* Whatever $operation returns.
|
||||||
|
*/
|
||||||
|
public function skip(callable $operation): mixed {
|
||||||
|
$this->depth++;
|
||||||
|
try {
|
||||||
|
return $operation();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$this->depth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the répartition-sum check is currently disabled for this
|
||||||
|
* request. Read by figli_compta_ledger_node_presave().
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
* TRUE when a skip() is currently in progress.
|
||||||
|
*/
|
||||||
|
public function isSkipped(): bool {
|
||||||
|
return $this->depth > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
{#
|
||||||
|
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' : '' }}">SAS</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</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 %}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
{#
|
||||||
|
Répartition / Soldes: solde par compte (all-time + évolution année par
|
||||||
|
année), split out of the general dashboard (figli-compta-dashboard.html.twig)
|
||||||
|
to keep that one focused on activité/CA/type/client.
|
||||||
|
|
||||||
|
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
|
||||||
|
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
|
||||||
|
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' : '' }}">SAS</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</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>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endverbatim %}
|
||||||
@@ -1,36 +1,89 @@
|
|||||||
{#
|
{#
|
||||||
Aggregate view: solde par compte / solde par client. The spreadsheet-like
|
Charts and aggregate totals: chiffre d'affaires par année, répartition
|
||||||
|
par type, top clients. Solde par compte lives on its own page now (see
|
||||||
|
figli-compta-dashboard-repartition.html.twig). 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' : '' }}">SAS</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</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>
|
||||||
<td class="amount">{{ formatEur(row.solde) }}</td>
|
|
||||||
</tr>
|
<section class="figli-chart-section">
|
||||||
<tr class="total">
|
<h2>Chiffre d'affaires par année</h2>
|
||||||
<td>TOTAL</td>
|
<p class="figli-note">Total des entrées client (montant HT) facturées chaque année.</p>
|
||||||
<td class="amount">{{ formatEur(table.totals.entrees) }}</td>
|
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
|
||||||
<td class="amount">{{ formatEur(table.totals.sorties) }}</td>
|
</section>
|
||||||
<td class="amount">{{ formatEur(table.totals.solde) }}</td>
|
|
||||||
</tr>
|
<section class="figli-chart-section">
|
||||||
</tbody>
|
<h2>Répartition de l'activité par type</h2>
|
||||||
</table>
|
<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>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Charges structurelles par client</h2>
|
||||||
|
<p class="figli-note">Montant total (HT, valeur absolue) des charges structurelles par vendeur/organisme, toutes années confondues.</p>
|
||||||
|
<h-bar-chart :items="chargeParClientItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
|
||||||
|
<div class="figli-chart-total">Total : {{ formatEurRound(sumItems(chargeParClientItems)) }}</div>
|
||||||
|
<div class="figli-year-hbar-grid">
|
||||||
|
<div class="figli-year-hbar-card" v-for="y in chargeParClientParAnnee" :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 class="figli-chart-total is-compact">Total : {{ formatEurRound(sumItems(y.items)) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="figli-chart-section">
|
||||||
|
<h2>Total des versements par compte</h2>
|
||||||
|
<p class="figli-note">Montant total versé à chaque compte associé, plus les salaires/stages et sous-traitants, toutes années confondues.</p>
|
||||||
|
<h-bar-chart :items="versementsParCompteItems" :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 versementsParCompteParAnnee" :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>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,40 +1,94 @@
|
|||||||
{#
|
{#
|
||||||
Dashboard shell: Drupal renders the page (nav, auth, permissions).
|
Dashboard shell: Drupal renders the page (nav, auth, permissions).
|
||||||
dashboard.js (Vue 3) fetches JSON:API and renders a spreadsheet-like table
|
home.js (Vue 3) renders a spreadsheet-like table of every ligne comptable,
|
||||||
of every ligne comptable, with filters and month/year grouping, client-side.
|
with filters and month/year grouping applied server-side (see
|
||||||
"Ajouter une ligne" opens the real Drupal node form in a modal
|
LedgerRowsController). "Ajouter une ligne" opens the real Drupal node form
|
||||||
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
|
in a modal (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' : '' }}">SAS</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</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">
|
||||||
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
|
|
||||||
|
|
||||||
<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>Libellé / Détails
|
||||||
|
<span class="figli-filter-row">
|
||||||
|
<input type="text" v-model="filterQ" placeholder="Rechercher…" autocomplete="off" class="figli-client-input" />
|
||||||
|
<button v-if="filterQ" type="button" class="figli-filter-clear" @click="filterQ = ''" 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>
|
||||||
|
|
||||||
@@ -50,52 +104,278 @@
|
|||||||
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
|
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
|
<label class="figli-checkbox">
|
||||||
|
<input type="checkbox" v-model="onlyFlagged" /> Signalées uniquement
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<datalist id="figli-flag-datalist">
|
||||||
|
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<span class="figli-count" v-if="!loading">{{ rows.length }} ligne{{ rows.length > 1 ? 's' : '' }} chargée{{ rows.length > 1 ? 's' : '' }}{{ 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>
|
<table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<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 class="amount">Montant HT</th>
|
<th>Signalement</th>
|
||||||
<th class="amount">Montant TTC</th>
|
<th class="amount figli-ht-col">HT</th>
|
||||||
|
<th class="amount figli-cotisation-col">1,1%</th>
|
||||||
|
<th class="amount figli-tva-col">TVA</th>
|
||||||
|
<th class="amount figli-ttc-col">TTC</th>
|
||||||
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
|
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
|
||||||
<th class="amount">Écart</th>
|
<th class="amount">Écart</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr ref="topSentinel" class="figli-sentinel-row">
|
||||||
|
<td :colspan="7 + allComptes.length + 5">
|
||||||
|
<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 + 1">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
|
<td :colspan="7 + allComptes.length + 5">{{ 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>{{ item.date }}</td>
|
<td class="actions-col">
|
||||||
<td>{{ item.client || '—' }}</td>
|
<button type="button" class="figli-edit-btn" title="Modifier" @click="openEditForm(item.nid)">
|
||||||
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
|
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<td class="figli-libelle">{{ item.libelle }}</td>
|
<path d="M13.5 3.5l3 3L6 17l-3.5.5.5-3.5L13.5 3.5z" />
|
||||||
<td class="amount">{{ formatEur(item.montant_ht) }}</td>
|
</svg>
|
||||||
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(item.date) }}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-if="isEditingCell(item, 'client')"
|
||||||
|
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
|
||||||
|
v-else
|
||||||
|
class="figli-badge figli-badge-editable"
|
||||||
|
:class="'type-' + item.type"
|
||||||
|
title="Cliquer pour changer le type"
|
||||||
|
@click="startEditType(item)"
|
||||||
|
>{{ 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 class="amount figli-ht-col" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
|
||||||
|
<td class="amount figli-cotisation-col">{{ formatEur(item.cotisation) }}</td>
|
||||||
|
<td class="amount figli-tva-col" :class="{'figli-tva-nonofficial': !isTvaOfficial(item.tva)}" :title="!isTvaOfficial(item.tva) ? 'Taux TVA non officiel (ni 0, 2,1, 5,5, 10 ni 20 %)' : null">{{ formatPct(item.tva) }}</td>
|
||||||
|
<td class="amount figli-ttc-col">{{ formatEur(item.montant_ttc) }}</td>
|
||||||
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
|
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
|
||||||
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
<td class="amount" :class="{'figli-ecart': item.hasError && item.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
<tr ref="bottomSentinel" class="figli-sentinel-row">
|
||||||
|
<td :colspan="7 + allComptes.length + 5">
|
||||||
|
<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 colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td>
|
<td colspan="7">
|
||||||
<td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td>
|
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
|
||||||
<td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td>
|
{% endverbatim %}
|
||||||
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="soldeClass(footerTotals.parCompte[c])">{{ formatEur(footerTotals.parCompte[c]) }}</td>
|
{% if can_import_releve %}
|
||||||
<td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
|
<a href="{{ path('figli_compta_ledger.releve_import_form') }}" class="button">Importer un relevé</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_view_history %}
|
||||||
|
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
|
||||||
|
{% endif %}
|
||||||
|
{% verbatim %}
|
||||||
|
Solde {{ currentYear || '…' }} (créditeur / débiteur)
|
||||||
|
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
|
||||||
|
</td>
|
||||||
|
<td class="amount figli-ht-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
|
||||||
|
<td class="amount figli-cotisation-col">{{ currentYearTotals ? formatEur(currentYearTotals.cotisation) : '' }}</td>
|
||||||
|
<td class="amount figli-tva-col"></td>
|
||||||
|
<td class="amount figli-ttc-col">{{ 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.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="figli-totals-row">
|
||||||
|
<td colspan="6">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 %}
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
{#
|
||||||
|
Résultat d'import d'un relevé bancaire (ReleveImportResultController).
|
||||||
|
Page simple sans Vue : Twig rend tout, les chiffres viennent du tempstore
|
||||||
|
privé (une seule lecture, purgée aussitôt). Mêmes classes de navigation
|
||||||
|
que les autres pages du module (figli-page-nav, admin-chrome.css).
|
||||||
|
#}
|
||||||
|
<nav class="figli-page-nav">
|
||||||
|
<a href="{{ path('figli_compta_ledger.home') }}">Grand livre</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard') }}">SAS</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}">Répartition/Soldes</a>
|
||||||
|
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}">Par compte</a>
|
||||||
|
</nav>
|
||||||
|
<div class="figli-releve-result">
|
||||||
|
<h2>Import terminé — {{ summary.file_name }}</h2>
|
||||||
|
<p class="figli-releve-tag">Chaque ligne créée porte le signalement
|
||||||
|
<span class="figli-flag-badge">{{ summary.tag }}</span> et apparaît sur
|
||||||
|
<a href="{{ lignes_url }}">/lignes</a> avec un liseré rouge (répartition à faire) tant qu'elle n'a pas été triée.</p>
|
||||||
|
|
||||||
|
<div class="figli-releve-stats">
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.created }}</span>
|
||||||
|
<span class="figli-releve-stat-label">lignes créées</span>
|
||||||
|
</div>
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.duplicates }}</span>
|
||||||
|
<span class="figli-releve-stat-label">doublons déjà en base (ignorés)</span>
|
||||||
|
</div>
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.matched }}</span>
|
||||||
|
<span class="figli-releve-stat-label">clients rapprochés</span>
|
||||||
|
</div>
|
||||||
|
<div class="figli-releve-stat">
|
||||||
|
<span class="figli-releve-stat-value">{{ summary.unmatched }}</span>
|
||||||
|
<span class="figli-releve-stat-label">sans client (à remplir)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Totaux de contrôle</h3>
|
||||||
|
<table class="figli-releve-totals">
|
||||||
|
<tbody>
|
||||||
|
<tr><th scope="row">Somme des mouvements du fichier</th><td class="figli-releve-amount">{{ summary.file_total }}</td></tr>
|
||||||
|
<tr><th scope="row">Somme des lignes créées</th><td class="figli-releve-amount">{{ summary.created_total }}</td></tr>
|
||||||
|
<tr><th scope="row">Somme des doublons ignorés</th><td class="figli-releve-amount">{{ summary.duplicates_total }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if summary.totals_ok %}
|
||||||
|
<p class="figli-releve-ok">Équilibre vérifié au centime : aucun mouvement perdu ni dupliqué.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="figli-releve-alert">Anomalie de parsing : la somme des mouvements du fichier ne correspond pas à « créées + doublons ». Vérifiez le fichier source et les lignes importées avant de trier le lot.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if summary.errors_count %}
|
||||||
|
<h3>Erreurs ({{ summary.errors_count }})</h3>
|
||||||
|
<ul class="figli-releve-errors">
|
||||||
|
{% for e in summary.errors %}<li>{{ e }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if summary.duplicates_list %}
|
||||||
|
<h3>Doublons ignorés ({{ summary.duplicates_list|length }})</h3>
|
||||||
|
<p>Ces transactions figuraient déjà en base (empreinte identique, toutes provenances confondues). Vérifiez la liste si vous attendiez un mouvement.</p>
|
||||||
|
<div class="figli-releve-dup-wrap">
|
||||||
|
<table class="figli-releve-dups">
|
||||||
|
<thead>
|
||||||
|
<tr><th scope="col">Date</th><th scope="col">Montant</th><th scope="col">Libellé</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in summary.duplicates_list %}
|
||||||
|
<tr><td>{{ d.date }}</td><td class="figli-releve-amount">{{ d.montant }}</td><td>{{ d.libelle }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="figli-releve-actions">
|
||||||
|
<a class="button button--primary" href="{{ lignes_url }}">Voir les lignes importées sur /lignes</a>
|
||||||
|
<a class="button" href="{{ import_url }}">Importer un autre relevé</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,922 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// phpcs:ignoreFile
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
* Drupal site-specific configuration file.
|
|
||||||
*
|
|
||||||
* IMPORTANT NOTE:
|
|
||||||
* This file may have been set to read-only by the Drupal installation program.
|
|
||||||
* If you make changes to this file, be sure to protect it again after making
|
|
||||||
* your modifications. Failure to remove write permissions to this file is a
|
|
||||||
* security risk.
|
|
||||||
*
|
|
||||||
* In order to use the selection rules below the multisite aliasing file named
|
|
||||||
* sites/sites.php must be present. Its optional settings will be loaded, and
|
|
||||||
* the aliases in the array $sites will override the default directory rules
|
|
||||||
* below. See sites/example.sites.php for more information about aliases.
|
|
||||||
*
|
|
||||||
* The configuration directory will be discovered by stripping the website's
|
|
||||||
* hostname from left to right and pathname from right to left. The first
|
|
||||||
* configuration file found will be used and any others will be ignored. If no
|
|
||||||
* other configuration file is found then the default configuration file at
|
|
||||||
* 'sites/default' will be used.
|
|
||||||
*
|
|
||||||
* For example, for a fictitious site installed at
|
|
||||||
* https://www.drupal.org:8080/my-site/test/, the 'settings.php' file is searched
|
|
||||||
* for in the following directories:
|
|
||||||
*
|
|
||||||
* - sites/8080.www.drupal.org.my-site.test
|
|
||||||
* - sites/www.drupal.org.my-site.test
|
|
||||||
* - sites/drupal.org.my-site.test
|
|
||||||
* - sites/org.my-site.test
|
|
||||||
*
|
|
||||||
* - sites/8080.www.drupal.org.my-site
|
|
||||||
* - sites/www.drupal.org.my-site
|
|
||||||
* - sites/drupal.org.my-site
|
|
||||||
* - sites/org.my-site
|
|
||||||
*
|
|
||||||
* - sites/8080.www.drupal.org
|
|
||||||
* - sites/www.drupal.org
|
|
||||||
* - sites/drupal.org
|
|
||||||
* - sites/org
|
|
||||||
*
|
|
||||||
* - sites/default
|
|
||||||
*
|
|
||||||
* Note that if you are installing on a non-standard port number, prefix the
|
|
||||||
* hostname with that number. For example,
|
|
||||||
* https://www.drupal.org:8080/my-site/test/ could be loaded from
|
|
||||||
* sites/8080.www.drupal.org.my-site.test/.
|
|
||||||
*
|
|
||||||
* @see example.sites.php
|
|
||||||
* @see \Drupal\Core\DrupalKernel::getSitePath()
|
|
||||||
*
|
|
||||||
* In addition to customizing application settings through variables in
|
|
||||||
* settings.php, you can create a services.yml file in the same directory to
|
|
||||||
* register custom, site-specific service definitions and/or swap out default
|
|
||||||
* implementations with custom ones.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database settings:
|
|
||||||
*
|
|
||||||
* The $databases array specifies the database connection or
|
|
||||||
* connections that Drupal may use. Drupal is able to connect
|
|
||||||
* to multiple databases, including multiple types of databases,
|
|
||||||
* during the same request.
|
|
||||||
*
|
|
||||||
* One example of the simplest connection array is shown below. To use the
|
|
||||||
* sample settings, copy and uncomment the code below and paste it after the
|
|
||||||
* $databases declaration. You will need to replace the database username and
|
|
||||||
* password and possibly the host and port with the appropriate credentials for
|
|
||||||
* your database system.
|
|
||||||
*
|
|
||||||
* The next section describes how to customize the $databases array for more
|
|
||||||
* specific needs.
|
|
||||||
*
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default'] = [
|
|
||||||
* 'database' => 'database_name',
|
|
||||||
* 'username' => 'sql_username',
|
|
||||||
* 'password' => 'sql_password',
|
|
||||||
* 'host' => 'localhost',
|
|
||||||
* 'port' => '3306',
|
|
||||||
* 'driver' => 'mysql',
|
|
||||||
* 'prefix' => '',
|
|
||||||
* 'collation' => 'utf8mb4_general_ci',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
*/
|
|
||||||
$databases = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Customizing database settings.
|
|
||||||
*
|
|
||||||
* Many of the values of the $databases array can be customized for your
|
|
||||||
* particular database system. Refer to the sample in the section above as a
|
|
||||||
* starting point.
|
|
||||||
*
|
|
||||||
* The "driver" property indicates what Drupal database driver the
|
|
||||||
* connection should use. This is usually the same as the name of the
|
|
||||||
* database type, such as mysql or sqlite, but not always. The other
|
|
||||||
* properties will vary depending on the driver. For SQLite, you must
|
|
||||||
* specify a database file name in a directory that is writable by the
|
|
||||||
* webserver. For most other drivers, you must specify a
|
|
||||||
* username, password, host, and database name.
|
|
||||||
*
|
|
||||||
* Drupal core implements drivers for mysql, pgsql, and sqlite. Other drivers
|
|
||||||
* can be provided by contributed or custom modules. To use a contributed or
|
|
||||||
* custom driver, the "namespace" property must be set to the namespace of the
|
|
||||||
* driver. The code in this namespace must be autoloadable prior to connecting
|
|
||||||
* to the database, and therefore, prior to when module root namespaces are
|
|
||||||
* added to the autoloader. To add the driver's namespace to the autoloader,
|
|
||||||
* set the "autoload" property to the PSR-4 base directory of the driver's
|
|
||||||
* namespace. This is optional for projects managed with Composer if the
|
|
||||||
* driver's namespace is in Composer's autoloader.
|
|
||||||
*
|
|
||||||
* For each database, you may optionally specify multiple "target" databases.
|
|
||||||
* A target database allows Drupal to try to send certain queries to a
|
|
||||||
* different database if it can but fall back to the default connection if not.
|
|
||||||
* That is useful for primary/replica replication, as Drupal may try to connect
|
|
||||||
* to a replica server when appropriate and if one is not available will simply
|
|
||||||
* fall back to the single primary server (The terms primary/replica are
|
|
||||||
* traditionally referred to as master/slave in database server documentation).
|
|
||||||
*
|
|
||||||
* The general format for the $databases array is as follows:
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default'] = $info_array;
|
|
||||||
* $databases['default']['replica'][] = $info_array;
|
|
||||||
* $databases['default']['replica'][] = $info_array;
|
|
||||||
* $databases['extra']['default'] = $info_array;
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* In the above example, $info_array is an array of settings described above.
|
|
||||||
* The first line sets a "default" database that has one primary database
|
|
||||||
* (the second level default). The second and third lines create an array
|
|
||||||
* of potential replica databases. Drupal will select one at random for a given
|
|
||||||
* request as needed. The fourth line creates a new database with a name of
|
|
||||||
* "extra".
|
|
||||||
*
|
|
||||||
* For MySQL, MariaDB or equivalent databases the 'isolation_level' option can
|
|
||||||
* be set. The recommended transaction isolation level for Drupal sites is
|
|
||||||
* 'READ COMMITTED'. The 'REPEATABLE READ' option is supported but can result
|
|
||||||
* in deadlocks, the other two options are 'READ UNCOMMITTED' and 'SERIALIZABLE'.
|
|
||||||
* They are available but not supported; use them at your own risk. For more
|
|
||||||
* info:
|
|
||||||
* https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
|
|
||||||
*
|
|
||||||
* On your settings.php, change the isolation level:
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default']['init_commands'] = [
|
|
||||||
* 'isolation_level' => 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* You can optionally set a prefix for all database table names by using the
|
|
||||||
* 'prefix' setting. If a prefix is specified, the table name will be prepended
|
|
||||||
* with its value. Be sure to use valid database characters only, usually
|
|
||||||
* alphanumeric and underscore. If no prefix is desired, do not set the 'prefix'
|
|
||||||
* key or set its value to an empty string ''.
|
|
||||||
*
|
|
||||||
* For example, to have all database table prefixed with 'main_', set:
|
|
||||||
* @code
|
|
||||||
* 'prefix' => 'main_',
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* Advanced users can add or override initial commands to execute when
|
|
||||||
* connecting to the database server, as well as PDO connection settings. For
|
|
||||||
* example, to enable MySQL SELECT queries to exceed the max_join_size system
|
|
||||||
* variable, and to reduce the database connection timeout to 5 seconds:
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default'] = [
|
|
||||||
* 'init_commands' => [
|
|
||||||
* 'big_selects' => 'SET SQL_BIG_SELECTS=1',
|
|
||||||
* ],
|
|
||||||
* 'pdo' => [
|
|
||||||
* PDO::ATTR_TIMEOUT => 5,
|
|
||||||
* ],
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* WARNING: The above defaults are designed for database portability. Changing
|
|
||||||
* them may cause unexpected behavior, including potential data loss. See
|
|
||||||
* https://www.drupal.org/docs/8/api/database-api/database-configuration for
|
|
||||||
* more information on these defaults and the potential issues.
|
|
||||||
*
|
|
||||||
* More details can be found in the constructor methods for each driver:
|
|
||||||
* - \Drupal\mysql\Driver\Database\mysql\Connection::__construct()
|
|
||||||
* - \Drupal\pgsql\Driver\Database\pgsql\Connection::__construct()
|
|
||||||
* - \Drupal\sqlite\Driver\Database\sqlite\Connection::__construct()
|
|
||||||
*
|
|
||||||
* Sample Database configuration format for PostgreSQL (pgsql):
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default'] = [
|
|
||||||
* 'driver' => 'pgsql',
|
|
||||||
* 'database' => 'database_name',
|
|
||||||
* 'username' => 'sql_username',
|
|
||||||
* 'password' => 'sql_password',
|
|
||||||
* 'host' => 'localhost',
|
|
||||||
* 'prefix' => '',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* Sample Database configuration format for SQLite (sqlite):
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default'] = [
|
|
||||||
* 'driver' => 'sqlite',
|
|
||||||
* 'database' => '/path/to/database_filename',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* Sample Database configuration format for a driver in a contributed module:
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default'] = [
|
|
||||||
* 'driver' => 'my_driver',
|
|
||||||
* 'namespace' => 'Drupal\my_module\Driver\Database\my_driver',
|
|
||||||
* 'autoload' => 'modules/my_module/src/Driver/Database/my_driver/',
|
|
||||||
* 'database' => 'database_name',
|
|
||||||
* 'username' => 'sql_username',
|
|
||||||
* 'password' => 'sql_password',
|
|
||||||
* 'host' => 'localhost',
|
|
||||||
* 'prefix' => '',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* Sample Database configuration format for a driver that is extending another
|
|
||||||
* database driver.
|
|
||||||
* @code
|
|
||||||
* $databases['default']['default'] = [
|
|
||||||
* 'driver' => 'my_driver',
|
|
||||||
* 'namespace' => 'Drupal\my_module\Driver\Database\my_driver',
|
|
||||||
* 'autoload' => 'modules/my_module/src/Driver/Database/my_driver/',
|
|
||||||
* 'database' => 'database_name',
|
|
||||||
* 'username' => 'sql_username',
|
|
||||||
* 'password' => 'sql_password',
|
|
||||||
* 'host' => 'localhost',
|
|
||||||
* 'prefix' => '',
|
|
||||||
* 'dependencies' => [
|
|
||||||
* 'parent_module' => [
|
|
||||||
* 'namespace' => 'Drupal\parent_module',
|
|
||||||
* 'autoload' => 'core/modules/parent_module/src/',
|
|
||||||
* ],
|
|
||||||
* ],
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location of the site configuration files.
|
|
||||||
*
|
|
||||||
* The $settings['config_sync_directory'] specifies the location of file system
|
|
||||||
* directory used for syncing configuration data. On install, the directory is
|
|
||||||
* created. This is used for configuration imports.
|
|
||||||
*
|
|
||||||
* The default location for this directory is inside a randomly-named
|
|
||||||
* directory in the public files path. The setting below allows you to set
|
|
||||||
* its location.
|
|
||||||
*/
|
|
||||||
# $settings['config_sync_directory'] = '/directory/outside/webroot';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings:
|
|
||||||
*
|
|
||||||
* $settings contains environment-specific configuration, such as the files
|
|
||||||
* directory and reverse proxy address, and temporary configuration, such as
|
|
||||||
* security overrides.
|
|
||||||
*
|
|
||||||
* @see \Drupal\Core\Site\Settings::get()
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Salt for one-time login links, cancel links, form tokens, etc.
|
|
||||||
*
|
|
||||||
* This variable will be set to a random value by the installer. All one-time
|
|
||||||
* login links will be invalidated if the value is changed. Note that if your
|
|
||||||
* site is deployed on a cluster of web servers, you must ensure that this
|
|
||||||
* variable has the same value on each server.
|
|
||||||
*
|
|
||||||
* For enhanced security, you may set this variable to the contents of a file
|
|
||||||
* outside your document root, and vary the value across environments (like
|
|
||||||
* production and development); you should also ensure that this file is not
|
|
||||||
* stored with backups of your database.
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* @code
|
|
||||||
* $settings['hash_salt'] = file_get_contents('/home/example/salt.txt');
|
|
||||||
* @endcode
|
|
||||||
*/
|
|
||||||
$settings['hash_salt'] = 'nXTGpRvb9ZFPyxW3-W0_JH7hB_ON_YSsA_1mXJ7Lz5S4n8owFkpdfnXAF7HPUWr4dFxqPGtWqw';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deployment identifier.
|
|
||||||
*
|
|
||||||
* Drupal's dependency injection container will be automatically invalidated and
|
|
||||||
* rebuilt when the Drupal core version changes. When updating contributed or
|
|
||||||
* custom code that changes the container, changing this identifier will also
|
|
||||||
* allow the container to be invalidated as soon as code is deployed.
|
|
||||||
*/
|
|
||||||
# $settings['deployment_identifier'] = \Drupal::VERSION;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access control for update.php script.
|
|
||||||
*
|
|
||||||
* If you are updating your Drupal installation using the update.php script but
|
|
||||||
* are not logged in using either an account with the "Administer software
|
|
||||||
* updates" permission or the site maintenance account (the account that was
|
|
||||||
* created during installation), you will need to modify the access check
|
|
||||||
* statement below. Change the FALSE to a TRUE to disable the access check.
|
|
||||||
* After finishing the upgrade, be sure to open this file again and change the
|
|
||||||
* TRUE back to a FALSE!
|
|
||||||
*/
|
|
||||||
$settings['update_free_access'] = FALSE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback to HTTP for Update Status and for fetching security advisories.
|
|
||||||
*
|
|
||||||
* If your site fails to connect to updates.drupal.org over HTTPS (either when
|
|
||||||
* fetching data on available updates, or when fetching the feed of critical
|
|
||||||
* security announcements), you may uncomment this setting and set it to TRUE to
|
|
||||||
* allow an insecure fallback to HTTP. Note that doing so will open your site up
|
|
||||||
* to a potential man-in-the-middle attack. You should instead attempt to
|
|
||||||
* resolve the issues before enabling this option.
|
|
||||||
* @see https://www.drupal.org/docs/system-requirements/php-requirements#openssl
|
|
||||||
* @see https://en.wikipedia.org/wiki/Man-in-the-middle_attack
|
|
||||||
* @see \Drupal\update\UpdateFetcher
|
|
||||||
* @see \Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher
|
|
||||||
*/
|
|
||||||
# $settings['update_fetch_with_http_fallback'] = TRUE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* External access proxy settings:
|
|
||||||
*
|
|
||||||
* If your site must access the Internet via a web proxy then you can enter the
|
|
||||||
* proxy settings here. Set the full URL of the proxy, including the port, in
|
|
||||||
* variables:
|
|
||||||
* - $settings['http_client_config']['proxy']['http']: The proxy URL for HTTP
|
|
||||||
* requests.
|
|
||||||
* - $settings['http_client_config']['proxy']['https']: The proxy URL for HTTPS
|
|
||||||
* requests.
|
|
||||||
* You can pass in the user name and password for basic authentication in the
|
|
||||||
* URLs in these settings.
|
|
||||||
*
|
|
||||||
* You can also define an array of host names that can be accessed directly,
|
|
||||||
* bypassing the proxy, in $settings['http_client_config']['proxy']['no'].
|
|
||||||
*/
|
|
||||||
# $settings['http_client_config']['proxy']['http'] = 'http://proxy_user:proxy_pass@example.com:8080';
|
|
||||||
# $settings['http_client_config']['proxy']['https'] = 'http://proxy_user:proxy_pass@example.com:8080';
|
|
||||||
# $settings['http_client_config']['proxy']['no'] = ['127.0.0.1', 'localhost'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse Proxy Configuration:
|
|
||||||
*
|
|
||||||
* Reverse proxy servers are often used to enhance the performance
|
|
||||||
* of heavily visited sites and may also provide other site caching,
|
|
||||||
* security, or encryption benefits. In an environment where Drupal
|
|
||||||
* is behind a reverse proxy, the real IP address of the client should
|
|
||||||
* be determined such that the correct client IP address is available
|
|
||||||
* to Drupal's logging and access management systems. In the most simple
|
|
||||||
* scenario, the proxy server will add an X-Forwarded-For header to the request
|
|
||||||
* that contains the client IP address. However, HTTP headers are vulnerable to
|
|
||||||
* spoofing, where a malicious client could bypass restrictions by setting the
|
|
||||||
* X-Forwarded-For header directly. Therefore, Drupal's proxy configuration
|
|
||||||
* requires the IP addresses of all remote proxies to be specified in
|
|
||||||
* $settings['reverse_proxy_addresses'] to work correctly.
|
|
||||||
*
|
|
||||||
* Enable this setting to get Drupal to determine the client IP from the
|
|
||||||
* X-Forwarded-For header. If you are unsure about this setting, do not have a
|
|
||||||
* reverse proxy, or Drupal operates in a shared hosting environment, this
|
|
||||||
* setting should remain commented out.
|
|
||||||
*
|
|
||||||
* In order for this setting to be used you must specify every possible
|
|
||||||
* reverse proxy IP address in $settings['reverse_proxy_addresses'].
|
|
||||||
* If a complete list of reverse proxies is not available in your
|
|
||||||
* environment (for example, if you use a CDN) you may set the
|
|
||||||
* $_SERVER['REMOTE_ADDR'] variable directly in settings.php.
|
|
||||||
* Be aware, however, that it is likely that this would allow IP
|
|
||||||
* address spoofing unless more advanced precautions are taken.
|
|
||||||
*/
|
|
||||||
# $settings['reverse_proxy'] = TRUE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse proxy addresses.
|
|
||||||
*
|
|
||||||
* Specify every reverse proxy IP address in your environment, as an array of
|
|
||||||
* IPv4/IPv6 addresses or subnets in CIDR notation. This setting is required if
|
|
||||||
* $settings['reverse_proxy'] is TRUE.
|
|
||||||
*/
|
|
||||||
# $settings['reverse_proxy_addresses'] = ['a.b.c.d', 'e.f.g.h/24', ...];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse proxy trusted headers.
|
|
||||||
*
|
|
||||||
* Sets which headers to trust from your reverse proxy.
|
|
||||||
*
|
|
||||||
* Common values are:
|
|
||||||
* - \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_FOR
|
|
||||||
* - \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_HOST
|
|
||||||
* - \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PORT
|
|
||||||
* - \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PROTO
|
|
||||||
* - \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED
|
|
||||||
*
|
|
||||||
* Note the default value of
|
|
||||||
* @code
|
|
||||||
* \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_FOR | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_HOST | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PORT | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PROTO | \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED
|
|
||||||
* @endcode
|
|
||||||
* is not secure by default. The value should be set to only the specific
|
|
||||||
* headers the reverse proxy uses. For example:
|
|
||||||
* @code
|
|
||||||
* \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_FOR | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_HOST | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PORT | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PROTO
|
|
||||||
* @endcode
|
|
||||||
* This would trust the following headers:
|
|
||||||
* - X_FORWARDED_FOR
|
|
||||||
* - X_FORWARDED_HOST
|
|
||||||
* - X_FORWARDED_PROTO
|
|
||||||
* - X_FORWARDED_PORT
|
|
||||||
*
|
|
||||||
* @see \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_FOR
|
|
||||||
* @see \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_HOST
|
|
||||||
* @see \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PORT
|
|
||||||
* @see \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PROTO
|
|
||||||
* @see \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED
|
|
||||||
* @see \Symfony\Component\HttpFoundation\Request::setTrustedProxies
|
|
||||||
*/
|
|
||||||
# $settings['reverse_proxy_trusted_headers'] = \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_FOR | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_HOST | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PORT | \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PROTO | \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page caching:
|
|
||||||
*
|
|
||||||
* By default, Drupal sends a "Vary: Cookie" HTTP header for anonymous page
|
|
||||||
* views. This tells a HTTP proxy that it may return a page from its local
|
|
||||||
* cache without contacting the web server, if the user sends the same Cookie
|
|
||||||
* header as the user who originally requested the cached page. Without "Vary:
|
|
||||||
* Cookie", authenticated users would also be served the anonymous page from
|
|
||||||
* the cache. If the site has mostly anonymous users except a few known
|
|
||||||
* editors/administrators, the Vary header can be omitted. This allows for
|
|
||||||
* better caching in HTTP proxies (including reverse proxies), i.e. even if
|
|
||||||
* clients send different cookies, they still get content served from the cache.
|
|
||||||
* However, authenticated users should access the site directly (i.e. not use an
|
|
||||||
* HTTP proxy, and bypass the reverse proxy if one is used) in order to avoid
|
|
||||||
* getting cached pages from the proxy.
|
|
||||||
*/
|
|
||||||
# $settings['omit_vary_cookie'] = TRUE;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache TTL for client error (4xx) responses.
|
|
||||||
*
|
|
||||||
* Items cached per-URL tend to result in a large number of cache items, and
|
|
||||||
* this can be problematic on 404 pages which by their nature are unbounded. A
|
|
||||||
* fixed TTL can be set for these items, defaulting to one hour, so that cache
|
|
||||||
* backends which do not support LRU can purge older entries. To disable caching
|
|
||||||
* of client error responses set the value to 0. Currently applies only to
|
|
||||||
* page_cache module.
|
|
||||||
*/
|
|
||||||
# $settings['cache_ttl_4xx'] = 3600;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expiration of cached forms.
|
|
||||||
*
|
|
||||||
* Drupal's Form API stores details of forms in a cache and these entries are
|
|
||||||
* kept for at least 6 hours by default. Expired entries are cleared by cron.
|
|
||||||
*
|
|
||||||
* @see \Drupal\Core\Form\FormCache::setCache()
|
|
||||||
*/
|
|
||||||
# $settings['form_cache_expiration'] = 21600;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class Loader.
|
|
||||||
*
|
|
||||||
* If the APCu extension is detected, the classloader will be optimized to use
|
|
||||||
* it. Set to FALSE to disable this.
|
|
||||||
*
|
|
||||||
* @see https://getcomposer.org/doc/articles/autoloader-optimization.md
|
|
||||||
*/
|
|
||||||
# $settings['class_loader_auto_detect'] = FALSE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default mode for directories and files written by Drupal.
|
|
||||||
*
|
|
||||||
* Value should be in PHP Octal Notation, with leading zero.
|
|
||||||
*/
|
|
||||||
# $settings['file_chmod_directory'] = 0775;
|
|
||||||
# $settings['file_chmod_file'] = 0664;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimized assets path:
|
|
||||||
*
|
|
||||||
* A local file system path where optimized assets will be stored. This directory
|
|
||||||
* must exist and be writable by Drupal. This directory must be relative to
|
|
||||||
* the Drupal installation directory and be accessible over the web.
|
|
||||||
*/
|
|
||||||
# $settings['file_assets_path'] = 'sites/default/files';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asset aggregate garbage collection threshold.
|
|
||||||
*
|
|
||||||
* During cache clears, JavaScript and CSS aggregates older than this threshold
|
|
||||||
* will be deleted. Set this to 0 to immediately delete all files, e.g. during
|
|
||||||
* development.
|
|
||||||
*/
|
|
||||||
# $settings['aggregate_gc_threshold'] = 86400 * 45;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public file base URL:
|
|
||||||
*
|
|
||||||
* An alternative base URL to be used for serving public files. This must
|
|
||||||
* include any leading directory path.
|
|
||||||
*
|
|
||||||
* A different value from the domain used by Drupal to be used for accessing
|
|
||||||
* public files. This can be used for a simple CDN integration, or to improve
|
|
||||||
* security by serving user-uploaded files from a different domain or subdomain
|
|
||||||
* pointing to the same server. Do not include a trailing slash.
|
|
||||||
*/
|
|
||||||
# $settings['file_public_base_url'] = 'http://downloads.example.com/files';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public file path:
|
|
||||||
*
|
|
||||||
* A local file system path where public files will be stored. This directory
|
|
||||||
* must exist and be writable by Drupal. This directory must be relative to
|
|
||||||
* the Drupal installation directory and be accessible over the web.
|
|
||||||
*/
|
|
||||||
# $settings['file_public_path'] = 'sites/default/files';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional public file schemes:
|
|
||||||
*
|
|
||||||
* Public schemes are URI schemes that allow download access to all users for
|
|
||||||
* all files within that scheme.
|
|
||||||
*
|
|
||||||
* The "public" scheme is always public, and the "private" scheme is always
|
|
||||||
* private, but other schemes, such as "https", "s3", "example", or others,
|
|
||||||
* can be either public or private depending on the site. By default, they're
|
|
||||||
* private, and access to individual files is controlled via
|
|
||||||
* hook_file_download().
|
|
||||||
*
|
|
||||||
* Typically, if a scheme should be public, a module makes it public by
|
|
||||||
* implementing hook_file_download(), and granting access to all users for all
|
|
||||||
* files. This could be either the same module that provides the stream wrapper
|
|
||||||
* for the scheme, or a different module that decides to make the scheme
|
|
||||||
* public. However, in cases where a site needs to make a scheme public, but
|
|
||||||
* is unable to add code in a module to do so, the scheme may be added to this
|
|
||||||
* variable, the result of which is that system_file_download() grants public
|
|
||||||
* access to all files within that scheme.
|
|
||||||
*/
|
|
||||||
# $settings['file_additional_public_schemes'] = ['example'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File schemes whose paths should not be normalized:
|
|
||||||
*
|
|
||||||
* Normally, Drupal normalizes '/./' and '/../' segments in file URIs in order
|
|
||||||
* to prevent unintended file access. For example, 'private://css/../image.png'
|
|
||||||
* is normalized to 'private://image.png' before checking access to the file.
|
|
||||||
*
|
|
||||||
* On Windows, Drupal also replaces '\' with '/' in URIs for the local
|
|
||||||
* filesystem.
|
|
||||||
*
|
|
||||||
* If file URIs with one or more scheme should not be normalized like this, then
|
|
||||||
* list the schemes here. For example, if 'porcelain://china/./plate.png' should
|
|
||||||
* not be normalized to 'porcelain://china/plate.png', then add 'porcelain' to
|
|
||||||
* this array. In this case, make sure that the module providing the 'porcelain'
|
|
||||||
* scheme does not allow unintended file access when using '/../' to move up the
|
|
||||||
* directory tree.
|
|
||||||
*/
|
|
||||||
# $settings['file_sa_core_2023_005_schemes'] = ['porcelain'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for phpinfo() admin status report.
|
|
||||||
*
|
|
||||||
* Drupal's admin UI includes a report at admin/reports/status/php which shows
|
|
||||||
* the output of phpinfo(). The full output can contain sensitive information
|
|
||||||
* so by default Drupal removes some sections.
|
|
||||||
*
|
|
||||||
* This behavior can be configured by setting this variable to a different
|
|
||||||
* value corresponding to the flags parameter of phpinfo().
|
|
||||||
*
|
|
||||||
* If you need to expose more information in the report - for example to debug a
|
|
||||||
* problem - consider doing so temporarily.
|
|
||||||
*
|
|
||||||
* @see https://www.php.net/manual/function.phpinfo.php
|
|
||||||
*/
|
|
||||||
# $settings['sa_core_2023_004_phpinfo_flags'] = ~ (INFO_VARIABLES | INFO_ENVIRONMENT);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private file path:
|
|
||||||
*
|
|
||||||
* A local file system path where private files will be stored. This directory
|
|
||||||
* must be absolute, outside of the Drupal installation directory and not
|
|
||||||
* accessible over the web.
|
|
||||||
*
|
|
||||||
* Note: Caches need to be cleared when this value is changed to make the
|
|
||||||
* private:// stream wrapper available to the system.
|
|
||||||
*
|
|
||||||
* See https://www.drupal.org/documentation/modules/file for more information
|
|
||||||
* about securing private files.
|
|
||||||
*/
|
|
||||||
# $settings['file_private_path'] = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Temporary file path:
|
|
||||||
*
|
|
||||||
* A local file system path where temporary files will be stored. This directory
|
|
||||||
* must be absolute, outside of the Drupal installation directory and not
|
|
||||||
* accessible over the web.
|
|
||||||
*
|
|
||||||
* If this is not set, the default for the operating system will be used.
|
|
||||||
*
|
|
||||||
* @see \Drupal\Component\FileSystem\FileSystem::getOsTemporaryDirectory()
|
|
||||||
*/
|
|
||||||
# $settings['file_temp_path'] = '/tmp';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatically create an Apache HTTP .htaccess file in writable directories.
|
|
||||||
*
|
|
||||||
* This setting can be disabled if you are not using Apache HTTP server, or if
|
|
||||||
* you have a web server configuration that protects the various writable file
|
|
||||||
* directories.
|
|
||||||
*
|
|
||||||
* @see \Drupal\Component\FileSecurity\FileSecurity::writeHtaccess()
|
|
||||||
* @see https://www.drupal.org/docs/administering-a-drupal-site/security-in-drupal/securing-file-permissions-and-ownership
|
|
||||||
*/
|
|
||||||
# $settings['auto_create_htaccess'] = FALSE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session write interval:
|
|
||||||
*
|
|
||||||
* Set the minimum interval between each session write to database.
|
|
||||||
* For performance reasons it defaults to 180.
|
|
||||||
*/
|
|
||||||
# $settings['session_write_interval'] = 180;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* String overrides:
|
|
||||||
*
|
|
||||||
* To override specific strings on your site with or without enabling the Locale
|
|
||||||
* module, add an entry to this list. This functionality allows you to change
|
|
||||||
* a small number of your site's default English language interface strings.
|
|
||||||
*
|
|
||||||
* Remove the leading hash signs to enable.
|
|
||||||
*
|
|
||||||
* The "en" part of the variable name, is dynamic and can be any langcode of
|
|
||||||
* any added language. (eg locale_custom_strings_de for german).
|
|
||||||
*/
|
|
||||||
# $settings['locale_custom_strings_en'][''] = [
|
|
||||||
# 'Home' => 'Front page',
|
|
||||||
# 'Last run @time ago' => 'Last run was done @time ago',
|
|
||||||
# ];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A custom theme for the offline page:
|
|
||||||
*
|
|
||||||
* This applies when the site is explicitly set to maintenance mode through the
|
|
||||||
* administration page or when the database is inactive due to an error.
|
|
||||||
* The template file should also be copied into the theme. It is located inside
|
|
||||||
* 'core/modules/system/templates/maintenance-page.html.twig'.
|
|
||||||
*
|
|
||||||
* Note: This setting does not apply to installation and update pages.
|
|
||||||
*/
|
|
||||||
# $settings['maintenance_theme'] = 'claro';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PHP settings:
|
|
||||||
*
|
|
||||||
* To see what PHP settings are possible, including whether they can be set at
|
|
||||||
* runtime (by using ini_set()), read the PHP documentation:
|
|
||||||
* http://php.net/manual/ini.list.php
|
|
||||||
* See \Drupal\Core\DrupalKernel::bootEnvironment() for required runtime
|
|
||||||
* settings and the .htaccess file for non-runtime settings.
|
|
||||||
* Settings defined there should not be duplicated here so as to avoid conflict
|
|
||||||
* issues.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If you encounter a situation where users post a large amount of text, and
|
|
||||||
* the result is stripped out upon viewing but can still be edited, Drupal's
|
|
||||||
* output filter may not have sufficient memory to process it. If you
|
|
||||||
* experience this issue, you may wish to uncomment the following two lines
|
|
||||||
* and increase the limits of these variables. For more information, see
|
|
||||||
* http://php.net/manual/pcre.configuration.php.
|
|
||||||
*/
|
|
||||||
# ini_set('pcre.backtrack_limit', 200000);
|
|
||||||
# ini_set('pcre.recursion_limit', 200000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration overrides.
|
|
||||||
*
|
|
||||||
* To globally override specific configuration values for this site,
|
|
||||||
* set them here. You usually don't need to use this feature. This is
|
|
||||||
* useful in a configuration file for a vhost or directory, rather than
|
|
||||||
* the default settings.php.
|
|
||||||
*
|
|
||||||
* Note that any values you provide in these variable overrides will not be
|
|
||||||
* viewable from the Drupal administration interface. The administration
|
|
||||||
* interface displays the values stored in configuration so that you can stage
|
|
||||||
* changes to other environments that don't have the overrides.
|
|
||||||
*
|
|
||||||
* There are particular configuration values that are risky to override. For
|
|
||||||
* example, overriding the list of installed modules in 'core.extension' is not
|
|
||||||
* supported as module install or uninstall has not occurred. Other examples
|
|
||||||
* include field storage configuration, because it has effects on database
|
|
||||||
* structure, and 'core.menu.static_menu_link_overrides' since this is cached in
|
|
||||||
* a way that is not config override aware. Also, note that changing
|
|
||||||
* configuration values in settings.php will not fire any of the configuration
|
|
||||||
* change events.
|
|
||||||
*/
|
|
||||||
# $config['system.site']['name'] = 'My Drupal site';
|
|
||||||
# $config['user.settings']['anonymous'] = 'Visitor';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable HTML5 form validation.
|
|
||||||
*
|
|
||||||
* Drupal 12 will disable HTML5 form validation by default due to issues with
|
|
||||||
* usability and accessibility. Setting this to TRUE will allow user agents to
|
|
||||||
* continue performing client-side HTML5 validation. This prevents Drupal's
|
|
||||||
* Form API (FAPI) validation from executing, so FAPI validation error messages
|
|
||||||
* may not be displayed including those for required elements.
|
|
||||||
*
|
|
||||||
* Setting this to FALSE will cause HTML5 validation to be disabled on all
|
|
||||||
* forms. Only Drupal's server-side validation will be executed.
|
|
||||||
*
|
|
||||||
* This setting will be removed in Drupal 13.
|
|
||||||
*
|
|
||||||
* @see https://www.drupal.org/node/3537128
|
|
||||||
*/
|
|
||||||
# $settings['enable_html5_validation'] = TRUE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load services definition file.
|
|
||||||
*/
|
|
||||||
$settings['container_yamls'][] = $app_root . '/' . $site_path . '/services.yml';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Override the default service container class.
|
|
||||||
*
|
|
||||||
* This is useful for example to trace the service container for performance
|
|
||||||
* tracking purposes, for testing a service container with an error condition or
|
|
||||||
* to test a service container that throws an exception.
|
|
||||||
*/
|
|
||||||
# $settings['container_base_class'] = '\Drupal\Core\DependencyInjection\Container';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trusted host configuration.
|
|
||||||
*
|
|
||||||
* Drupal core can use the Symfony trusted host mechanism to prevent HTTP Host
|
|
||||||
* header spoofing.
|
|
||||||
*
|
|
||||||
* To enable the trusted host mechanism, you enable your allowable hosts
|
|
||||||
* in $settings['trusted_host_patterns']. This should be an array of regular
|
|
||||||
* expression patterns, without delimiters, representing the hosts you would
|
|
||||||
* like to allow.
|
|
||||||
*
|
|
||||||
* For example:
|
|
||||||
* @code
|
|
||||||
* $settings['trusted_host_patterns'] = [
|
|
||||||
* '^www\.example\.com$',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
* will allow the site to only run from www.example.com.
|
|
||||||
*
|
|
||||||
* If you are running multisite, or if you are running your site from
|
|
||||||
* different domain names (eg, you don't redirect http://www.example.com to
|
|
||||||
* http://example.com), you should specify all of the host patterns that are
|
|
||||||
* allowed by your site.
|
|
||||||
*
|
|
||||||
* For example:
|
|
||||||
* @code
|
|
||||||
* $settings['trusted_host_patterns'] = [
|
|
||||||
* '^example\.com$',
|
|
||||||
* '^.+\.example\.com$',
|
|
||||||
* '^example\.org$',
|
|
||||||
* '^.+\.example\.org$',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
* will allow the site to run off of all variants of example.com and
|
|
||||||
* example.org, with all subdomains included.
|
|
||||||
*
|
|
||||||
* @see https://www.drupal.org/docs/installing-drupal/trusted-host-settings
|
|
||||||
*/
|
|
||||||
# $settings['trusted_host_patterns'] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default list of directories that will be ignored by Drupal's file API.
|
|
||||||
*
|
|
||||||
* By default ignore node_modules and bower_components folders to avoid issues
|
|
||||||
* with common frontend tools and recursive scanning of directories looking for
|
|
||||||
* extensions.
|
|
||||||
*
|
|
||||||
* @see \Drupal\Core\File\FileSystemInterface::scanDirectory()
|
|
||||||
* @see \Drupal\Core\Extension\ExtensionDiscovery::scanDirectory()
|
|
||||||
*/
|
|
||||||
$settings['file_scan_ignore_directories'] = [
|
|
||||||
'node_modules',
|
|
||||||
'bower_components',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default number of entities to update in a batch process.
|
|
||||||
*
|
|
||||||
* This is used by update and post-update functions that need to go through and
|
|
||||||
* change all the entities on a site, so it is useful to increase this number
|
|
||||||
* if your hosting configuration (i.e. RAM allocation, CPU speed) allows for a
|
|
||||||
* larger number of entities to be processed in a single batch run.
|
|
||||||
*/
|
|
||||||
$settings['entity_update_batch_size'] = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity update backup.
|
|
||||||
*
|
|
||||||
* This is used to inform the entity storage handler that the backup tables as
|
|
||||||
* well as the original entity type and field storage definitions should be
|
|
||||||
* retained after a successful entity update process.
|
|
||||||
*/
|
|
||||||
$settings['entity_update_backup'] = TRUE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Node migration type.
|
|
||||||
*
|
|
||||||
* This is used to force the migration system to use the classic node migrations
|
|
||||||
* instead of the default complete node migrations. The migration system will
|
|
||||||
* use the classic node migration only if there are existing migrate_map tables
|
|
||||||
* for the classic node migrations and they contain data. These tables may not
|
|
||||||
* exist if you are developing custom migrations and do not want to use the
|
|
||||||
* complete node migrations. Set this to TRUE to force the use of the classic
|
|
||||||
* node migrations.
|
|
||||||
*/
|
|
||||||
$settings['migrate_node_migrate_type_classic'] = FALSE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default settings for migration sources.
|
|
||||||
*
|
|
||||||
* These settings are used as the default settings on the Credential form at
|
|
||||||
* /upgrade/credentials.
|
|
||||||
*
|
|
||||||
* - migrate_source_version - The version of the source database. This can be
|
|
||||||
* '6' or '7'. Defaults to '7'.
|
|
||||||
* - migrate_source_connection - The key in the $databases array for the source
|
|
||||||
* site.
|
|
||||||
* - migrate_file_public_path - The location of the source Drupal 6 or Drupal 7
|
|
||||||
* public files. This can be a local file directory containing the source
|
|
||||||
* Drupal 6 or Drupal 7 site (e.g /var/www/docroot), or the site address
|
|
||||||
* (e.g http://example.com).
|
|
||||||
* - migrate_file_private_path - The location of the source Drupal 7 private
|
|
||||||
* files. This can be a local file directory containing the source Drupal 7
|
|
||||||
* site (e.g /var/www/docroot), or empty to use the same value as Public
|
|
||||||
* files directory.
|
|
||||||
*
|
|
||||||
* Sample configuration for a drupal 6 source site with the source files in a
|
|
||||||
* local directory.
|
|
||||||
*
|
|
||||||
* @code
|
|
||||||
* $settings['migrate_source_version'] = '6';
|
|
||||||
* $settings['migrate_source_connection'] = 'migrate';
|
|
||||||
* $settings['migrate_file_public_path'] = '/var/www/drupal6';
|
|
||||||
* @endcode
|
|
||||||
*
|
|
||||||
* Sample configuration for a drupal 7 source site with public source files on
|
|
||||||
* the source site and the private files in a local directory.
|
|
||||||
*
|
|
||||||
* @code
|
|
||||||
* $settings['migrate_source_version'] = '7';
|
|
||||||
* $settings['migrate_source_connection'] = 'migrate';
|
|
||||||
* $settings['migrate_file_public_path'] = 'https://drupal7.com';
|
|
||||||
* $settings['migrate_file_private_path'] = '/var/www/drupal7';
|
|
||||||
* @endcode
|
|
||||||
*/
|
|
||||||
# $settings['migrate_source_connection'] = '';
|
|
||||||
# $settings['migrate_source_version'] = '';
|
|
||||||
# $settings['migrate_file_public_path'] = '';
|
|
||||||
# $settings['migrate_file_private_path'] = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Media oEmbed discovery trusted host configuration.
|
|
||||||
*
|
|
||||||
* The oEmbed spec allows for provider/resource discovery by fetching a URL. The
|
|
||||||
* patterns here restrict which domains Drupal will make a request to for oEmbed
|
|
||||||
* discovery.
|
|
||||||
*
|
|
||||||
* For example:
|
|
||||||
* @code
|
|
||||||
* $settings['media_oembed_discovery_trusted_host_patterns'] = [
|
|
||||||
* '^www\.example\.com$',
|
|
||||||
* ];
|
|
||||||
* @endcode
|
|
||||||
* will allow the site to make oEmbed discovery requests to www.example.com.
|
|
||||||
*/
|
|
||||||
# $settings['media_oembed_discovery_trusted_host_patterns'] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load local development override configuration, if available.
|
|
||||||
*
|
|
||||||
* Create a settings.local.php file to override variables on secondary (staging,
|
|
||||||
* development, etc.) installations of this site.
|
|
||||||
*
|
|
||||||
* Typical uses of settings.local.php include:
|
|
||||||
* - Disabling caching.
|
|
||||||
* - Disabling JavaScript/CSS compression.
|
|
||||||
* - Rerouting outgoing emails.
|
|
||||||
*
|
|
||||||
* Keep this code block at the end of this file to take full effect.
|
|
||||||
*/
|
|
||||||
#
|
|
||||||
# if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
|
|
||||||
# include $app_root . '/' . $site_path . '/settings.local.php';
|
|
||||||
# }
|
|
||||||
$databases['default']['default'] = array (
|
|
||||||
'database' => 'figli_compta',
|
|
||||||
'username' => 'figli_compta',
|
|
||||||
'password' => 'figli_compta',
|
|
||||||
'prefix' => '',
|
|
||||||
'host' => 'mysql',
|
|
||||||
'port' => '3306',
|
|
||||||
'isolation_level' => 'READ COMMITTED',
|
|
||||||
'driver' => 'mysql',
|
|
||||||
'namespace' => 'Drupal\\mysql\\Driver\\Database\\mysql',
|
|
||||||
'autoload' => 'core/modules/mysql/src/Driver/Database/mysql/',
|
|
||||||
);
|
|
||||||
$settings['config_sync_directory'] = '../config/sync';
|
|
||||||
|
|
||||||
if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
|
|
||||||
include $app_root . '/' . $site_path . '/settings.local.php';
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user