Compare commits

...
104 Commits
Author SHA1 Message Date
bachir 6e1c421e5b ecarts de 0.01€ visible dans le tableau 2026-09-09 21:42:17 +02:00
bachir 5eca3804bf Répartition assistée sur le formulaire + widget compacté
- ledger-form.js : nouveau behavior figliLedgerRepartition --
  pré-remplit les montants de répartition (1re ligne = HT entier,
  chaque ajout partage au centime avec report du reste sur les lignes
  suivantes), valeurs saisies manuellement ou chargées de la base
  « figées » (plus jamais déplacées), écart en direct dans la cellule
  de titre du widget (miroir exact du round(HT − somme, 2) et de la
  tolérance 0,01 du presave). Zéro changement PHP : #validate et
  node_presave() restent l'autorité. Les verrous vivent hors du DOM
  (survie aux re-rendus AJAX du widget) ; la détection manuel/assist
  repose sur le fait qu'une écriture programmatique ne déclenche pas
  d'événement input.
- ledger-form.css : compactage du widget -- titre « Répartition » par
  ligne, bouton Collapse et colonne Order masqués, paragraph-top en
  absolu (coin haut-droit, zéro hauteur), une seule ligne par
  répartition « Compte [input] Montant (€) [input] » où l'input du
  Compte est contraint en pourcentage de son wrapper claro-autocomplete
  (size=60 : il débordait sur le libellé Montant), header réordonné
  titre / écart / menu trois-points, inputs visibles au repos
  (--flform-bg, atténué en mode sombre), marqueur « figé ».
- install : update_8015 (features du widget vidées) puis update_8016
  (collapse_edit_all rétabli à la demande, duplicate reste off) +
  settings de l'install fraîche synchronisés.
2026-09-09 16:44:46 +02:00
bachir d2f3179f01 Import de relevé bancaire CSV en libre-service (/lignes/importer-releve)
Chaque transaction du relevé devient une ligne « à trier » : type autre,
répartition vide (liseré rouge existant via field_ecart), tag field_flag
« IMP AAMMJJ » (liseré ambre + filtre existants), client rapproché par
mots (ClientMatcher : séquence contiguë, sinon mot significatif unique --
jamais en sous-chaîne, jamais auto-créé).

- src/Import/ : CsvReleveParser (ISO-8859-1 confirmé, en-tête strict,
  fgetcsv avec escape '' explicite -- dépréciation PHP 8.4, rejets
  propres avec n° de ligne), ReleveImportBatch (Batch API par lots de
  25, dédoublonnage COMPTÉ par empreinte field_import_fitid -- max(0,
  k−m) importe les vrais doublons légitimes et dédoublonne à travers
  des fichiers qui se chevauchent --, totaux de contrôle au centime
  sur la page de résultat, résumé en tempstore privé).
- ReleveUploadForm : upload private://releves (fichier conservé +
  usage, hors de portée du cron), parse en validateForm(), batch,
  redirection vers la page de résultat.
- field_montant_releve : référence bancaire immuable, écrite une fois
  à l'import et jamais par presave ; affichée en TEXTE sous Montant
  TTC (widget remplacé par un #type item -- un item ne soumet rien et
  extractFormValues() saute le champ sans valeur soumise, la valeur
  survit donc à chaque save) ; masquée sur les lignes sans montant.
- SkipValidationContext : contournement du contrôle de répartition
  requête-scopé (ferme le trou de concurrence de l'ancien state
  global, AUDIT-2026-09-09 §2.2) -- presave honore le service (state
  gardé pour compat), updateType/updateField basculent dessus.
- Permissions (AUDIT §2.2 priorité 1) : access figli ledger sur toutes
  les routes du module + autocomplete (RouteSubscriber), import
  réservé Éditeur/Admin, access content retiré du rôle Authenticated
  (config/sync re-exportée pour les 4 rôles).
- Gin : hook_gin_ignore_sticky_form_actions() -- sans ça, le bouton
  Importer partait dans la barre sticky du chrome masqué.
- install : 8011 champs, 8012 index sur les empreintes, 8013/8014
  montant_releve sur le formulaire sous le TTC (poids renumérotés).
- /lignes : boutons + Ajouter / Importer / Historique dans le footer
  sticky (compacts), footer colspan dès la première colonne, badges de
  signalement qui reviennent à la ligne au lieu de déborder.
2026-09-09 14:58:36 +02:00
bachir 3688bddba8 Affiche les messages Drupal par-dessus les fenêtres modales
La région [data-drupal-messages] vit dans le layout Gin, où des stacking
contexts ancêtres neutralisaient son position:fixed + z-index : tout le
sous-arbre passait sous l'overlay de la modale jQuery UI (enfant direct
du <body>), rendant illisibles les messages insérés pendant l'édition
d'une ligne (MessageCommand -- erreur de répartition, création...).

admin-chrome.js déplace la région en enfant direct du <body> au
chargement et la re-vérifie dès qu'une modale entre dans le DOM ;
admin-chrome.css passe son z-index à 100000, hors d'atteinte du
_moveToTop de jQuery UI (qui ne remonte un dialog que au-dessus des
siblings .ui-front, ce que la région n'est pas).
2026-09-09 12:59:37 +02:00
bachir af36591d70 Ajoute le total en bas de chaque graph Charges structurelles par client
Ligne "Total" sous le graph principal et sous chacune des cartes par
année -- somme calculée à partir des items déjà tracés (sumItems()), pas
d'un champ stats séparé, pour rester cohérente par construction avec les
barres affichées au-dessus.
2026-09-08 15:38:17 +02:00
bachir 74d8eb2aa2 Ajoute "Charges structurelles par client" (+ par année) sur le dashboard SAS
Réutilise le node-level query déjà exécuté pour caParClient/totalParType
(aucune requête SQL supplémentaire) -- accumule abs(montant_ht) des
lignes type=charge par client (le vendeur/organisme : loyer, assurance,
URSSAF...). Trié par montant décroissant (pas alphabétique), pas de
plafond top-N vu le petit nombre de vendeurs distincts. Coloré en gris
"charge", même couleur que ce type dans "Répartition de l'activité par
type".
2026-09-08 15:30:31 +02:00
bachir 96bd9502d2 Les totaux du footer de /lignes suivent maintenant les filtres actifs
Remplace /lignes/api/totaux (LedgerStatsController::totauxAnnee(),
toujours non filtré) par une agrégation côté client des lignes renvoyées
par LedgerRowsController::index() (même endpoint que la fenêtre
glissante) avec annee=<année visible> + les filtres actifs. Reste une
requête serveur dédiée sur l'année entière (pas de LIMIT/range sur la
requête Entity), donc le total ne dépend jamais de ce qui est
effectivement chargé dans la fenêtre glissante à cet instant -- vérifié :
207 lignes non filtrées vs 13 avec le filtre "OVH", total du footer
identique au calcul indépendant dans les deux cas.

onFilterChanged() déclenche maintenant systématiquement
loadCurrentYearTotals() (pas seulement quand detectCurrentYear() détecte
un changement d'année) -- sinon changer un filtre en restant sur la même
année laissait le footer afficher l'ancien total non filtré.

totauxAnnee() et sa route sont supprimés (plus aucun appelant après ce
changement, vérifié par recherche).
2026-09-08 15:25:18 +02:00
bachir cb2a0fcdaa Trie le graph versements par ordre alphabétique, Salaire/stage et Sous-traitant en dernier
Les 6 comptes sont désormais triés alphabétiquement plutôt que par
montant décroissant ; Salaire/stage et Sous-traitant ne participent pas à
ce tri, ils sont simplement ajoutés après coup, dans cet ordre fixe.
2026-09-08 15:10:49 +02:00
bachir 612269ec6d Colore les lignes du graph versements selon leur type
Les 6 comptes reprennent la couleur "versement" (orange, même que
"Versement freelance" dans Répartition par type) puisque c'est la même
somme, juste ventilée par bénéficiaire ; Salaire/stage et Sous-traitant
gardent leur propre couleur de type. Réutilise typeColor()/TYPE_COLORS
déjà en place, juste besoin d'un champ "type" sur chaque item pour que
colorFor puisse s'en servir.
2026-09-08 15:07:41 +02:00
bachir d17a3e2e33 Ajoute Salaire/stage et Sous-traitant au graphique des versements par compte
Deux lignes supplémentaires (pas ventilées par compte, un seul total
chacune) dans "Total des versements par compte" et sa version par année --
même source déjà utilisée par "Répartition de l'activité par type"
(total_par_type / total_par_type_par_annee), aucun changement backend.
2026-09-08 15:05:18 +02:00
bachir 692f7f3ea3 Ajoute "Total des versements par compte" (+ par année) sur le dashboard SAS
Réutilise total_par_type_par_compte / total_par_type_par_compte_par_annee,
déjà exposés par /dashboard/api/stats pour /dashboard/compte -- aucun
changement backend nécessaire, juste extrait la clé "versement" par
compte au lieu de la garder scindée par type.
2026-09-08 15:03:51 +02:00
bachir 8dfb4af98a Nouvelle page /dashboard/repartition, renomme "Dashboard" en "SAS" dans le menu
Nouvelle page "Répartition/Soldes" entre "SAS" (ex-"Dashboard") et "Par
compte" dans le menu : reprend "Solde par compte" et "Évolution du solde
par compte", retirés du dashboard général pour le recentrer sur
l'activité/CA/type/client. Mêmes données déjà exposées par
/dashboard/api/stats (solde_par_compte, solde_par_compte_par_annee),
aucun changement backend nécessaire pour cette page.

js/dashboard-repartition.js reprend le HBarChart/MiniTrend de
dashboard.js -- dupliqués plutôt que partagés, même convention que
dashboard-compte.js. Racine Vue volontairement le même id
#figli-dashboard-app que les deux autres pages dashboard (pas un id
dédié) : dashboard.css scope ses variables CSS (thème clair/sombre) sur
ce sélecteur, réutiliser le même id est comment les trois pages héritent
du même thème sans feuille de style séparée -- vérifié en dark mode.

Le lien de menu "Dashboard" devient "SAS" partout (les 3 templates Twig
+ le nav en render array de HistoryController, qui n'a pas de template
Twig propre).

dashboard.js : MiniTrend/soldeParCompteItems/comptesOrdonnes/
trendValues supprimés (code mort après le déplacement, plus rien ne les
utilise sur le dashboard général).
2026-09-08 15:00:22 +02:00
bachir 43817dcdce Retire la carte "Meilleur solde" du dashboard général 2026-09-08 14:38:23 +02:00
bachir 3552b5a79d Ajoute la répartition par type et le top clients par année sur /dashboard
Reprend exactement le pattern des petits multiples déjà en place sur
/dashboard/compte (grille figli-year-hbar-grid, variante compacte de
h-bar-chart) -- mêmes classes CSS, aucun nouveau style nécessaire.

Backend : DashboardStatsController::stats() calcule maintenant aussi
total_par_type_par_annee et top_clients_par_annee (top 8, contre 12 en
toutes années confondues) à partir des mêmes requêtes SQL déjà en place,
sans requête supplémentaire.

Frontend : dashboard.js n'a jamais de données ligne par ligne (contraire-
ment à dashboard-compte.js qui filtre côté client) -- l'agrégation par
année doit donc venir du serveur. Ajout du prop "compact" au HBarChart de
dashboard.js (jusqu'ici absent, seule la copie de dashboard-compte.js
l'avait).
2026-09-08 14:34:04 +02:00
bachir c927771795 Corrige un deadlock dans onFilterChanged() qui gelait le tableau
ensureScrollable() (appelé par onFilterChanged() après chaque changement
de filtre) appelle lui-même loadOlder()/loadNewer(), qui passent par le
même _queueWindowOp -- en le chaînant *à l'intérieur* de l'opération déjà
mise en file par onFilterChanged(), la file d'attente se retrouvait à
attendre sa propre continuation dès qu'un filtre laissait trop peu de
lignes pour remplir l'écran, gelant purement et simplement le tableau
(recherche qui ne charge plus les lignes précédentes en scrollant, et
même effacer le filtre ensuite ne faisait plus rien -- tout attendait
derrière l'opération bloquée).

Corrigé en chaînant ensureScrollable() après la résolution de l'opération
mise en file, pas dedans -- ses propres appels à loadOlder()/loadNewer()
s'empilent alors normalement sur la file, sans dépendance circulaire.

Reproduit et vérifié en conditions réelles : recherche "Assurance local"
(47 correspondances de 2023 à 2026) qui chargeait bien 2026 mais bloquait
en scrollant vers le haut -- après correctif, chaque scroll vers le haut
déclenche bien un nouveau chargement (vérifié sur 2 scrolls successifs),
et effacer le filtre recharge immédiatement la vue complète.
2026-09-08 14:22:39 +02:00
bachir f7e7ae3265 Phase 2 : filtrage server-side de /lignes (compte, client, type, signalement, écarts, recherche libre)
Remplace le "charge tout puis filtre côté client" (filteredRows()) par un
nouvel endpoint /lignes/api/lignes (LedgerRowsController), qui pousse tous
les filtres de la barre d'outils dans une seule requête Entity/Field Query
API (pas de SQL brut) -- y compris à travers la relation
field_repartition -> field_compte (paragraph -> taxonomie), confirmé
fonctionner empiriquement avant d'écrire le contrôleur. `rows` ne contient
donc plus que ce qui est à la fois dans la fenêtre de dates ET dans les
filtres actifs ; la fenêtre glissante elle-même (loadOlder/loadNewer/
ensureScrollable) est inchangée, seul ce qui la peuple change.

Ajouts :
- Nouveau filtre "Libellé / Détails" (recherche plein texte sur
  field_notes ou le titre), débouncé côté client (350ms).
- filterYear (l'"Année" dédiée) passe par le même endpoint via son
  paramètre "annee".
- mergeChangedRows() (le merge du polling) tient maintenant compte des
  filtres actifs : une ligne qui ne correspond plus après un changement
  est retirée de `rows`, une ligne qui correspond nouvellement est ajoutée
  -- polling lui-même reste global/non filtré, seul le merge est
  filter-aware.

JSON:API reste utilisé pour ce que l'endroit filtré ne couvre pas : le
groupe entrée/sorties liées (fetchLignesByNids) et le polling
(fetchChangedSince), tous deux indépendants d'une plage de dates+filtres.

Testé en local : chaque filtre individuellement et combiné (compte+q,
client+type), widening de fenêtre sous filtre restrictif, restauration
combinée depuis le hash au reload, modale d'édition + reloadWindow après
fermeture, polling sans erreur.
2026-09-08 14:04:35 +02:00
bachir 7bcf65d25b Ajoute field_ecart (Montant HT - somme répartition), stocké à la sauvegarde
Phase 1 du chantier filtrage serveur de /lignes : l'écart devient une
vraie valeur stockée plutôt que recalculée à la volée en résolvant les
paragraphes de répartition, pour que le futur filtre "Écarts uniquement"
côté serveur puisse filtrer directement dessus.

Contrairement au Montant TTC (protégé par un garde-fou pour ne jamais
altérer un TTC historique), l'écart n'a pas de valeur passée à
protéger -- il reflète l'état *actuel* de la répartition, donc
recalculé sans condition à chaque sauvegarde. figli_compta_ledger_
node_presave() calculait déjà cette somme pour la validation
répartition == HT ; le stockage était quasi gratuit à ajouter au même
endroit.

Migration (update hooks 8009/8010) : rétro-calcul mécanique pour les
1556 lignes existantes (tous types, contrairement aux migrations TVA/
cotisation qui ne concernaient que les entrées client) -- aucune
ambiguïté à arbitrer, juste HT moins répartition. Vérifié
indépendamment ligne par ligne après coup : 0 écart entre la valeur
recalculée à la main et celle stockée par la migration.

Nouveau flag d'état 'figli_compta_ledger.skip_revision', utilisé
uniquement par cette migration : backfiller un champ purement calculé
sur 1556 lignes déjà migrées n'est pas un changement éditorial qui
justifie 1556 nouvelles révisions -- vérifié qu'aucune révision
supplémentaire n'a été créée. Reste indépendant de skip_validation
(déjà utilisé par tous les autres scripts de migration de ce module,
et qui doit continuer à créer une révision).

Restructuration de figli_compta_ledger_node_presave() : le calcul de
l'écart et son stockage se font maintenant même sous skip_validation
(seul le lancement de l'exception reste conditionnel) -- vérifié que
l'exception se déclenche toujours normalement sur une vraie
répartition incohérente, et que Montant TTC/Cotisation restent
inchangés sur une sauvegarde qui ne touche ni HT ni TVA.
2026-09-08 13:37:39 +02:00
bachir 00956825fa Réduit les marges du tableau (côtés et dessous) aux 2/3
- Gin's .layout-container margin-left/right (48px) réduit à 16px, sur
  les routes de ce module (hide_admin_chrome, déjà route-scopé).
- #figli-home-app margin-bottom réduit de 1rem à 0,33rem.
- .figli-table-wrap : max-height 75vh remplacé par calc(100vh - 16rem)
  -- le pourcentage de viewport ne tenait plus compte du titre/menu +
  barre d'outils au-dessus (hauteur à peu près fixe, ~16rem), qui a
  grandi au fil des filtres ajoutés depuis le dernier fix du double
  scroll -- avait fini par redépasser ce que 75vh laissait de marge,
  réintroduisant un scroll de page en plus de celui du tableau (84px
  d'écart mesuré avant ce correctif, ~8px après -- l'essentiel de
  l'écart restant venant justement de la marge du dessous réduite
  ci-dessus, pas d'un nouveau débordement).
2026-09-07 14:54:06 +02:00
bachir 74ee8ab3b9 Badge TVA non officielle plus visible : gros liseret rouge sous le montant
Le point ambre discret n'était pas assez visible. Remplacé par un
border-bottom épais (3px, rouge) directement sur la cellule TVA --
lisible d'un coup d'œil sans avoir besoin de place horizontale
supplémentaire, contrairement à un badge texte/icône dans une colonne
de 3,5% de large.
2026-09-07 14:48:09 +02:00
bachir 6576b35c07 Badge sur les taux TVA non officiels, écart positif en vert
- Un point ambre discret (pas de texte -- la colonne TVA ne fait que
  3,5% de large, à peine assez pour "12,64 %" lui-même) marque tout
  taux TVA qui ne tombe sur aucun des 5 taux officiels français (0,
  2,1, 5,5, 10, 20 %, ±0,05 point comme dans les scripts de migration).
  Infobulle pour le détail.
- Écart positif (répartition > Montant HT) passe au vert
  (--figli-positive), au lieu du rouge uniforme précédent -- même
  logique que la colonne Montant HT, qui distingue déjà positif/négatif.
2026-09-07 14:45:06 +02:00
bachir 0baa03855b Le champ/colonne "1,1%" affiche le delta seul, pas HT + 1,1%
field_cotisation_urssaf stockait round(HT * 1.011, 2) (le montant
augmenté) -- "1,1%" comme libellé de champ/colonne désigne la
cotisation elle-même, pas HT + cotisation. Change pour
round(HT * 0.011, 2). La TVA continue de s'appliquer sur la base
augmentée (HT + ce champ) : Montant TTC est inchangé par cette
correction, seule la valeur affichée/stockée dans "1,1%" change.

Migration (update hook 8008) : recalcule field_cotisation_urssaf pour
les 154 lignes que figli_compta_ledger_update_8007() avait marquées
cotisation active, en soustrayant simplement le HT déjà correct --
contrairement à cette dernière, ce n'est pas un arbitrage sur des
données historiques ambiguës, juste un bug dans du code écrit plus tôt
le même jour, donc recalculé sans condition ni prudence particulière.

Ajoute aussi le total "1,1%" au pied du tableau (solde de l'année),
absent jusqu'ici : LedgerStatsController::totauxAnnee() somme
maintenant field_cotisation_urssaf comme il le fait déjà pour HT/TTC.
2026-09-07 14:38:10 +02:00
bachir 6c33bc0098 Cotisation diffuseur URSSAF (1,1%) : nouveau champ, TTC calculé en cascade
Architecture métier corrigée : la SAS devise HT, ajoute (quand
applicable -- pas systématique) 1,1% de cotisation diffuseur URSSAF
pour l'usage de freelances, puis applique la TVA sur ce montant
augmenté -- pas sur le HT brut. La répartition entre comptes reste
basée sur le HT seul, inchangée.

- field_cotisation_active (case à cocher, "Entrée client" uniquement,
  cochée par défaut sur une nouvelle ligne -- optionnelle puisque tous
  les devis ne l'incluent pas historiquement).
- field_cotisation_urssaf ("1,1%", montant calculé HT × 1,011, jamais
  éditable).
- Montant TTC = round(cotisation × (1 + TVA/100), 2) quand la
  cotisation s'applique, round(HT × (1 + TVA/100), 2) sinon --
  inchangé pour tous les types hors "Entrée client".

Migration (update hooks 8006/8007) : toutes les lignes "Entrée client"
de field_tva rétro-calculées par figli_compta_ledger_update_8005()
étaient fausses dès que la cotisation s'appliquait (taux mixte HT->TTC,
pas le vrai taux de TVA) -- corrigées avec la même prudence que la
migration précédente : 2021 laissée de côté (pratique non confirmée
sur cette année), et par ligne, un taux déjà "propre" (0/2,1/5,5/10/20
%) signifie qu'aucune cotisation n'a été appliquée (laissé tel quel) ;
sinon le nouveau taux recalculé n'est retenu que s'il retombe lui-même
sur un taux officiel, sinon la ligne reste inchangée plutôt que de
deviner. Montant TTC historique jamais réécrit, comme pour toute
migration de ce module. Résultat : 154 lignes corrigées (cotisation
active), 90 déjà correctes (cotisation inactive), 40 ambiguës laissées
telles quelles, 43 lignes 2021 ignorées.

Formulaire réorganisé en grille à 4 colonnes (HT | 1,1% | TVA | TTC),
case à cocher masquée hors "Entrée client" via #states réel (fiable
ici, contrairement au select TVA -- watch sur field_type_ligne, un
vrai champ, pas un élément ajouté à la main). Colonne "1,1%" ajoutée
au grand livre entre HT et TVA.
2026-09-07 14:33:20 +02:00
bachir 134c97ac9d Supprime le double scroll : menu à hauteur du titre, marge Gin retirée
Le menu Grand livre/Dashboard/Par compte passe en position fixe,
aligné avec le <h1> de la page plutôt que sur sa propre ligne en
dessous -- les deux viennent de régions Drupal différentes (le titre
du bloc sticky top-bar de Gin, le menu du contenu de la page) sans
conteneur flex/grid commun pour les aligner autrement.

Ça ne suffisait pas à éliminer le scroll de page en plus de celui du
tableau (max-height: 75vh sur .figli-table-wrap) : Gin applique un
margin-bottom: 80px sur <main class="page-content">, pensé pour une
page d'admin classique, pas pour ce layout à hauteur de viewport fixe.
Neutralisé sur les routes du module (hide_admin_chrome, déjà route-
scopé).

Au passage, corrige un oubli : figli_compta_ledger.dashboard_compte
n'était jamais dans la liste hide_admin_chrome, donc la barre d'admin
Gin restait visible sur /dashboard/compte (et donc le titre plus bas
que sur les 3 autres pages) -- ajouté.

Et sur demande complémentaire en cours de route : le même menu manquait
purement et simplement sur /lignes/historique (pas de template Twig
propre, juste un tableau brut) -- ajouté en tableau de rendu directement
dans HistoryController, mêmes classes CSS que le <nav> des autres pages.
2026-09-07 12:46:36 +02:00
bachir 897d3c208e htaccess 2026-09-07 12:26:48 +02:00
bachir e117089be5 Renomme HT/TTC, réduit leur largeur, ajoute une colonne TVA au grand livre
HT/TTC passent de "Montant HT"/"Montant TTC" à "HT"/"TTC" (5% de large
au lieu de 6,2%), avec une nouvelle colonne TVA entre les deux (3,5%,
affichée en %, arrondie à 2 décimales -- les lignes migrées portent
parfois un taux rétro-calculé à 4 décimales, voir
figli_compta_ledger_update_8005()). field_tva était déjà exposé sans
changement de requête JSON:API (pas de sparse fieldset ici).

Piège de spécificité CSS : .amount:not(.compte-col) (déjà en place
pour Écart) compte comme 2 classes à cause de :not(), donc un simple
.figli-ht-col à une classe perdait systématiquement contre elle --
recombiné en .amount.figli-ht-col pour égaler/dépasser sa spécificité.
2026-09-07 12:18:58 +02:00
bachir f0927bac88 TVA par défaut à 10 %, corrige le Montant TTC illisible
- TVA par défaut passe de 0 % à 10 % (taux intermédiaire) pour une
  nouvelle ligne -- la plupart des lignes saisies portent effectivement
  de la TVA.
- Montant TTC (désactivé) était illisible : Chromium affiche le texte
  d'un champ disabled via -webkit-text-fill-color plutôt que color,
  qui restait sur son gris par défaut du navigateur -- quasi invisible
  sur le fond gris de --flform-bg-subtle en mode sombre. Fixé
  explicitement sur les deux thèmes.
2026-09-07 12:01:33 +02:00
bachir 5aa00b2ffd TVA en sélection des taux officiels français, TTC non éditable
Le champ TVA devient un select (0 %, 2,1 %, 5,5 %, 10 %, 20 %) plutôt
qu'une saisie libre, avec une option "Autre (préciser)" qui révèle le
champ décimal existant -- indispensable pour les lignes migrées, dont
le taux rétro-calculé (figli_compta_ledger_update_8005()) est souvent
un taux effectif non standard qu'il ne faut surtout pas forcer à
s'aligner sur l'une des 5 valeurs officielles.

Montant TTC passe de readonly à #disabled : non focusable, non
éditable même via les flèches d'un input number, et Form API rejette
toute valeur soumise malgré tout au profit de #default_value.

Le select est un élément de formulaire à part (pas un widget de champ)
pour éviter que WidgetBase::extractFormValues() ne s'étouffe sur une
clé étrangère mêlée aux valeurs de field_tva -- un #validate callback
recopie la valeur choisie dans field_tva au moment opportun (après
tous les #validate, avant la reconstruction de l'entité au submit).

L'affichage/masquage du champ "Autre" repose sur du JS simple plutôt
que sur #states : #states pose bien l'attribut data-drupal-states mais
ne bascule jamais la visibilité dans cette modale AJAX précise, pour
une raison non identifiée (un #states pourtant fonctionnel existe
juste au-dessus, sur field_entree_liee, qui surveille un vrai champ
Field API plutôt qu'un select ajouté à la main).
2026-09-07 11:56:08 +02:00
bachir b7490eb76a Ajoute la TVA (%) et calcule automatiquement le Montant TTC
Architecture : Montant HT reste saisi à la main, un nouveau champ TVA
(%) le complète, et Montant TTC = round(HT * (1 + TVA/100), 2) devient
une valeur calculée plutôt que saisie -- champ readonly dans le
formulaire (aperçu live en JS), calcul faisant foi côté serveur dans
figli_compta_ledger_node_presave().

Migration (update hooks 8004/8005) : Montant TTC n'est **jamais**
modifié pour les données historiques -- seul un TVA effectif est
rétro-calculé depuis HT/TTC existants et ajouté en tant que nouvelle
métadonnée (1498 lignes renseignées, 58 laissées vides faute de TTC
source). Précision du champ TVA fixée à 4 décimales après vérification
empirique sur les 1556 lignes existantes : reconstruire TTC = HT * (1 +
TVA/100) avec un taux arrondi à 4 décimales ne s'écarte du TTC réel que
pour 9 lignes (probables factures multi-taux), contre 74 à 2 décimales.

Garde-fou supplémentaire dans le presave : le recalcul du TTC ne se
déclenche que si HT ou TVA ont réellement changé par rapport à la
révision précédente (comparaison à $node->original) -- sans ça,
rouvrir une ancienne ligne migrée pour corriger un simple libellé
aurait silencieusement dérivé son TTC historique de ±0,01€ à cause de
l'arrondi du taux rétro-calculé, ce qu'interdit la règle du projet de
ne jamais corriger les données historiques.

Réorganisation du formulaire : N° Facture rejoint Client sur une même
ligne, Montant HT/TVA/Montant TTC forment la ligne suivante -- les
poids de champs doivent rester des entiers (Drupal tronque silencieusement
tout poids fractionnaire lors de la sauvegarde de l'affichage).
2026-09-07 11:36:53 +02:00
bachir f00f680143 Corrige trois régressions du restyling du formulaire de ligne comptable
- Le select "Type de ligne" affichait un chevron géant répété (le
  shorthand `background: transparent` sur input/select/textarea
  réinitialisait aussi position/repeat/size de Claro, transformant sa
  flèche unique alignée à droite en motif carrelé). Les select gardent
  maintenant leur background Claro intact.
- Les menus "Toggle Actions" (⋮) de la Répartition étaient ouverts en
  permanence : mon display:flex écrasait le display:none par défaut de
  Claro, cassant le toggle piloté par paragraphs.actions.js. Rescopé à
  `.paragraphs-dropdown.open .paragraphs-dropdown-actions`.
- Marges resserrées : le `.form-item { margin-block: 1.5rem }` de Claro
  s'ajoutait à notre propre grid-gap. Neutralisé (`margin-block: 0`), et
  gap/paddings réduits.
2026-09-07 11:14:24 +02:00
bachir 4ce1b12099 Modernise l'UI du formulaire modal d'ajout/édition de ligne comptable
Le formulaire natif Drupal (Menu settings, URL alias, Authoring
information, tabledrag/drag-handle pour des tableaux de 1-3 lignes,
"Title" affiché tel quel) était pensé pour un éditeur de contenu
générique, pas pour la saisie numérique quotidienne d'une ligne
comptable. Sans toucher au Form API (validation, structure des champs
inchangées) :

- Grille compacte à 3 colonnes (Date+Type, Client, Montant HT/Facture/
  TTC en ligne), au lieu de l'empilement vertical par défaut.
- Menu settings / URL alias / Authoring information / Published
  masqués via #access -- jamais utilisés pour une ligne_comptable.
- "Title" relabellisé en "Libellé court" avec une description qui
  explicite son rôle de repli quand "Notes / détail" est vide (déjà le
  comportement de home.js/dashboard*.js, jusque-là invisible côté
  formulaire).
- Répartition / Entrée liée : poignée de glisser-déposer et bascule
  "Show row weights" masquées (l'ordre n'affecte jamais la somme ni
  l'affichage), boutons Ajouter/Retirer/Dupliquer restylés.

Classe CSS partagée `figli-ledger-form` ajoutée par form_alter plutôt
que de cibler la classe générée par Drupal, différente entre le
formulaire d'ajout (node-ligne-comptable-form) et celui d'édition
(node-ligne-comptable-edit-form).
2026-09-07 09:57:37 +02:00
bachir 9234050a68 Ajoute un filtre par tag de signalement sur /lignes et /dashboard/compte
Même modèle multiselect/OR que les filtres Compte et Type déjà en place,
en complément (pas en remplacement) du simple on/off "Signalées
uniquement" du grand livre. Sur /dashboard/compte, la liste d'options se
limite aux tags réellement présents pour le compte sélectionné, et se
réinitialise au changement de compte.
2026-09-06 21:59:32 +02:00
bachirandClaude Sonnet 5 11250bea77 Add solde footers + per-année small multiples to /dashboard/compte
1. "Entrées sur-versées" and "Versements sans entrée liée" tables get a
   solde footer, same convention as "Reste à verser" above them --
   reuses the totalSurVerse/totalNonLies computeds already backing the
   summary cards, no new computation.

2. "Répartition de l'activité par type" and "Top clients" keep their
   all-time chart, now followed by a small-multiples grid of the same
   chart per année. The type breakdown reuses
   total_par_type_par_compte_par_annee, a new field on
   DashboardStatsController::stats() built from the same répartition-
   level rows already fetched for total_par_type_par_compte (no extra
   query, just one more level of grouping in the PHP aggregation). Top
   clients per année is computed client-side from the same
   entreesDuCompte already used for the all-time version, capped to top
   5 (not 10) to keep the grid readable.

HBarChart gains a `compact` prop (narrower fixed columns, smaller text)
for use inside the small-multiples cards -- the all-time charts above
keep the full-width layout unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:41:11 +02:00
bachirandClaude Sonnet 5 ec2af0ea7e Add "Répartition de l'activité par type" chart to /dashboard/compte
Extends DashboardStatsController::stats()'s répartition-level SQL query
to also group by type (not just année/compte), producing a new
total_par_type_par_compte breakdown alongside the existing global one.
Unlike the reconciliation tables on this page (deliberately entrée/
versement only), this chart covers every type touching the selected
compte's répartition, matching what the general /dashboard already
shows for the whole ledger.

Fixed a latent bug the query change would otherwise have introduced:
solde_par_compte_par_annee[année][compte] used to be a 1:1 assignment
because each (année, compte) pair was unique in the old query -- adding
type to the GROUP BY means several rows can now share that same pair,
so it has to accumulate instead of overwrite (verified the accumulated
totals exactly match a query without the type split, so this preserves
existing behavior for the fields already in use).

HBarChart in dashboard-compte.js gained the same colorFor prop
dashboard.js's version already has (existing Top clients usage keeps
its default green, unaffected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:35:10 +02:00
bachirandClaude Sonnet 5 bd251d0a5e Fix diverging year-bars chart: bars were pinned to the top, not the zero line
Root cause: .figli-year-bar was position: relative with an explicit
height (set inline by barStyle()), which opts it out of the flex
container's default stretch alignment -- so it rendered flex-start
(top) aligned first, and the top/bottom: 50% from barStyle() only
*offset* that already-top position instead of anchoring an edge to the
middle. Visibly: green (positive) bars piling up near the top instead
of growing upward from the zero line.

Fixed by wrapping each bar in a .figli-year-bar-slot that stays in the
normal flex flow (so horizontal side-by-side layout for two bars/year
still works) and giving the bar itself position: absolute, anchored
against the slot's full-height box -- that's what makes a 50% top/
bottom offset actually mean "the zero line" instead of "50% further
down/up from wherever flex already put it."

Verified geometrically in-browser: bars now straddle the track's
midpoint and extend outward in the correct direction (positive up,
negative down) on both "Entrées vs versements par année" and
"Évolution du solde".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:14:00 +02:00
bachirandClaude Sonnet 5 22f57379e5 Add solde footer to the "Lignes signalées" table on /dashboard/compte
Sum of the Montant column across every flagged line for the selected
compte, styled red when negative -- same convention as the other
reconciliation tables' footers on this page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:10:25 +02:00
bachirandClaude Sonnet 5 c7c143e038 Revert: force jj/mm/aaaa on the ligne_comptable date field
Back to the native <input type="date"> widget, per request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:07:52 +02:00
bachirandClaude Sonnet 5 463a7f343c Force jj/mm/aaaa on the ligne_comptable date field, regardless of browser locale
The native <input type="date"> widget's displayed digit order follows
the browser's own locale, not Drupal's site language -- an English-
locale browser was showing 07/26/2024. Switches the date sub-element to
'text' with an explicit PHP date format so Drupal parses/displays it
consistently for everyone.

Verified end-to-end (not just cosmetically): submitted 05/03/2024 through
the real add form and confirmed it stored as 2024-03-05 (5 March), not
2024-05-03 -- genuinely parsed as day/month, not silently reinterpreted
as month/day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:03:18 +02:00
bachirandClaude Sonnet 5 775ee0955f Display /lignes dates as aa/mm/jj instead of jj/mm/aa
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 17:56:57 +02:00
bachirandClaude Sonnet 5 78661628fc Make the "Reste à verser" badge dark green instead of orange
Distinguishable from the default badge green (used for "Lié") while
staying green rather than orange -- it's an amount owed, not a warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:51:22 +02:00
bachirandClaude Sonnet 5 9ce5d87ace Surface signalement (flag) tags on the per-compte dashboard
Fetches field_flag alongside the existing entrée/versement data (same
JSON:API include list, same buildRows() shape as home.js's flags/
hasFlag). Two changes:

- Rows already shown in the reste-à-verser/sur-versé/non-liés tables
  get the same amber left-edge accent + tag badges as /lignes when
  they're also flagged.
- New "Lignes signalées" section lists every flagged entrée/versement
  for the selected compte, including ones that don't appear in any of
  the other tables -- a fully-settled line can still carry a flag for
  an unrelated reason (e.g. "client injoignable"), which none of the
  reconciliation-based tables would otherwise surface.

Verified live against an isolated test node plus two real flagged lines
already present in production data (client had already started using
the /lignes signalement feature) -- both the section and the inline
badges render correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:43:44 +02:00
bachirandClaude Sonnet 5 3f8767b4ef Narrow edit columns for wider amounts, modal-based signalement, fix scroll storm
Three related /lignes fixes:

1. Column widths: shrink Date/Facture/Libellé/Signalement (the four
   editable text columns) to free up room for the amount columns
   (Montant HT/TTC, the 8 compte columns, Écart), which were cramped.
   Date/Facture stay nowrap (already short: jj/mm/aa, Fxxxxxxx); Libellé/
   Signalement keep wrapping.

2. Signalement editing: replaced the comma-separated inline text input
   with a small modal -- one tag per line, each with its own remove
   button, plus an add field at the bottom. Clearer than parsing/
   retyping a whole comma list to drop one tag. Backend endpoint is
   unchanged (still takes a comma-joined value); only the front-end
   interaction model changed.

3. Scroll storm: a continuous scroll gesture fires many native 'scroll'
   events, and checkEdges() ran on every one of them -- each qualifying
   event queued its own loadOlder()/loadNewer() call (queuing, not
   dropping, was the previous session's fix for a *different* bug), and
   every queued call did a real fetch + scroll compensation regardless
   of whether an earlier one already moved the window away from the
   edge. That pileup is what looked like the same request firing over
   and over and dragged the scroll position around unpredictably.
   Fixed by guarding checkEdges() with the existing loadingOlder/
   loadingNewer flags so it stops queuing once one's already in flight.
   (A first attempt at this suppressed the compensation write's own
   resulting scroll event via a flag cleared on requestAnimationFrame --
   reproduced, live, the exact "stuck forever" failure already fixed
   once this session for the old rAF-based scroll throttle, because rAF
   doesn't reliably fire in this environment. Removed: turns out no
   suppression is needed at all, since that event finds loadingOlder
   already true and the checkEdges() guard blocks it on its own.)
   Also added a re-entrancy guard to pollForChanges(), which had no
   protection against a slow response overlapping with the next
   setInterval tick.

Verified with scripted scroll stress tests (dense bursts of 40-100
events, and repeated attempts to scroll back from the very top): exactly
one fetch per genuine edge crossing, no duplicate requests, scroll
position stays correctly anchored, never snaps back down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:47:34 +02:00
bachirandClaude Sonnet 5 fa28783bcc Fix table jitter, horizontal scroll, and vertical scroll snapping on /lignes
Root cause: the main table used auto layout, so every loadOlder()/
loadNewer() reflowed every column's width based on whatever was
currently loaded -- visibly shifting the table, spilling past the
viewport into a horizontal scrollbar, and corrupting loadOlder()'s
scroll-position compensation (which assumes the scrollHeight delta
after prepending rows is *only* the new rows' own height -- not true
once existing rows also reflow). That's what made scrolling up feel
like it kept snapping back down.

- table-layout: fixed with explicit per-column widths (percentages
  throughout, not mixed with rem -- mixing meant the rem columns' width
  was added on top of the percentage budget instead of coming out of
  it), plus box-sizing: border-box so padding doesn't inflate columns
  beyond their declared width.
- overflow-x: hidden instead of auto on the scroll container: with both
  x and y auto on the same element, the browser has to guess whether a
  vertical scrollbar will appear before laying out width: 100%, and a
  wrong guess understates available width by a scrollbar's worth --
  exactly enough to tip a tightly-fitting table into needing horizontal
  scroll too.
- Fixed a footer-row column count bug found along the way: it still had
  two actions-col cells and a colspan=5 label from before the link
  button was removed, leaving the label 6 columns short of Signalement
  and misaligning every footer cell after it.

Verified with a 25-round scripted scroll-up stress test spanning 3
loadOlder() triggers: scroll position stays correctly anchored near
where the user was looking, never jumps toward the bottom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:16:06 +02:00
bachirandClaude Sonnet 5 eda79e75a3 Simplify /lignes row indicators: drop redundant link button and écart outline
- Remove the left-side link button (actions-col) -- the link status
  badge already does the same thing (view linked entrées, or open the
  link form when unlinked), so the button was pure duplication.
- Remove the red row outline on écart lines -- the Écart column itself
  (bold red text) already flags it, no need for a second row-level cue.
- Hide the link status badge specifically on "Hébergement" lines (both
  the main table and the drill-down modal) -- reconciliation math is
  untouched (hébergement sorties still count toward other rows' totals),
  this only suppresses the badge on hébergement's own row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:00:29 +02:00
bachirandClaude Sonnet 5 34b1082991 Add signalement (flag) tags for problem lines on /lignes
New field_flag: free-tagging taxonomy reference (multi-value,
auto-create) on ligne_comptable, for marking a line with an
unstructured problem description (e.g. "client impayé") that can't be
detected automatically the way the répartition écart already is.

- New "Signalement" column, inline-editable like Client/Facture/Libellé
  (comma-separated tags, datalist autocomplete, server-side auto-create
  of unknown tags -- same pattern LedgerActionsController already used
  for Client, now shared via findOrCreateTerm()).
- New "Signalées uniquement" filter, mirroring "Écarts uniquement".
- Flagged rows get a distinct amber left-edge accent (box-shadow, not
  border) so a row that's both in écart and signalée shows both
  indicators without one overwriting the other.
- Native node add/edit form gets the field for free via core's
  entity_reference_autocomplete_tags widget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 14:55:46 +02:00
bachirandClaude Sonnet 5 ceaf301bf0 Add per-compte dashboard: reste à verser between entrées and versements
New /dashboard/compte page, one compte associé (freelance) at a time via
a selector. Focused on entree/versement only (not achat/hébergement/
sous-traitant): reuses the same entrée<->versement reconciliation
algorithm as the /lignes badges (a versement can settle several entrées
at once, split equally), aggregated per compte to surface outstanding
"reste à verser", over-paid entrées, and versements with no linked
entrée at all -- plus solde/entrées-vs-versements/top-clients charts for
context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 14:29:27 +02:00
bachirandClaude Sonnet 5 3404ee14c3 Fix sliding window silently stalling under multi-filter URL hashes
Restoring several filters at once from the URL hash (client + type)
fired several concurrent loadOlder()/loadNewer()/ensureScrollable()
chains. They raced on the loadingWindow guard, which silently dropped
a call arriving mid-flight instead of queuing it -- indistinguishable
from "nothing more to load", so the sliding window gave up expanding
after one round even when the table was still far from scrollable,
with no scroll events left to ever retry it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:06:02 +02:00
bachir 71fde0f3c0 Add live polling and optimistic locking for concurrent inline edits
Two-part fix so multiple people can edit /lignes at once without
clobbering each other, and see each other's changes without a manual
reload -- no Socket.io/websocket infra, just what fits the existing
fetch-based architecture:

1. Polling (POLL_INTERVAL_MS = 8s): pollForChanges() asks JSON:API for
   any ligne_comptable changed since the last check (filtering on the
   `changed` field -- confirmed live that JSON:API only accepts a raw
   Unix timestamp for this, not the ISO string it returns in responses,
   silently matching everything otherwise) and mergeChangedRows()
   patches matching rows in place via Object.assign (not a `rows`
   reassignment, so it doesn't re-trigger the reconciliation-resolution
   watcher for routine polls). A row currently being edited is skipped
   entirely rather than overwritten out from under an in-progress
   keystroke.

2. Optimistic locking: every row now carries its `changed` timestamp,
   sent back on every inline edit (updateType/updateField). A new
   checkConflict() compares it against the node's actual changed time
   before saving and rejects with 409 if they differ -- someone else
   saved this exact line in between. On a 409, refreshSingleRow()
   re-fetches just that node and patches it in place so the view
   self-corrects instead of staying stuck on the stale state that
   caused the rejection.

Verified live end-to-end against an isolated temporary test node (not
real data): an external edit correctly appeared in the browser within
one poll cycle with no reload; a save using a stale `changed` value was
rejected with the conflict error, confirmed via direct DB query that it
left the node's data completely untouched, and the view auto-corrected
to show the other edit. Test node and its paragraph fully cleaned up
afterward.
2026-09-06 12:49:56 +02:00
bachir 0c893b1b24 Show the exact solde amount directly in the versement badge
The reste-à-verser/sur-versé badge only showed the word, with the exact
figure available in the hover title -- had to open the drill-down modal
just to see how much. linkStatus() now exposes the computed amount
(montant) alongside kind/detail, and linkStatusLabel() takes the whole
status object to format it inline: "Reste à verser : 440,00 €",
"Sur-versé : 710,00 €", "Lié : 0,00 €" for a fully settled one.

Verified live: all three cases render with the exact figure in both the
main table and the drill-down modal.
2026-09-06 12:32:41 +02:00
bachir fc89ae6232 Resolve versement reste-à-verser/sur-versé from the server proactively
The badge on a versement freelance row previously only got the correct
status once someone clicked it -- before that it could show "inconnu"
(or, worse, silently default to "ok") whenever its linked entrée wasn't
in the currently loaded sliding window, which is common: the entrée
that got paid can be dated years before or after the versement itself.

Also fixed a latent bug in the click path itself: toggleEntreeFilter()
passed the *entrée's* id to loadEntreeGroup(), which needs an
already-loaded row to find a starting nid for the server-side query --
if that entrée wasn't loaded (the exact case this is all about), the
lookup silently found nothing and did nothing. Split the fetch+merge
logic into fetchGroupFromNid() and let loadEntreeGroup() accept a
fallbackNid (the versement's own, always loaded) to start the
traversal from when the entrée itself isn't available locally --
LedgerStatsController::groupeEntree() finds the same connected
component either way.

New ensureLinkedReconciliationResolved(), triggered by a `rows` watcher
after every load/loadOlder/loadNewer, calls the same server-backed
resolution for every visible linkable row instead of waiting for a
click, deduplicated via resolvedLinkGroups so rows sharing an entrée
don't each trigger their own fetch.

Verified live: the LA MINE versement (own date 2022, linked entrées
dated 2023 and 2025) now shows "⚠ Sur-versé" immediately on page load
with zero clicks, where before it required manually opening the badge
first. Drill-down modal and the "Non liée" -> link-form click path both
still work; no console errors on repeated fresh loads.
2026-09-06 12:21:53 +02:00
bachir 18cb4cc3c8 Auto-focus click-to-edit fields instead of requiring a second click
Type/Client/Facture/Libellé all swap a span for an <input>/<select> via
v-if -- Vue doesn't focus a newly created element on its own, so the
first click only revealed the field and a second click was needed to
actually type into it. Added a v-focus directive (mounted() calls
el.focus() + el.select(), firing exactly once per v-if true-flip) to
all four.

Verified live: a single click on each of the four editable cells now
moves document.activeElement into the field immediately.
2026-09-06 11:14:02 +02:00
bachir 0efc41f121 Create a new Client term on the fly instead of rejecting unknown names
updateField()'s client branch previously rejected any name that didn't
match an existing "Client" term, on the assumption the front-end's
datalist restricted input to known names -- it doesn't, it only
suggests them, so this blocked adding a genuinely new client from the
inline edit even though the content type itself allows it. Now creates
the term (same "autocreate" behavior as a standard Drupal entity
reference autocomplete widget) rather than erroring.

Frontend also appends the newly created name to allClientsList so it
shows up in the filter dropdown/datalist immediately, not just after a
reload picks it up via fetchClientNames().

Verified live: typed a brand-new client name inline, save succeeded,
confirmed the taxonomy term was actually created in the database and
the filter datalist updated immediately -- then reverted the test node
and deleted the test term.
2026-09-06 11:11:37 +02:00
bachir 179a994f69 Fix empty Facture/Libellé cells having nothing to click
Facture and libellé rendered their span with no fallback text when
empty, unlike Client's existing "|| '—'" -- an empty span has zero
width/height, so there was nothing there to click at all. Added the
same "—" placeholder to both.

Also switched .figli-editable-cell to display: block with a min-height,
so the clickable area fills the whole cell's content box instead of
just hugging the text -- a lone "—" character was still a tiny target
even with a placeholder.
2026-09-06 10:52:23 +02:00
bachir 11eb9cadb0 Make Client/Facture/Libellé editable in place, same as the type badge
New POST /lignes/{node}/champ endpoint (LedgerActionsController::updateField(),
whitelisted to client/facture/libelle -> field_client/field_numero_facture/
field_notes) mirrors updateType(): skips the répartition invariant check
for this save (client/facture/libellé never touch montant_ht or
field_repartition, so it can only ever leave a pre-existing historical
mismatch as it was, never introduce one), wrapped in the same
skip_validation state flag with a try/finally.

Client resolves the typed text against existing "Client" taxonomy terms
only (same known-names list the toolbar's Client filter already offers
via a datalist) -- a non-match is rejected with a clear error rather
than silently creating a new term from a typo.

Frontend mirrors the existing editingTypeId/startEditType/saveType
pattern exactly, generalized to any of the three fields via a single
{id, field} editingCell state.

Verified live: editing all three fields on a row with a known
répartition écart succeeds (bypass confirmed), an unknown client name
is rejected with a visible error and the display value stays unchanged,
and the database was confirmed clean of test artifacts afterward.
2026-09-06 10:46:30 +02:00
bachir 50a6432691 Search field_numero_facture too in the entrée-liée autocomplete
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
is what actually finds the right one quickly. buildEntityQuery()
withholds $match from the parent call (which would otherwise add its
own title-only condition) and applies it manually as an OR across title
and field_numero_facture instead.

Verified live: searching "F2549" (not present in the title at all, only
in field_numero_facture) now correctly surfaces that entrée; a
title-only search and a no-match search both still behave as before.
2026-09-06 10:37:06 +02:00
bachir b6540e56c7 Add field_numero_facture (N° Facture), a Facture column, and backfill existing content
New plain string field on ligne_comptable, positioned after Client in
both form and view displays. Added as a "Facture" column in both the
main table and the entrée/sortie drill-down modal, right after Type.

Backfilled via figli_compta_ledger_update_8002() by extracting an
invoice number from field_notes (or title, same fallback the front-end
libellé already uses) wherever the pattern is unambiguous: literal F,
optional _/-, 2-8 digits, optional "-digits" continuations for a
compound reference (e.g. "F2549-50-51"), optional 0-3 trailing
uppercase letters (e.g. "F250427A"), with a hard boundary right after --
not immediately followed by more letters/digits/underscore. That last
part is what skips "F58_260506_FIGLI" or "F_2601_FIGLI_EPAU": no way to
tell whether the trailing "_xxx" belongs to the reference or is an
unrelated client/description code glued on, so those are left blank
rather than guessed, per explicit instruction to skip when unsure.

Verified the pattern against every existing ligne_comptable's
notes/title before writing the migration: 319 confident matches with no
false positives found on manual review of the full list, 1236 left
blank. Applied via drush updb, config exported.
2026-09-05 22:37:34 +02:00
bachir 852eda2056 Show the répartition validation error message instead of failing silently
Editing a ligne_comptable with an inconsistent répartition correctly
blocked the save (setErrorByName() on field_repartition), but the
message text had nowhere to render: it normally goes through the page's
status-messages region, which isn't part of this standalone AJAX
ReplaceCommand fragment. The form re-render did mark every répartition
row with a subtle red outline, but with no visible explanation the modal
just looked stuck.

figli_compta_ledger_node_form_ajax_submit() now also emits a
MessageCommand for each form error, mirroring the pattern already used
for the success path's messenger() messages. Verified live: breaking a
répartition sum and clicking Save now shows a proper error toast with
the exact mismatch amount, the modal correctly stays open, and the node
is confirmed unchanged in the database.
2026-09-05 22:08:42 +02:00
bachir bb01c2dd90 Turn Compte/Type filters into hover dropdowns, add a clear button to each filter
A native <select multiple> can't collapse to a single-line trigger and
open as an overlay on hover -- it's either an always-open multi-row
list box or a single-value dropdown, not both. Replaced with a custom
dropdown: a trigger showing the current selection ("Tous" / "Maud,
Ouidade"), and a checkbox list shown on hover via a plain CSS :hover
rule (no open/close state needed). Checkboxes instead of ctrl/shift+click,
but same result -- several comptes/types at once -- with a more
discoverable interaction (no keyboard modifier to know about).

Also added a "✕ Effacer ce filtre" button next to Compte/Client/Type/
Année, shown only once that filter is active. Directly addresses the
Client filter specifically being painful to clear (select the text,
delete, press enter) -- one click now.
2026-09-05 21:55:50 +02:00
bachir 319b22207d Allow selecting several comptes/types at once in the filters
Compte and Type were single-value <select> elements. Converted both to
<select multiple> -- Vue binds those to an array natively, so
ctrl/cmd+click and shift+click just work with no custom JS. Matching
semantics are OR within a filter (any of the selected comptes/types) and
AND across filters, same as the existing client/year/écarts filters.

The URL hash (compte=Maud,Bachir&type=versement,achat) now carries
comma-separated lists instead of a single value, so the shareable/
reloadable filtered view still works with multiple selections.
2026-09-05 21:48:59 +02:00
bachir 0479f3fbbb Fix scroll-triggered loading permanently stopping after one round
onScroll() throttled via requestAnimationFrame with a guard
(_scrollRaf) reset from *inside* the rAF callback -- if that callback
ever failed to fire (reproduced via a backgrounded/non-visible tab,
where browsers routinely throttle or suspend rAF), the guard stayed
true forever, silently dropping every future scroll event. Matches
exactly what was reported: the window extends once when scrolling
toward the past, then stops responding to scrolling at all.

detectCurrentYear()/checkEdges() are cheap (DOM reads + early-return
guards, do no real work themselves), so there's nothing worth
throttling here -- removed the rAF entirely and call both directly on
every scroll event. Verified live: repeated scroll-to-top no longer
gets stuck after the first extension, keeps loading older months
across many rounds.
2026-09-05 21:39:47 +02:00
bachir 0c9f8ca41f Show the link-status badge on all linkable types, not just versement
Hébergement (and achat/sous-traitant) were already linkable to an
entrée client -- LINKABLE_TYPES already included them, so the "Lier"
button and the reconciliation math worked fine -- but versementStatus()
hardcoded item.type !== 'versement' and returned null for every other
type, so those rows never got the Non liée/Lié/Reste à verser/Sur-versé
badge at all, and had no way to open the link form except the small
actions-column icon.

Renamed versementStatus()/versementStatusLabel()/versementStatusClasses()
to linkStatus()/linkStatusLabel()/linkStatusClasses() and generalized
the type check to LINKABLE_TYPES.includes(item.type). Verified live: a
hébergement row linked to an entrée now shows the same badge, status
math, and drill-down modal as a versement.
2026-09-05 21:17:18 +02:00
bachir 8f65a9097a Make the "Non liée" versement badge clickable to open the link form
It was the only versement status that did nothing on click -- every
other kind opens the drill-down modal. Now it opens the same "Lier à une
entrée client" form as the actions-column link button, since that's
exactly what a non-liée versement needs next.
2026-09-05 19:51:43 +02:00
bachir 2a6d36c603 Hide empty compte columns in the drill-down modal, rename its title
The modal's group is usually 2-4 rows touching only 1-2 comptes -- unlike
the main table (always all 8, so columns line up across every row/year),
showing all 8 there was mostly empty columns. New modalComptes computed
filters allComptes down to whichever actually carry a value somewhere in
filterEntreeGroup.

Also renamed "Entrée + sorties liées" to "Entrées et sorties liées"
(title and footer label) to match multi-entrée groups.
2026-09-05 19:32:38 +02:00
bachir 6c0bddd964 Replace the comma-separated tags field with one field per linked entrée
field_entree_liee is multi-value, but the single #tags entity_autocomplete
rendered it as one comma-separated text box -- functionally correct but
easy to mistake for "only one entrée can be linked". Rebuilt as one
autocomplete field per link plus an "Ajouter une référence" button (the
classic Drupal multi-value pattern, matching the Répartition paragraphs
widget already used elsewhere in this form). Clearing a field's text
drops that link on save, rather than a "remove" button -- a remove
button would need to shift every later delta, which fights Drupal's own
value-restoration-on-AJAX-rebuild (verified live: that restoration only
works cleanly for pure appends at a stable delta, which is all
addItem() does).

Verified end-to-end via raw AJAX POSTs against a real 3-entrée versement:
initial load pre-fills 3 separate fields, "Ajouter" appends a 4th empty
one without disturbing the first 3, and submitting with one field
cleared and a new one filled saves exactly the expected set.
2026-09-05 19:19:08 +02:00
bachir b73a980282 Resolve the full entrée/versement group regardless of the sliding window
filterEntreeGroup, reconciliationByEntree and versementStatus only ever
searched `rows`, the currently loaded date-range slice -- a versement
linked to entrées from other years (confirmed live: one node had 3
linked entrées spanning 2023/2025/2025) silently only showed whatever
happened to be in the loaded window, both in the drill-down modal and in
the reste-à-verser/sur-versé math.

Added LedgerStatsController::groupeEntree() (GET /lignes/api/groupe/
{node}), a real DB query following field_entree_liee in both directions
(a node's own targets, and any node referencing it) -- something the
client can't discover from a partial window. toggleEntreeFilter() now
fetches the complete group up front and merges whatever isn't already
loaded into groupExtraRows; every affected computed reads the combined
pool via a new allKnownRows.

Also stopped versementStatus from defaulting to a false "ok" when a
linked entrée's reconciliation can't be resolved yet -- it now reports a
distinct "inconnu" (À vérifier) status instead of silently assuming
everything's settled.
2026-09-05 19:03:41 +02:00
bachir 619a2ab70c Stop hiding the top bar globally -- it hid the Save button on entity edit forms
.top-bar was unconditionally hidden on every page (admin_chrome library)
on the assumption it always renders empty, which held on our own pages
but not on entity edit forms, where Gin puts the page's local actions --
Save included -- inside .top-bar. Folded it into the same route-scoped
library as the admin sidebar (renamed hide_admin_sidebar ->
hide_admin_chrome) so both are hidden only on our front-end routes and
real admin pages keep a working Save button.
2026-09-05 17:01:39 +02:00
bachir 73818779c1 Fix client autocomplete pagination + hide admin sidebar on front-end pages
fetchClientNames() requested page[limit]=200 but never followed
links.next, and JSON:API silently clamps to a 50-item hard cap -- with
106 client terms, everything past the 50th alphabetically (e.g. "LE
CAMPUS") was dropped. Now loops through every page like fetchLignes()
already does.

The core Navigation module's admin sidebar (#admin-toolbar) was showing
on our custom /lignes, /dashboard, /lignes/historique and lier-entree
pages. Added a route-scoped library (attached only for those route
names in hook_page_attachments(), not folded into the always-on
admin_chrome attachment) so real Drupal admin pages keep the sidebar.
2026-09-05 13:27:33 +02:00
bachir 82b03ac5e2 Fix horizontal scroll in the entrée/versement drill-down modal
900px was way too narrow for 16 columns forced to nowrap. Widened the
modal to 96vw, let cells wrap instead of forcing nowrap, and gave
columns proportional widths (table-layout: fixed otherwise spread all
16 evenly, squeezing the libellé text into the same width as an empty
compte column).
2026-09-05 13:20:13 +02:00
bachir 9ec0aa4699 Open the entrée/versement drill-down in a modal instead of in place
Replacing the main table's rows with the filtered group made the browser
clamp scrollTop to 0 the moment the drill-down shrank the visible content,
so closing it never returned to where the user had been scrolled. A modal
overlay leaves the main table (and its scroll position) untouched entirely.
2026-09-05 13:16:44 +02:00
bachir 0f33d57ec0 Show a badge on linked-and-settled versements too
versementStatus() returned null whenever a linked versement's own
compte(s) had no residual against any linked entrée -- meaning a
versement that was linked but fully reconciled got no badge at all,
silently losing the only way to open its drill-down (the badge is
also the click target). Only "not linked" and "has a residual" ever
rendered one.

Now always returns a status for any versement, adding a fourth kind
("ok": linked, no residual on any checked entrée) alongside
non-liee/reste/sur-verse. Renders as a plain "Lié" badge in the
default green (no is-anomalie/is-reste modifier), matching the
convention the entrée side already uses for a fully-settled "N
sorties liées" badge, and stays clickable since item.entreeLieeIds
is still non-empty.

Verified: several previously badge-less linked versements (e.g.
"facture-SC-Bachir-260329B-FIGLI", "F58_260506_FIGLI") now show a
green "Lié" badge, and clicking one opens the same 3-row drill-down
(entrée + both its linked sorties) as before.
2026-09-05 12:53:30 +02:00
bachir 0314625593 Client autocomplete, drill down from versements, multi-entrée linking
Three related changes to the /lignes table:

1. The "Client" filter is now a text input with a <datalist> instead
   of a <select> -- 50+ clients made the dropdown unwieldy. v-model.lazy
   (not the default per-keystroke binding) since a change here triggers
   ensureScrollable() and a hash rewrite, which shouldn't fire on every
   character typed.

2. Clicking a "versement freelance" row's own status badge (Non liée /
   Reste à verser / Sur-versé) now drills down the same way an entrée's
   "N sorties liées" badge already did, instead of only being clickable
   from the entrée side.

3. field_entree_liee is now multi-value (cardinality unlimited) --
   sometimes one payment covers several client invoices at once.
   LinkEntreeForm uses #tags => TRUE (a single comma-separated
   autocomplete field, Drupal's field-API-native multi-value shape on
   submit, no manual tag parsing needed). This is the deeper change and
   touches most of the reconciliation logic in home.js:
   - buildRows() reads field_entree_liee as an array
     (entreeLieeIds/entreeLieeLabels) -- JSON:API always returns a list
     for a multi-cardinality relationship now, even with 0 or 1 items.
   - sortiesByEntree indexes a sortie under every entrée it links to.
   - reconciliationByEntree splits a multi-linked sortie's répartition
     equally across its linked entrées -- there's no per-link amount to
     divide by, so equal split is the least-wrong assumption available
     rather than counting the sortie's full amount against every linked
     entrée (which would double-count the same money).
   - versementStatus() sums residuals across all of a versement's linked
     entrées for its own compte(s), skipping any not in the currently
     loaded window (same accepted trade-off reconciliationByEntree
     already had).
   - The drill-down (filterEntreeId) is now filterEntreeGroup, a
     transitive closure over shared entrée<->sortie links -- clicking
     one entrée (or, per #2, one versement) surfaces every other entrée
     it's connected to through a shared sortie, and every sortie linked
     to any of them, not just the originally-clicked one's direct links.

Verified end-to-end: linked a real unlinked versement to two entrées
for the same client via the actual form submission (no manual DB
edit), confirmed both persisted, confirmed the link button's tooltip
lists both, and confirmed clicking either the versement's or an
entrée's badge produces the same 3-row connected group with correct
drill-down footer totals. Reverted the test link afterward.
2026-09-05 11:35:18 +02:00
bachir 0170ec4475 Make filters and "Aller à" reloadable/shareable via the URL hash
#compte=Maud&client=EPAU&type=versement&annee=2023&ecarts=1&aller=2024
-- reload the page or send the link and the same filtered view (or
year jump) comes back.

readHashState()/buildHashString() handle the encoding; syncHash()
writes back via history.replaceState() (not pushState -- tweaking a
dropdown shouldn't spam the back button with history entries), called
from the existing filter watchers plus jumpToYear(). "aller" isn't an
ongoing filter (jumpYearValue always resets to '' right after firing)
so its target year is tracked separately (lastJumpYear) purely for the
hash, and omitted whenever "annee" is also present -- the two describe
overlapping year state and jumpToYear() already clears an active
"Année" filter when it runs, so keeping both would just be redundant.

mounted() applies compte/client/type/écarts unconditionally (they only
narrow filteredRows, safe regardless of load path), then branches:
annee in the hash sets filterYear (triggering enterYearMode() same as
manual use), aller calls jumpToYear() directly, otherwise the default
load()+scroll-to-bottom path runs as before -- each path already
manages its own loading state and scroll position, so only one runs.

Verified all three round-trip through reload: compte+type together
(ensureScrollable extends to find matches, as before), aller=2023
(first fetch targets exactly 2023-01-01..2024-06-30), and annee=2022
(every loaded row's data-year is 2022, filter dropdown reflects it).
Default no-hash load is unaffected.
2026-09-05 11:17:33 +02:00
bachir 9a70205c83 Scope versement reste-à-verser to its own compte(s), not the entrée total
versementStatus() was reusing reconciliationByEntree()'s aggregate
resteAVerser/surVerse, which sums residuals across every compte the
entrée touches -- including comptes tied to *other* sorties linked to
the same entrée. A versement paid entirely through Maud could show
"reste à verser" driven by an unrelated Sandrine/Chloé shortfall on
the same entrée, or vice versa mask its own compte's sur-versement
behind an unrelated compte's surplus.

reconciliationByEntree() now also keeps a per-compte residual map
(parCompteResidual), and versementStatus() sums only the residuals for
the compte(s) this specific versement's own répartition touches.

Verified against a real case: entrée EPAU F2549-50-51 (répartition
across 8 comptes) with one linked "Versement Maud" of -10 000€ against
an entrée-side Maud share of 5 632,86€. The entrée's own badge still
correctly shows the aggregate ("reste 24 325,54 € · sur-versé
4 367,14 €"), but the versement row itself now shows "Sur-versé :
4 367,14 €" -- its actual Maud-only residual -- instead of the
previous "Reste à verser", which was purely an artifact of the other
7 comptes' unrelated shortfalls.
2026-09-05 11:09:37 +02:00
bachir 6bdf12b8fe Show the drill-down's own totals in the footer, not the year's
Clicking an entrée's "N sorties liées" badge drills down to just that
entrée + its linked sorties (filterEntreeId) -- but the footer kept
showing the current year's totals throughout, which answers a
different question than the one this view exists for ("does this
entrée balance against what was paid out").

drilldownTotals() computes the same shape locally (montant_ht/ttc,
écart, par_compte) from the already-loaded drill-down rows -- no
server round-trip needed, unlike the per-year figures. footerTotals()
picks between it and currentYearTotals depending on whether
filterEntreeId is set, so the footer swaps automatically and reverts
the moment the drill-down is cleared.

Verified: montant_ht and every compte column match the drill-down's
two rows summed by hand exactly (to the cent), and closing the
drill-down correctly restores the normal per-year footer.
2026-09-05 11:02:01 +02:00
bachir 5a311b8439 Drop the versement-warning row outline, keep only the badge
Too much visual noise -- the badge alone already says everything
needed. Removes the now-unused --figli-warning custom property along
with the outline rule.
2026-09-05 10:49:56 +02:00
bachir 1ab38bc2a7 Highlight versements not (fully) backed by their entrée
For "versement freelance" rows specifically: an amber outline plus a
badge when the versement either isn't linked to any entrée client at
all ("Non liée"), or is linked but reconciliationByEntree still shows
a residual on that entrée ("Reste à verser" / "Sur-versé", with the
amount in the tooltip). The residual belongs to the entrée as a whole,
not to any one sortie -- when several versements share an entrée, each
shows the same aggregate figure, since there's no way to attribute the
shortfall to one specific payment. No highlight when the linked entrée
isn't in the currently loaded window (can't tell either way -- same
accepted trade-off as reconciliationByEntree itself).

Verified: filtering to "Versement freelance" shows 21 of 25 loaded
rows flagged (17 unlinked, 4 with a reste-à-verser residual), the
remaining 4 fully-reconciled rows correctly unflagged, and the amber
outline resolves correctly in dark mode.
2026-09-05 10:40:01 +02:00
bachir ef2d7b801d Add sous-traitant, salaire/stage, and charges local pro ligne types
Three new field_type_ligne values (config/sync +
figli_compta_ledger.install for fresh-install parity), wired through
every place that enumerates the type list: LedgerActionsController's
inline type-change endpoint, home.js's type dropdown/badge, dashboard.js's
per-type chart, home.css's badge colors.

Sous-traitant is linkable to field_entree_liee (pays out against a
client's work, like versement/achat); salaire/stage and charges local
pro are not (structural costs, like charge). Turns out 13 lines
already carried "salaire_stage" and 1 "sous_traitant" as raw field
values from the historical migration -- list_string doesn't enforce
allowed_values at the storage level, so they saved fine but had no
label and weren't selectable in the UI until now.

Also fixes a real bug this surfaced: filtering /lignes by a sparse
type (e.g. "Autre") silently broke the sliding window -- so few rows
matched that the table no longer overflowed, so it never fired another
'scroll' event, so loadOlder()/loadNewer() never ran again ("les lignes
antérieures ne chargent plus"). Added ensureScrollable(), which keeps
extending the window in both directions whenever a thinning filter
(compte/client/type/écarts) leaves too little to scroll, and widened
the trim cap while filtering (MAX_LOADED_MONTHS_FILTERED) since the
normal 30-month cap actively fights a sparse filter -- extending one
end and immediately trimming the other nets out to nearly the same
slice every round. Bounded by a round counter rather than "did the
window stop moving": addMonths() uses Date#setMonth(), which isn't
invertible for month-end dates, so the window can drift indefinitely
in tiny steps without ever exactly repeating.

Verified: filtering by "Autre" now finds 24 matches (was stuck at 1)
and the view becomes scrollable within ~17s, settling cleanly rather
than hanging.
2026-09-05 10:26:47 +02:00
bachir 2f9ad7d52c Rebuild /dashboard with charts, add Grand livre/Dashboard nav
Nav: a small "Grand livre" / "Dashboard" switcher, top-right on both
pages, with the current page highlighted. Needed hook_theme() to
declare current_route as an accepted variable for both theme hooks --
same class of bug as the earlier can_view_history fix: an undeclared
variable passed via the render array is silently dropped rather than
reaching Twig.

Dashboard: replaced the old "solde par compte / par client" tables
(computed client-side from a full unwindowed JSON:API fetch of every
node -- the same performance problem the /lignes sliding window was
built to avoid, just not yet felt at 1500+ lines) with a single
aggregate endpoint (DashboardStatsController, plain SQL GROUP BY) and
five chart panels: CA par année, solde par compte (diverging,
red/green), solde par compte trend (small multiples per compte),
répartition par type, top clients par CA. No charting library --
small dependency-free div/CSS bar charts (HBarChart/ColumnChart/
MiniTrend components in dashboard.js), consistent with this project
vendoring its own JS.

Two data-correctness fixes along the way: (1) the historical stray
mistyped dates (0213-06-15, 2015-08-29 -- preserved as-is per this
module's policy) needed excluding from per-year buckets without
excluding their money from all-time totals, so the filtering happens
per-output-field in PHP rather than as a blanket SQL date range. (2)
PHP silently casts numeric-looking array keys ("2021") to actual
integers, so array_keys() on a year-keyed map produces a mix of ints
and strings -- json_encode emits the int ones as bare JSON numbers in
a list (unlike object keys, which JSON always stringifies), which
broke the frontend's annees[i].slice(2) trend-card labels. Fixed with
an explicit array_map('strval', ...).

Verified: bar widths/colors match the underlying data exactly (e.g.
EXT.'s red bar is proportionally sized against Maud's green one per
their actual solde ratio), all 8 trend cards render correct year
labels, and both nav links correctly highlight on their own page.
2026-09-04 23:30:40 +02:00
bachir 2947a542da Surface Drupal messages as small top-right toasts, auto-dismissed
Two problems: (1) the node form's modal AJAX submit handler
(figli_compta_ledger_node_form_ajax_submit) closed the dialog on
success without ever surfacing whatever messenger() had queued during
save() -- e.g. NodeForm's own "Ligne comptable X has been updated."
Since the /lignes table only ever refreshes via AJAX afterwards, never
a full page load, that message was silently lost instead of just
delayed. Now forwarded via MessageCommand before closing the modal.
(2) Gin's default message styling is a full-size flash banner meant
for a single admin form, in-flow (pushing the page down) and far too
large for frequent background feedback on this dashboard.

admin-chrome.css repositions [data-drupal-messages] /
[data-drupal-messages-fallback] to fixed top-right (out of document
flow, so nothing shifts) and shrinks text/padding/dismiss-button size
drastically while keeping them legible. admin-chrome.js auto-dismisses
non-error messages after 2s via a MutationObserver (Drupal.Message.add()
just appends a DOM node, it never re-fires attachBehaviors(), so
behaviors/once() would miss anything added after the initial page
load); error messages are left alone, requiring the existing manual
click on Gin's own dismiss button. Both attached globally via the
existing admin_chrome library (already loaded on every page for the
phantom-top-bar fix), consistent with that precedent.

Verified: a real full-form save on a clean node returns exactly
{message (status, "has been updated"), closeDialog} in that order: a
répartition-inconsistent node still correctly blocks the full form
(unaffected, skip_validation is never used here); a synthetic
status/warning/error trio confirms fixed positioning, no layout
shift, ~40% smaller text, and status/warning auto-hiding while error
persists until manually dismissed.
2026-09-04 22:17:52 +02:00
bachir 3ec894c167 Don't block type changes on a pre-existing répartition écart
updateType() only ever touches field_type_ligne (and possibly
field_entree_liee) -- never montant_ht or field_repartition -- so it
can't introduce a new répartition mismatch, only leave an existing
one (preserved from historical data, per the module's own docblock)
untouched. Wrap the save in the same skip_validation state flag the
migration scripts use, scoped tightly with try/finally so it always
clears even if save() throws for an unrelated reason.

Verified: changing the type of a line with a known -0.02€ écart now
succeeds, the écart is unchanged afterward (montant_ht and the
répartition sum both identical to before), and the state flag reads
back unset once the request completes.
2026-09-04 22:02:58 +02:00
bachir f260e8a605 Inline-edit the type badge directly in the table
Clicking a row's type badge swaps it for a native <select> in place;
picking a new value POSTs to a new endpoint
(LedgerActionsController::updateType) instead of opening the full
edit modal for this one field.

The endpoint goes through the normal node save() lifecycle, so
figli_compta_ledger_node_presave() still forces a proper revision and
still enforces the répartition invariant -- nothing here bypasses
that. It also clears a stale field_entree_liee when the new type is
no longer linkable (versement/achat/hébergement), mirroring the full
form's #states visibility rule. CSRF-protected via core's own
/session/token, scoped to CsrfRequestHeaderAccessCheck::TOKEN_KEY to
match what that endpoint actually generates. Verified end-to-end via
the real click flow: correct revision (user + timestamp), correct
optimistic UI update, correct field_entree_liee clearing, and 400/403
on invalid type / missing CSRF respectively.
2026-09-04 21:57:33 +02:00
bachir a45d55cb81 Fix footer freezing after Année filter / Aller à / retour à Toutes
v-if="loading" swaps out <div ref="tableWrap"> for a brand new DOM
node every time loading toggles true -> false. mounted() only attaches
the scroll listener once, to whichever wrap existed at mount time --
enterYearMode(), exitYearMode(), and jumpToYear() all trigger that
swap, silently orphaning the listener on the old (now-detached) node.
After any of those three, scrolling stopped calling checkEdges()/
detectCurrentYear() at all, freezing the footer's year totals.

ensureScrollListener() re-attaches (idempotently, via a dataset flag)
after every such transition. Verified: the wrap element does change
identity across jumpToYear(), and the new element picks up the
listener and fetches totals for the newly-visible year correctly.
2026-09-04 21:39:19 +02:00
bachir 53c4e58e72 Add "Aller à" year-jump shortcut next to the filters
Distinct from the existing "Année" filter (which restricts the view
to just that year): this re-centers the sliding window on 1 January
of the chosen year and scrolls there, while staying in the continuous
view -- scrolling still extends the window normally in both
directions, so overlaps between years stay visible, matching the
earlier stated preference for a continuous view over a filtered one.

Resets to the placeholder immediately after firing since it's a
one-shot action, not a sticky filter. Guards against racing the
"Année" watcher's own exitYearMode() when a year filter is already
active when the jump is triggered.
2026-09-04 21:33:59 +02:00
bachir 5afc5905d7 Include ouverture lines in the footer's year totals
totauxAnnee() excluded ouverture lines, so the footer showed net
movement for the year rather than the actual account balance -- it
never matched the "clôture calculée" (ouverture + movement) that the
ouverture/clôture reconciliation badge's tooltip cites for the same
year, which was confusing side by side. Verified per compte: ouverture
2023 minus the reconciliation écart now equals the footer's 2022
total exactly, to the cent, for every compte.
2026-09-04 21:25:53 +02:00
bachir 7eabcc6bd8 Flag ouverture/clôture discrepancies between consecutive years
Each year's ouverture (opening balance) should match the previous
year's calculated closing balance (that year's own ouverture + every
movement dated within it). The historical spreadsheets carry real
gaps here that were preserved as-is during migration -- this surfaces
them per compte, on the ouverture row(s) they affect, the same way
the per-ligne écart column already does, rather than correcting them.

New LedgerStatsController::reconciliationOuverture() endpoint (a
single grouped SQL aggregate over ouverture vs. non-ouverture lines
per year/compte, not per-node loading) backs a small badge shown only
on the affected ouverture row(s), scoped to whichever compte that
specific row's répartition touches.
2026-09-04 21:21:57 +02:00
bachir 831c0a0e62 Fix runaway scroll-loading loop on the grand livre
Two compounding issues in the sliding-window edge loading:

- loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
  even when a fetch came back empty (e.g. extending into future dates
  with no data yet). Since nothing about the table's height or scroll
  position changes when 0 rows come back, checkEdges() stayed
  satisfied and the very next scroll/layout tick fired the same load
  again, drifting the window further out forever. Now bounded by
  MIN_LOADABLE_DATE/MAX_LOADABLE_DATE.
- loadOlder() and loadNewer() had independent in-flight guards and
  could run concurrently, racing on the same rows/windowStart/windowEnd
  state -- a scroll-position compensation write inside one (from
  trimming the far end) fires a real scroll event that can kick off
  the other direction mid-flight. Now serialized behind a shared
  loadingWindow lock.
2026-09-04 21:12:20 +02:00
bachir 9e489b48e6 Fix footer solde colors not showing red/green
The generic totals-row rule (tr.figli-totals-row td) has higher
specificity than the lone td.figli-solde-crediteur/-debiteur rules, so
its neutral color always won in the footer even though soldeClass()
was already applying the right class there.
2026-09-04 20:58:44 +02:00
bachir b6358a7258 Sliding-window loading for the grand livre + year-scoped sticky footer
With 5+ years of migrated history, loading every ligne up front took
~40s. The table now only ever holds a date-range window (~18 months
around today by default), extended by 6 months when scrolling near
either edge and trimmed from the far end past a 30-month cap. The
totals footer and "Année" filter can't be answered from a partial
window, so they're backed by two new small endpoints
(LedgerStatsController) instead: per-compte totals for whichever year
is currently scrolled into view, and the distinct list of years with
data.
2026-09-04 16:32:42 +02:00
bachirandClaude Sonnet 5 059d31f63d Stop the table flickering away on every edit save
load() unconditionally set loading = true, which unmounts the whole
v-else table (the "Chargement…" paragraph takes its place) every time
-- including the background refresh after saving a line, which is
exactly the scroll-resetting, full-table-disappears flicker Vue's
keyed diffing is supposed to prevent. The post-edit refresh
(dialog:afterclose) now calls load(false): rows get reassigned in
place, and Vue patches only what changed.

Verified with a real click (not synthetic JS events, which don't
reliably trigger Drupal's mousedown-bound AJAX submit in headless
testing): same .figli-table-wrap DOM node before/after, "Chargement…"
never appeared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:10:55 +02:00
bachirandClaude Sonnet 5 4636fc3522 Color and bold Montant HT to distinguish entrées from sorties
Single anchor per row -- Montant TTC and the 8 compte columns stay
neutral, so the table doesn't turn into a red/green garland. No 0.5€
threshold like soldeClass (used for aggregate totals): individual
lines are often small (a -1.07€ OVH renewal shouldn't read as
"neutral"), any nonzero sign gets colored.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:25:00 +02:00
bachirandClaude Sonnet 5 994c7c7aad Sync admin UI config: compact Gin layout density, hidden nav logo
These had been changed interactively in the admin UI (Gin layout
density -> small, Navigation logo -> hidden) but the exported config
in config/sync had drifted out of sync with the active config -- this
just catches it up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:10:07 +02:00
bachirandClaude Sonnet 5 1f357e5f1c Hide the phantom empty top bar, keep only the Gin sidebar
Core Navigation's top bar only shows itself when its tools/context/
actions regions are non-empty (`:has(:not(:empty))`). With no blocks
placed in those regions, they still contain whitespace text nodes from
Twig's loop scaffolding, which defeats that check -- the bar rendered
anyway, empty, and pushed page content down to make room for it.

Attached globally via hook_page_attachments() rather than scoped to
/lignes or /dashboard, since the Navigation chrome renders on every
authenticated page, not just our custom routes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:09:02 +02:00
bachirandClaude Sonnet 5 e7796754ab Remove unused administrator/content_editor roles
Leftover Standard-recipe scaffolding: "administrator" duplicated root's
is_admin bypass, "content_editor" had generic permissions unrelated to
this site's content model (a "tags" vocabulary that doesn't exist here,
nothing for ligne_comptable). Neither was assigned to any user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:08:50 +02:00
bachirandClaude Sonnet 5 a86875c03c Add admin/editeur/user roles for the associates
root (renamed from the default admin/admin account) keeps full,
unrestricted access outside any role system. These three cover the
actual associates:

- user: read-only, 'access content' only -- can browse /lignes and
  /dashboard, nothing else.
- editeur: 'access content' + create/edit any/delete any
  ligne_comptable content -- can enter and correct accounting lines,
  no site configuration.
- admin: identical permission set to editeur for now (per instruction,
  actual config permissions to be scoped later).

Left the pre-existing "administrator" (is_admin bypass -- too broad)
and "content_editor" (generic Standard-recipe scaffolding, permissions
unrelated to ligne_comptable) roles untouched but unused; worth
pruning later if nothing ends up using them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:01:07 +02:00
bachirandClaude Sonnet 5 f8ae2d6a9e Lock the site down to authenticated users only
All the ledger data is confidential (real client names, amounts,
associate balances) -- revoke 'access content' from the anonymous
role so nodes, taxonomy terms, and JSON:API all deny anonymous reads
(verified: JSON:API returns an empty data[] + "omitted" notice instead
of the records, /lignes and /dashboard 403).

Set the site's 403 page to /user/login: anonymous visitors hitting "/"
(the front page is /lignes) land on the login form instead of an
access-denied page; authenticated users still land on /lignes as
before since they still hold 'access content' via the authenticated
role.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:57:18 +02:00
bachirandClaude Sonnet 5 8c63c19619 Add per-row edit button and paired column highlight to /lignes
Pencil icon opens the existing node edit form in the same AJAX modal
as "+ Ajouter une ligne" -- no new form logic, reuses the form_alter
validation/close-on-save already in place for the add form.

Column highlight pairs with the existing row hover (from Gin's global
table CSS) to form a crosshair. Column position is computed logically
(accounting for colspan) rather than via DOM cellIndex, since the
totals row's label cell spans 4 columns and would otherwise misalign
every column after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:40:21 +02:00
bachirandClaude Sonnet 5 a5af59a349 Stop tracking web/sites/default/settings.php
Committed in git since the initial commit -- contains the DB credentials
and the Drupal hash_salt in clear text, already pushed to gitea and prod.
Keeping the file locally (still needed for the site to run) but no longer
tracked, so future secret rotations don't get recommitted. The already-
exposed history is left untouched for now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:40:11 +02:00
82 changed files with 9929 additions and 1297 deletions
+2
View File
@@ -5,4 +5,6 @@
/web/libraries/
/web/sites/*/files/
/web/sites/*/settings.local.php
/web/sites/*/settings.php
/private/
.env
+24
View File
@@ -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:
config:
- 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_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_ttc
- 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_tva
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
module:
@@ -36,37 +43,79 @@ content:
size: 60
placeholder: ''
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:
type: datetime_default
weight: 0
region: content
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:
type: number
weight: 3
weight: 5
region: content
settings:
placeholder: ''
third_party_settings: { }
field_montant_ttc:
type: number
weight: 4
weight: 8
region: content
settings:
placeholder: ''
third_party_settings: { }
field_notes:
type: string_textarea
weight: 6
weight: 11
region: content
settings:
rows: 5
placeholder: ''
third_party_settings: { }
field_numero_facture:
type: string_textfield
weight: 3
region: content
settings:
size: 60
placeholder: ''
third_party_settings: { }
field_repartition:
type: paragraphs
weight: 5
weight: 10
region: content
settings:
title: Répartition
@@ -82,6 +131,13 @@ content:
collapse_edit_all: collapse_edit_all
duplicate: duplicate
third_party_settings: { }
field_tva:
type: number
weight: 9
region: content
settings:
placeholder: ''
third_party_settings: { }
field_type_ligne:
type: options_select
weight: 1
@@ -120,5 +176,6 @@ content:
placeholder: ''
third_party_settings: { }
hidden:
field_ecart: true
promote: true
sticky: true
@@ -4,11 +4,18 @@ status: true
dependencies:
config:
- 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_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_ttc
- 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_tva
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
module:
@@ -29,6 +36,27 @@ content:
third_party_settings: { }
weight: 2
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:
type: datetime_default
label: above
@@ -38,6 +66,33 @@ content:
third_party_settings: { }
weight: 0
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:
type: number_decimal
label: above
@@ -47,7 +102,7 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 3
weight: 5
region: content
field_montant_ttc:
type: number_decimal
@@ -58,14 +113,23 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 4
weight: 8
region: content
field_notes:
type: basic_string
label: above
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
field_repartition:
type: entity_reference_revisions_entity_view
@@ -74,7 +138,18 @@ content:
view_mode: default
link: ''
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
field_type_ligne:
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
@@ -32,6 +32,15 @@ settings:
-
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: ''
module: options
locked: false
+1 -1
View File
@@ -20,7 +20,7 @@ secondary_toolbar_frontend: true
high_contrast_mode: false
accent_color: ''
focus_color: ''
layout_density: default
layout_density: small
show_description_toggle: false
show_user_theme_settings: false
sticky_action_buttons: false
+1 -1
View File
@@ -1,7 +1,7 @@
_core:
default_config_hash: FeJ38-AShWZUh_NwJprQueefcE06zSnUa3cw1lOdjTY
logo:
provider: default
provider: hide
path: ''
max:
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
+1 -1
View File
@@ -6,7 +6,7 @@ name: 'Figures Libres - Compta'
mail: admin@example.com
slogan: ''
page:
403: ''
403: /user/login
404: ''
front: /lignes
admin_compact_mode: false
+9
View File
@@ -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
+22
View File
@@ -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'
-11
View File
@@ -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: { }
-2
View File
@@ -6,7 +6,6 @@ dependencies:
- filter.format.restricted_html
module:
- filter
- system
_core:
default_config_hash: Tus1ZTNXKIOnrennR1hnTbc7wUPBXHTUm5UdCpEhJSk
id: anonymous
@@ -14,5 +13,4 @@ label: 'Anonymous user'
weight: 0
is_admin: false
permissions:
- 'access content'
- 'use text format restricted_html'
-2
View File
@@ -7,7 +7,6 @@ dependencies:
module:
- file
- filter
- system
_core:
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
id: authenticated
@@ -15,6 +14,5 @@ label: 'Authenticated user'
weight: 1
is_admin: false
permissions:
- 'access content'
- 'delete own files'
- 'use text format basic_html'
-35
View File
@@ -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'
+22
View File
@@ -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'
+18
View File
@@ -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-error: #c9312b;
--figli-positive: #1a7f37;
--figli-warning: #b8860b;
font-family: Inter, -apple-system, sans-serif;
max-width: 1200px;
@@ -23,54 +24,22 @@ html.gin--dark-mode #figli-dashboard-app {
--figli-border: #3d3e42;
--figli-error: #ff6b6b;
--figli-positive: #4ade80;
--figli-warning: #f0b429;
}
#figli-dashboard-app h2 {
font-size: 1.25rem;
font-size: 1.1rem;
font-weight: 600;
margin: 2rem 0 0.75rem;
margin: 0 0 0.25rem;
color: var(--figli-text);
}
#figli-dashboard-app .figli-note {
color: var(--figli-text-light);
font-size: 0.875rem;
font-size: 0.8rem;
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 {
background: #fde8e8;
border: 1px solid #f4a3a3;
@@ -78,3 +47,467 @@ html.gin--dark-mode #figli-dashboard-app {
padding: 0.75rem 1rem;
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-error: #c9312b;
--figli-positive: #1a7f37;
--figli-warning: #b8860b;
--figli-col-hover: rgba(15, 23, 42, 0.05);
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);
}
@@ -23,6 +29,8 @@ html.gin--dark-mode #figli-home-app {
--figli-border: #3d3e42;
--figli-error: #ff6b6b;
--figli-positive: #4ade80;
--figli-warning: #f0b429;
--figli-col-hover: rgba(255, 255, 255, 0.07);
}
#figli-home-app .figli-toolbar {
@@ -56,6 +64,91 @@ html.gin--dark-mode #figli-home-app {
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 {
flex-direction: row !important;
align-items: center;
@@ -70,24 +163,57 @@ html.gin--dark-mode #figli-home-app {
}
#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-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;
}
/* 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 {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
background: var(--figli-bg);
color: var(--figli-text);
font-size: 0.8rem;
white-space: nowrap;
}
#figli-home-app th,
#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;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--figli-border);
@@ -112,10 +238,16 @@ html.gin--dark-mode #figli-home-app {
font-variant-numeric: tabular-nums;
}
#figli-home-app th:nth-child(6),
#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;
min-width: 220px;
max-width: 340px;
}
#figli-home-app tr.figli-group-row td {
@@ -130,6 +262,10 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app tfoot {
position: sticky;
bottom: 0;
/* Higher than thead (2) and the sticky group-row headers (1) -- without
this, tfoot had no explicit z-index (auto), so a group-row header
scrolling underneath it painted on top instead of behind. */
z-index: 3;
}
#figli-home-app tr.figli-totals-row td {
@@ -140,30 +276,99 @@ html.gin--dark-mode #figli-home-app {
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 {
color: var(--figli-positive);
}
#figli-home-app td.figli-solde-debiteur {
color: var(--figli-error);
}
/* Error rows: a thin red outline around the row, not a background fill --
easier to read, doesn't fight with dark mode. */
#figli-home-app tr.figli-error-row td {
border-top: 1px solid var(--figli-error);
border-bottom: 1px solid var(--figli-error);
/* Footer totals row sets its own `color` (higher specificity: tr.class +
td beats a lone td.class) which otherwise overrides the solde colors
above -- repeat them scoped to the row so red/green still wins there. */
#figli-home-app tr.figli-totals-row td.figli-solde-crediteur {
color: var(--figli-positive);
}
#figli-home-app tr.figli-error-row td:first-child {
border-left: 1px solid var(--figli-error);
}
#figli-home-app tr.figli-error-row td:last-child {
border-right: 1px solid var(--figli-error);
#figli-home-app tr.figli-totals-row td.figli-solde-debiteur {
color: var(--figli-error);
}
/* Single colored + bold anchor per row (Montant HT only) so entrées vs
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 {
color: var(--figli-error);
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 {
display: inline-block;
@@ -179,14 +384,370 @@ html.gin--dark-mode #figli-home-app {
#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-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); }
/* 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 {
color: var(--figli-text-light);
font-weight: 400;
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 {
background: #fde8e8;
border: 1px solid #f4a3a3;
@@ -194,3 +755,14 @@ html.gin--dark-mode #figli-home-app {
padding: 0.75rem 1rem;
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() {
_figli_compta_ledger_create_vocabulary('compte', 'Compte', _figli_compta_ledger_comptes());
_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_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) {
@@ -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)) {
FieldStorageConfig::create([
'field_name' => $field_name,
'entity_type' => $entity_type,
'type' => 'entity_reference',
'cardinality' => $cardinality,
'settings' => ['target_type' => $target_type],
])->save();
}
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([
'field_name' => $field_name,
'entity_type' => $entity_type,
@@ -105,7 +144,7 @@ function _figli_entity_ref_field($entity_type, $bundle, $field_name, $label, $ta
'required' => $required,
'settings' => [
'handler' => 'default:' . $target_type,
'handler_settings' => ['target_bundles' => [$target_bundle => $target_bundle]],
'handler_settings' => $handler_settings,
],
])->save();
}
@@ -188,15 +227,53 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
'hebergement' => 'Hébergement',
'autre' => 'Autre',
'ouverture' => "Ligne d'ouverture",
'sous_traitant' => 'Sous-traitant',
'salaire_stage' => 'Salaire / stage',
'charges_local_pro' => 'Charges local pro',
],
], TRUE);
_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);
// 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]);
// 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');
// 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');
if (!EntityFormDisplay::load('node.ligne_comptable.default')) {
@@ -209,10 +286,36 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 5, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 6])
->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
// Cotisation diffuseur URSSAF (1,1%) -- only "Entrée client" lines
// go through a client devis, the only place this cotisation is
// 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();
}
@@ -226,10 +329,647 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 5])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 6])
->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5])
->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();
}
}
/**
* Adds field_numero_facture ( 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:
- core/drupal
- 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
parent: system.admin
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
* Enforces the one invariant that explained most of the errors found in the
* historical spreadsheets: sum(répartition.montant) must equal montant_ht.
* Enforces two invariants for ligne_comptable: sum(répartition.montant)
* must equal montant_ht (explained most of the errors found in the
* historical spreadsheets), and every save must create a revision with no
* way to opt out, for an audit trail of who changed the shared ledger and
* when.
*/
use Drupal\node\NodeInterface;
@@ -11,6 +14,7 @@ use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\MessageCommand;
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)) {
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';
// 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();
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
$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
* 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) {
$response = new AjaxResponse();
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']);
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
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());
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
* source spreadsheets' raw data, including known répartition mismatches --
* those get surfaced as visible inconsistencies in the dashboard instead of
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state
* flag around such a bulk import to bypass this check; new lines entered by
* associates through the form are never exempted.
* being silently fixed. Programmatic saves that need to bypass this check
* (bulk imports, the inline-edit endpoints, the bank statement import) wrap
* 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) {
if ($node->bundle() !== 'ligne_comptable') {
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;
}
$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;
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;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
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);
// 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) {
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.",
@@ -142,16 +513,73 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [
'figli_compta_home' => [
'variables' => [],
'variables' => ['can_view_history' => FALSE, 'can_import_releve' => FALSE, 'current_route' => NULL],
'template' => 'figli-compta-home',
],
'figli_compta_dashboard' => [
'variables' => [],
'variables' => ['current_route' => NULL],
'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().
*/
@@ -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'
_title: 'Grand livre - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.dashboard:
path: '/dashboard'
@@ -12,4 +12,119 @@ figli_compta_ledger.dashboard:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
_title: 'Tableau de bord - SAS Figures Libres'
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
* Aggregate dashboard: solde par compte / solde par client, computed
* client-side from JSON:API. The line-by-line spreadsheet view is the
* site's home page (home.js), not this one.
* Dashboard: charts and aggregate totals (chiffre d'affaires par année,
* répartition par type, top clients), computed server-side
* (DashboardStatsController -- plain SQL GROUP BY, not Entity API) and
* rendered here as small dependency-free div/CSS bar charts. No charting
* library: this project vendors its own JS (see js/vendor/), and a
* handful of bar/line charts don't warrant pulling one in. Solde par
* compte lives on its own page (js/dashboard-repartition.js).
*/
(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 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() {
// sort by nid: without an explicit, unique sort key, offset pagination
// can silently duplicate or skip rows across pages.
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=drupal_internal__nid';
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 };
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();
}
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
// Horizontal bar chart -- one row per item, label left, proportional
// bar, value right. Switches to a zero-centered "diverging" layout
// 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) {
if (!map.has(key)) map.set(key, { entrees: 0, sorties: 0 });
const row = map.get(key);
if (montant >= 0) row.entrees += montant;
else row.sorties += montant;
}
function computeAggregations(data, includedMap) {
const parComptes = new Map();
const parClients = new Map();
for (const node of data) {
const rels = node.relationships || {};
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
const clientName = clientTerm ? clientTerm.attributes.name : '(sans client)';
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)';
addTo(parComptes, compteName, montant);
addTo(parClients, clientName, montant);
}
}
return { parComptes, parClients };
}
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 }
);
}
// Vertical bar chart -- for a short time series (CA par année): a
// handful of columns read left-to-right as a trend more naturally than
// horizontal bars would.
const VBarChart = {
props: {
items: { type: Array, required: true },
formatValue: { type: Function, required: true },
},
computed: {
max() {
return Math.max(1, ...this.items.map((i) => i.value));
},
},
methods: {
barHeight(item) {
return Math.max(2, (item.value / this.max) * 100) + '%';
},
},
template:
'<div class="figli-vbar-chart">' +
'<div class="figli-vbar-col" v-for="item in items" :key="item.label">' +
'<div class="figli-vbar-value">{{ formatValue(item.value) }}</div>' +
'<div class="figli-vbar-track"><div class="figli-vbar-fill" :style="{height: barHeight(item)}"></div></div>' +
'<div class="figli-vbar-label">{{ item.label }}</div>' +
'</div>' +
'</div>',
};
const App = {
components: { HBarChart, ColumnChart: VBarChart },
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: {
formatEur(v) {
return EUR.format(v);
},
rowClass(solde) {
if (solde > 0.5) return 'positive';
if (solde < -0.5) return 'negative';
return '';
formatEurRound(v) {
return EUR_ROUND.format(v);
},
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() {
this.loading = true;
this.error = null;
try {
const { data, includedMap } = await fetchAllLignes();
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) },
];
this.stats = await fetchStats();
} catch (err) {
this.error = err.message;
} 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() {
return [
'#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' => [
'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() {
return [
'#theme' => 'figli_compta_dashboard',
'#current_route' => 'figli_compta_ledger.dashboard',
'#attached' => [
'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)]);
}
}
@@ -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'],
);
}
}
@@ -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;
}
}
@@ -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 %}
@@ -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).
{% 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 v-for="table in tables" :key="table.title">
<h2>{{ table.title }}</h2>
<p class="figli-note" v-if="table.note">{{ table.note }}</p>
<table>
<thead><tr><th></th><th>Entrées (+)</th><th>Sorties (-)</th><th>Solde</th></tr></thead>
<tbody>
<tr v-for="row in table.rows" :key="row.name" :class="rowClass(row.solde)">
<td>{{ row.name }}</td>
<td class="amount">{{ formatEur(row.entrees) }}</td>
<td class="amount">{{ formatEur(row.sorties) }}</td>
<td class="amount">{{ formatEur(row.solde) }}</td>
</tr>
<tr class="total">
<td>TOTAL</td>
<td class="amount">{{ formatEur(table.totals.entrees) }}</td>
<td class="amount">{{ formatEur(table.totals.sorties) }}</td>
<td class="amount">{{ formatEur(table.totals.solde) }}</td>
</tr>
</tbody>
</table>
<div class="figli-summary-row">
<div class="figli-summary-card">
<div class="figli-summary-label">Chiffre d'affaires total</div>
<div class="figli-summary-value">{{ formatEurRound(totalCA) }}</div>
</div>
<div class="figli-summary-card" v-if="caAnneeEnCours">
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
</div>
</div>
<section class="figli-chart-section">
<h2>Chiffre d'affaires par année</h2>
<p class="figli-note">Total des entrées client (montant HT) facturées chaque année.</p>
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
</section>
<section class="figli-chart-section">
<h2>Répartition de l'activité par type</h2>
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p>
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<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>
</template>
</div>
@@ -1,40 +1,94 @@
{#
Dashboard shell: Drupal renders the page (nav, auth, permissions).
dashboard.js (Vue 3) fetches JSON:API and renders a spreadsheet-like table
of every ligne comptable, with filters and month/year grouping, client-side.
"Ajouter une ligne" opens the real Drupal node form in a modal
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
home.js (Vue 3) renders a spreadsheet-like table of every ligne comptable,
with filters and month/year grouping applied server-side (see
LedgerRowsController). "Ajouter une ligne" opens the real Drupal node form
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 %}
<div id="figli-home-app">
<div class="figli-toolbar">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
<label>Compte
<select v-model="filterCompte">
<option value="">Tous</option>
<option v-for="c in allComptes" :key="c" :value="c">{{ c }}</option>
</select>
<span class="figli-filter-row">
<div class="figli-multiselect">
<div class="figli-multiselect-trigger">{{ filterCompte.length ? filterCompte.join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
<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>Client
<select v-model="filterClient">
<option value="">Tous</option>
<option v-for="c in allClients" :key="c" :value="c">{{ c }}</option>
</select>
<span class="figli-filter-row">
<input type="text" v-model.lazy="filterClient" list="figli-client-datalist" placeholder="Tous" autocomplete="off" class="figli-client-input" />
<datalist id="figli-client-datalist">
<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>Type
<select v-model="filterType">
<option value="">Tous</option>
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
<span class="figli-filter-row">
<div class="figli-multiselect">
<div class="figli-multiselect-trigger">{{ filterType.length ? filterType.map(typeLabel).join(', ') : 'Tous' }} <span class="figli-multiselect-arrow">▾</span></div>
<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>Année
<span class="figli-filter-row">
<select v-model="filterYear">
<option value="">Toutes</option>
<option v-for="y in allYears" :key="y" :value="y">{{ y }}</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>
</label>
@@ -50,52 +104,278 @@
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
</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>
<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-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
<div v-else class="figli-table-wrap">
<table>
<div v-else ref="tableWrap" class="figli-table-wrap">
<table ref="tableEl" @mouseover="onCellHover" @mouseleave="clearColHover">
<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>Signalement</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 class="amount">Écart</th>
</tr>
</thead>
<tbody>
<template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row">
<td :colspan="6 + allComptes.length + 1">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
<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>
<tr v-else :class="{'figli-error-row': item.hasError}">
<td>{{ item.date }}</td>
<td>{{ item.client || '—' }}</td>
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
<td class="figli-libelle">{{ item.libelle }}</td>
<td class="amount">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
<template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
<td :colspan="7 + allComptes.length + 5">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr>
<tr v-else :class="{'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
<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>
<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 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>
</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>
<tfoot>
<tr class="figli-totals-row">
<td colspan="4">Solde (créditeur / débiteur) — {{ filteredRows.length }} lignes filtrées</td>
<td class="amount">{{ formatEur(footerTotals.montant_ht) }}</td>
<td class="amount">{{ formatEur(footerTotals.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="soldeClass(footerTotals.parCompte[c])">{{ formatEur(footerTotals.parCompte[c]) }}</td>
<td class="amount" :class="soldeClass(footerTotals.ecart)">{{ formatEur(footerTotals.ecart) }}</td>
<td colspan="7">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_import_releve %}
<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>
</tfoot>
</table>
</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>
{% endverbatim %}
@@ -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>
-922
View File
@@ -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';
}