From f3ff8f5f74f11c2672afa86df02eb6b5ff94ff7b Mon Sep 17 00:00:00 2001 From: REDSTARS Date: Mon, 2 Nov 2026 20:09:18 +0100 Subject: [PATCH] chore: initialisation du projet ul_tresorerie et ajout du sous-module documentation Nextcloud --- .gitignore | 26 + .gitmodules | 3 + README.md | 82 + documentation | 1 + ul_tresorerie/appinfo/info.xml | 27 + ul_tresorerie/appinfo/routes.php | 21 + ul_tresorerie/css/app_v2.css | 1823 ++++++++++++ ul_tresorerie/css/style.css | 1823 ++++++++++++ ul_tresorerie/img/app.svg | 187 ++ ul_tresorerie/img/banniere_ul.png | Bin 0 -> 80741 bytes ul_tresorerie/img/cgt_logo.svg | 187 ++ ul_tresorerie/js/app_v2.js | 2617 +++++++++++++++++ ul_tresorerie/js/main.js | 2617 +++++++++++++++++ ul_tresorerie/lib/AppInfo/Application.php | 26 + .../lib/Controller/ApiController.php | 450 +++ .../lib/Controller/PageController.php | 41 + .../lib/Migration/Version1000Date20261031.php | 209 ++ ul_tresorerie/lib/Service/AspImportParser.php | 110 + .../lib/Service/DefaultTemplates.php | 111 + .../lib/Service/PermissionService.php | 209 ++ ul_tresorerie/lib/Service/TreasuryService.php | 832 ++++++ ul_tresorerie/templates/main.php | 847 ++++++ 22 files changed, 12249 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 160000 documentation create mode 100644 ul_tresorerie/appinfo/info.xml create mode 100644 ul_tresorerie/appinfo/routes.php create mode 100644 ul_tresorerie/css/app_v2.css create mode 100644 ul_tresorerie/css/style.css create mode 100644 ul_tresorerie/img/app.svg create mode 100644 ul_tresorerie/img/banniere_ul.png create mode 100644 ul_tresorerie/img/cgt_logo.svg create mode 100644 ul_tresorerie/js/app_v2.js create mode 100644 ul_tresorerie/js/main.js create mode 100644 ul_tresorerie/lib/AppInfo/Application.php create mode 100644 ul_tresorerie/lib/Controller/ApiController.php create mode 100644 ul_tresorerie/lib/Controller/PageController.php create mode 100644 ul_tresorerie/lib/Migration/Version1000Date20261031.php create mode 100644 ul_tresorerie/lib/Service/AspImportParser.php create mode 100644 ul_tresorerie/lib/Service/DefaultTemplates.php create mode 100644 ul_tresorerie/lib/Service/PermissionService.php create mode 100644 ul_tresorerie/lib/Service/TreasuryService.php create mode 100644 ul_tresorerie/templates/main.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56ab08d --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Exports CoGétise et fichiers de données sensibles +*.asp +*.csv + +# Archives de packaging +*.zip +*.tar.gz + +# Dépendances et outils de build éventuels +vendor/ +node_modules/ + +# Fichiers système et éditeurs +.DS_Store +Thumbs.db +Desktop.ini +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Logs et fichiers temporaires +*.log +tmp/ +temp/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..78224e6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "documentation"] + path = documentation + url = https://github.com/nextcloud/documentation.git diff --git a/README.md b/README.md new file mode 100644 index 0000000..96e9e66 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Cotisations-Nextcloud — Trésorerie UL CGT (`ul_tresorerie`) + +Application native **Nextcloud** (versions 33 à 35) conçue pour l'**Union Locale CGT de Foix et de la Haute-Ariège** permettant le suivi rapide des cotisations mensuelles des syndiqués isolés (*INDIVIDUELS UL FOIX HAUTE-ARIEGE* / *Multipros*), l'import depuis CoGétise (`.asp`) et le publipostage intégré. + +--- + +## Fonctionnalités principales + +- **Grille annuelle de saisie rapide** : Suivi mois par mois (Janvier à Décembre) des cotisations par adhérent, filtrage en temps réel, édition groupée et totaux mensuels/annuels. +- **Système d'alertes à deux niveaux** : + - **Niveau 1 (Orange)** : Retard modéré (configurable, par défaut 3 mois impayés). + - **Niveau 2 (Rouge)** : Retard critique (configurable, par défaut 6 mois impayés). +- **Import CoGétise (`.asp`)** : Importation et synchronisation des syndiqués depuis un fichier d'export tabulé CoGétise avec détection d'encodage (UTF-8 / Windows-1252) et aperçu des créations/mises à jour. +- **Publipostage & Reçus fiscaux (66 %)** : + - Génération de **reçus fiscaux** (déduction fiscale de 66 % des cotisations syndicales versées sur l'année). + - Lettres de **rappel de mois manquants** et de **rappel annuel**. + - Modèles personnalisables avec variables dynamiques et impression directe. +- **Contrôle d'accès** : Gestion des administrateurs, utilisateurs et groupes Nextcloud autorisés à consulter et modifier la trésorerie. + +--- + +## Structure du dépôt + +```text +Cotisations-Nextcloud/ +├── documentation/ # Sous-module Git officiel Nextcloud Documentation +├── ul_tresorerie/ # Code source de l'application Nextcloud +│ ├── appinfo/ # Métadonnées (info.xml) et routes (routes.php) +│ ├── css/ # Feuilles de style de l'interface +│ ├── img/ # Logos et bannières UL CGT +│ ├── js/ # Logique front-end de l'application +│ ├── lib/ # Contrôleurs, Services et Migrations PHP +│ └── templates/ # Template principal Nextcloud (main.php) +├── .gitignore # Exclusion des exports sensibles (*.asp) et archives (*.zip) +├── .gitmodules # Configuration du sous-module de documentation Nextcloud +└── README.md # Documentation du projet +``` + +--- + +## Gestion de la documentation officielle Nextcloud + +La documentation Nextcloud est gérée directement depuis le dépôt officiel (`https://github.com/nextcloud/documentation.git`) via un **sous-module Git** situé dans le dossier `documentation/`. + +### Cloner le projet avec la documentation +```bash +git clone --recurse-submodules +``` + +Si le dépôt a déjà été cloné sans l'option `--recurse-submodules` : +```bash +git submodule update --init --recursive +``` + +### Mettre à jour la documentation depuis le dépôt officiel Nextcloud +```bash +git submodule update --remote documentation +``` + +--- + +## Installation de l'application dans Nextcloud + +### Prérequis +- **Nextcloud** : 33 à 35 +- **PHP** : 8.3 minimum + +### Déploiement +1. Copier ou lier le dossier `ul_tresorerie` dans le répertoire `custom_apps/` (ou `apps/`) de votre instance Nextcloud : + ```bash + cp -r ul_tresorerie /var/www/nextcloud/custom_apps/ + chown -R www-data:www-data /var/www/nextcloud/custom_apps/ul_tresorerie + ``` +2. Activer l'application via `occ` ou depuis l'interface d'administration Nextcloud : + ```bash + sudo -u www-data php /var/www/nextcloud/occ app:enable ul_tresorerie + ``` + +--- + +## Licence +Distribué sous licence **AGPL-3.0-or-later**. diff --git a/documentation b/documentation new file mode 160000 index 0000000..d89f524 --- /dev/null +++ b/documentation @@ -0,0 +1 @@ +Subproject commit d89f524273c99edd5e1034422fa33228fde47646 diff --git a/ul_tresorerie/appinfo/info.xml b/ul_tresorerie/appinfo/info.xml new file mode 100644 index 0000000..b0f0a82 --- /dev/null +++ b/ul_tresorerie/appinfo/info.xml @@ -0,0 +1,27 @@ + + + ul_tresorerie + Trésorerie UL CGT + Suivi des cotisations mensuelles et publipostage — UL CGT Foix Haute-Ariège + + 1.0.0 + AGPL-3.0-or-later + Union Locale CGT de Foix et de la Haute-Ariège + ULTresorerie + organization + office + https://github.com/ul-cgt-foix/ul_tresorerie/issues + + + + + + + Trésorerie UL CGT + ul_tresorerie.page.index + app.svg + 10 + + + diff --git a/ul_tresorerie/appinfo/routes.php b/ul_tresorerie/appinfo/routes.php new file mode 100644 index 0000000..b4de9b4 --- /dev/null +++ b/ul_tresorerie/appinfo/routes.php @@ -0,0 +1,21 @@ + [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ['name' => 'api#getState', 'url' => '/api/state', 'verb' => 'GET'], + ['name' => 'api#createMember', 'url' => '/api/members', 'verb' => 'POST'], + ['name' => 'api#updateMember', 'url' => '/api/members/{id}', 'verb' => 'PUT'], + ['name' => 'api#deleteMember', 'url' => '/api/members/{id}', 'verb' => 'DELETE'], + ['name' => 'api#importAsp', 'url' => '/api/import', 'verb' => 'POST'], + ['name' => 'api#saveDue', 'url' => '/api/dues', 'verb' => 'POST'], + ['name' => 'api#deleteDue', 'url' => '/api/dues/delete', 'verb' => 'POST'], + ['name' => 'api#saveTemplate', 'url' => '/api/templates/{slug}', 'verb' => 'PUT'], + ['name' => 'api#resetTemplates', 'url' => '/api/templates/reset', 'verb' => 'POST'], + ['name' => 'api#sendEmails', 'url' => '/api/mailing/send', 'verb' => 'POST'], + ['name' => 'api#getPermissions', 'url' => '/api/permissions', 'verb' => 'GET'], + ['name' => 'api#savePermissions', 'url' => '/api/permissions', 'verb' => 'PUT'], + ], +]; diff --git a/ul_tresorerie/css/app_v2.css b/ul_tresorerie/css/app_v2.css new file mode 100644 index 0000000..d1f0a9e --- /dev/null +++ b/ul_tresorerie/css/app_v2.css @@ -0,0 +1,1823 @@ +/* ========================================================================== + UL Trésorerie CGT (OCA\ULTresorerie) — Feuille de style Nextcloud 34 + ========================================================================== */ + +:root { + --ul-cgt-red: #d32f2f; + --ul-cgt-red-dark: #991b1b; + --ul-cgt-red-soft: #fef2f2; + --ul-green-paid: #15803d; + --ul-green-paid-hover: #166534; + --ul-green-soft: #dcfce7; + --ul-orange-alert: #d97706; + --ul-orange-soft: #fffbeb; + --ul-gray-empty: #f3f4f6; + --ul-gray-empty-hover: #e5e7eb; + --ul-border: #d1d5db; + --ul-text-main: #111827; + --ul-text-muted: #4b5563; + --ul-bg-surface: #ffffff; + --ul-bg-app: #f8fafc; +} + +.hidden { + display: none !important; +} + +.ul-print-only { + display: none; +} + +/* Disposition principale dans Nextcloud */ +.ul-app-wrapper { + display: flex; + width: 100%; + height: calc(100vh - 50px); + overflow: hidden; + background-color: var(--ul-bg-app); + color: var(--ul-text-main); + font-family: var(--font-face, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); +} + +/* ========================================================================== + Barre latérale gauche (#app-navigation) + ========================================================================== */ +#app-navigation { + width: 290px; + min-width: 290px; + background: var(--ul-bg-surface); + border-right: 1px solid var(--ul-border); + display: flex; + flex-direction: column; + padding: 14px; + box-sizing: border-box; + overflow-y: auto; + transition: width 0.2s ease, min-width 0.2s ease, padding 0.2s ease; +} + +.ul-app-wrapper.ul-sidebar-collapsed #app-navigation { + width: 0 !important; + min-width: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + border-right: none !important; + overflow: hidden !important; +} + +.ul-brand-header { + display: flex; + flex-direction: column; + background: #ffffff; + border: 1px solid #e2e8f0; + border-top: 4px solid var(--ul-cgt-red); + border-radius: 8px; + margin-bottom: 14px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + flex-shrink: 0; +} + +.ul-brand-banner-box { + padding: 8px 8px 6px 10px; + background: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.ul-brand-banner-img { + max-width: 205px; + width: 100%; + height: auto; + max-height: 46px !important; + object-fit: contain; + display: block; +} + +.ul-sidebar-close-btn { + background: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 6px; + width: 28px; + height: 28px; + min-width: 28px; + font-size: 16px; + font-weight: 800; + line-height: 1; + color: #334155; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.ul-sidebar-close-btn:hover { + background: #fee2e2; + border-color: #fca5a5; + color: var(--ul-cgt-red-dark); +} + +.ul-btn-toggle-sidebar { + background: #f8fafc; + border: 1px solid var(--ul-border); + border-radius: 6px; + width: 34px; + height: 34px; + min-width: 34px; + font-size: 16px; + font-weight: 700; + color: #1e293b; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.ul-btn-toggle-sidebar:hover { + background: #fee2e2; + border-color: #fca5a5; + color: var(--ul-cgt-red-dark); +} + +.ul-brand-subtitle-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + background: linear-gradient(135deg, var(--ul-cgt-red), var(--ul-cgt-red-dark)); + color: #ffffff; + font-size: 11.5px; + font-weight: 700; + padding: 5px 10px; + letter-spacing: 0.02em; +} + +.ul-nav-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ul-text-muted); + margin: 10px 6px 6px; +} + +.ul-nav-list { + list-style: none; + padding: 0; + margin: 0 0 12px 0; +} + +.ul-nav-item, +.ul-filter-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 9px 10px; + border-radius: 6px; + color: var(--ul-text-main); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background-color 0.15s ease; + margin-bottom: 3px; +} + +.ul-nav-item { + justify-content: flex-start; +} + +.ul-nav-item:hover, +.ul-filter-item:hover { + background-color: #f1f5f9; +} + +.ul-nav-item.active { + background-color: #fee2e2; + color: var(--ul-cgt-red-dark); + font-weight: 700; +} + +.ul-filter-item.active { + background-color: #e2e8f0; + font-weight: 700; +} + +.ul-counter { + background: #e5e7eb; + color: #1f2937; + font-size: 11.5px; + font-weight: 700; + padding: 2px 7px; + border-radius: 999px; + min-width: 22px; + text-align: center; +} + +.ul-counter-green { + background: var(--ul-green-soft); + color: var(--ul-green-paid); +} + +.ul-counter-orange { + background: #ffedd5; + color: #c2410c; +} + +.ul-counter-red { + background: #fee2e2; + color: #b91c1c; +} + +.ul-nav-summary-card { + background: #f8fafc; + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 10px 12px; + margin-top: auto; + margin-bottom: 12px; + font-size: 12.5px; +} + +.ul-summary-row { + display: flex; + justify-content: space-between; + padding: 3px 0; +} + +.ul-summary-total { + border-top: 1px solid var(--ul-border); + margin-top: 6px; + padding-top: 6px; + font-size: 13.5px; + color: var(--ul-green-paid); +} + +.ul-nav-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* ========================================================================== + Zone centrale (#app-content) & Barre d'outils + ========================================================================== */ +#app-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 16px 20px; + box-sizing: border-box; + position: relative; +} + +.ul-view-section { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.ul-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--ul-bg-surface); + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 10px 14px; + margin-bottom: 10px; + flex-shrink: 0; +} + +.ul-toolbar-left, +.ul-toolbar-right { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.ul-year-selector { + display: inline-flex; + align-items: center; + background: #f1f5f9; + border: 1px solid var(--ul-border); + border-radius: 6px; + padding: 2px; +} + +.ul-year-label { + font-size: 16px; + font-weight: 800; + padding: 0 12px; + color: var(--ul-cgt-red-dark); +} + +.ul-btn-icon { + background: #ffffff; + border: 1px solid var(--ul-border); + border-radius: 4px; + width: 30px; + height: 30px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ul-btn-icon:hover { + background: #e2e8f0; +} + +.ul-search-input { + width: 310px; + max-width: 100%; + padding: 7px 12px; + border: 1px solid var(--ul-border); + border-radius: 6px; + font-size: 13.5px; +} + +.ul-checkbox-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; + user-select: none; +} + +/* Légende au-dessus de la grille */ +.ul-legend-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + font-size: 12px; + color: var(--ul-text-muted); + margin-bottom: 8px; + flex-shrink: 0; +} + +.ul-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ul-legend-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + display: inline-block; +} + +.ul-swatch-paid { + background: var(--ul-green-paid); +} + +.ul-swatch-empty { + background: var(--ul-gray-empty); + border: 1px solid var(--ul-border); +} + +.ul-active-filter-pill { + background: #fef3c7; + color: #92400e; + border: 1px solid #f59e0b; + padding: 2px 10px; + border-radius: 999px; + font-weight: 600; + cursor: pointer; +} + +/* ========================================================================== + Grille Annuelle des Cotisations (Sticky left, right, top & bottom) + ========================================================================== */ +.ul-grid-container { + flex: 1; + overflow: auto; + background: var(--ul-bg-surface); + border: 1px solid var(--ul-border); + border-radius: 8px; + position: relative; +} + +.ul-dues-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} + +.ul-dues-table th, +.ul-dues-table td { + border-bottom: 1px solid #e5e7eb; + border-right: 1px solid #e5e7eb; + padding: 6px 8px; + box-sizing: border-box; +} + +/* En-tête figé en haut */ +.ul-dues-table thead th { + position: sticky; + top: 0; + z-index: 4; + background: #1e293b; + color: #ffffff; + font-weight: 700; + text-align: center; + padding: 10px 6px; + white-space: nowrap; +} + +.ul-dues-table thead th.col-sticky-left { + left: 0; + z-index: 6; + text-align: left; + padding-left: 12px; +} + +.ul-dues-table thead th.col-sticky-right { + right: 0; + z-index: 6; + background: #0f172a; +} + +/* Colonne 1 figée à gauche */ +.col-sticky-left { + position: sticky; + left: 0; + z-index: 3; + min-width: 265px; + max-width: 295px; + background: #ffffff; + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.04); +} + +/* Colonne 14 figée à droite */ +.col-sticky-right { + position: sticky; + right: 0; + z-index: 3; + min-width: 112px; + text-align: right; + font-weight: 800; + background: #f8fafc; + box-shadow: -2px 0 4px rgba(0, 0, 0, 0.04); + white-space: nowrap; +} + +/* Pied de tableau figé en bas (Total Mensuel & Grand Total Annuel) */ +.ul-tfoot-row th { + position: sticky; + bottom: 0; + z-index: 4; + background: #f1f5f9; + color: #0f172a; + font-weight: 800; + border-top: 2px solid #94a3b8; + padding: 10px 6px; + text-align: right; + white-space: nowrap; +} + +.ul-tfoot-row th.col-sticky-left { + left: 0; + z-index: 6; + text-align: left; + padding-left: 12px; + background: #e2e8f0; +} + +.ul-tfoot-row th.col-sticky-right.col-grand-total { + right: 0; + z-index: 6; + background: var(--ul-cgt-red); + color: #ffffff; + font-size: 14px; +} + +/* Cellule Syndiqué (Colonne 1) */ +.ul-member-cell { + padding: 7px 10px !important; +} + +.ul-member-top-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 6px; +} + +.ul-member-name-wrap { + display: flex; + flex-direction: column; +} + +.ul-member-fullname { + font-weight: 700; + color: #0f172a; + font-size: 13px; +} + +.ul-member-submeta { + font-size: 11px; + color: var(--ul-text-muted); +} + +.ul-btn-edit-member { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + padding: 2px 5px; + font-size: 12px; + opacity: 0.65; +} + +.ul-btn-edit-member:hover { + opacity: 1; + background: #f1f5f9; + border-color: var(--ul-border); +} + +.ul-badges-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.ul-badge { + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 700; + padding: 1.5px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.ul-badge-green { + background: var(--ul-green-soft); + color: var(--ul-green-paid); +} + +.ul-badge-orange { + background: #ffedd5; + color: #c2410c; + border: 1px solid #fdba74; +} + +.ul-badge-red { + background: #fee2e2; + color: #b91c1c; + border: 1px solid #fca5a5; +} + +.ul-badge-archived { + background: #e5e7eb; + color: #374151; +} + +/* Lignes en Alerte Rouge Grave (0,00 € sur l'année) */ +.ul-row-alert-red td { + border-top: 2px solid #ef4444 !important; + border-bottom: 2px solid #ef4444 !important; +} + +.ul-row-alert-red td.col-sticky-left { + border-left: 5px solid #dc2626 !important; + background: #fff5f5; +} + +.ul-row-alert-red td.col-sticky-right { + background: #fef2f2; + color: #b91c1c; +} + +/* Lignes en Alerte Orange (Mois manquants) */ +.ul-row-alert-orange td.col-sticky-left { + border-left: 4px solid #f59e0b !important; +} + +.ul-row-archived { + opacity: 0.65; +} + +/* Cellules mensuelles (Colonnes 2 à 13) */ +.col-month { + min-width: 78px; +} + +.ul-due-cell { + text-align: center; + vertical-align: middle; + cursor: pointer; + user-select: none; + position: relative; + min-width: 78px; + height: 46px; + transition: transform 0.08s ease, background-color 0.12s ease; +} + +/* Cellule vide : gris clair neutre */ +.ul-due-empty { + background-color: var(--ul-gray-empty); + color: #9ca3af; +} + +.ul-due-empty:hover { + background-color: var(--ul-gray-empty-hover); + color: #374151; +} + +.ul-due-empty-plus { + font-size: 15px; + font-weight: 700; + opacity: 0.35; +} + +.ul-due-empty:hover .ul-due-empty-plus { + opacity: 0.9; +} + +/* Cellule cotisée : fond vert franc, montant en euros + badge mode de paiement */ +.ul-due-paid { + background-color: var(--ul-green-paid); + color: #ffffff; + font-weight: 700; +} + +.ul-due-paid:hover { + background-color: var(--ul-green-paid-hover); +} + +.ul-due-amount { + display: block; + font-size: 12.5px; + line-height: 1.2; +} + +.ul-due-method-badge { + display: inline-block; + margin-top: 2px; + font-size: 9.5px; + font-weight: 800; + background: rgba(255, 255, 255, 0.24); + color: #ffffff; + padding: 0 5px; + border-radius: 3px; +} + +.ul-due-note-dot { + position: absolute; + top: 2px; + right: 4px; + color: #fde047; + font-size: 14px; + line-height: 1; +} + +.ul-member-total-cell { + font-size: 13.5px; + color: var(--ul-green-paid); +} + +.ul-member-total-cell.ul-total-zero { + color: #dc2626; +} + +.ul-empty-cell { + text-align: center; + padding: 40px 20px !important; + color: var(--ul-text-muted); + font-size: 14px; +} + +/* ========================================================================== + Boutons & Formulaires génériques + ========================================================================== */ +.ul-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 14px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: background-color 0.15s ease, border-color 0.15s ease; + white-space: nowrap; +} + +.ul-btn-block { + width: 100%; +} + +.ul-btn-sm { + padding: 5px 10px; + font-size: 12px; +} + +.ul-btn-primary { + background: var(--ul-cgt-red); + color: #ffffff; +} + +.ul-btn-primary:hover { + background: var(--ul-cgt-red-dark); +} + +.ul-btn-secondary { + background: #1e293b; + color: #ffffff; +} + +.ul-btn-secondary:hover { + background: #0f172a; +} + +.ul-btn-success { + background: var(--ul-green-paid); + color: #ffffff; +} + +.ul-btn-success:hover { + background: var(--ul-green-paid-hover); +} + +.ul-btn-danger { + background: #fee2e2; + color: #b91c1c; + border-color: #fca5a5; +} + +.ul-btn-danger:hover { + background: #fecaca; +} + +.ul-btn-outline { + background: #ffffff; + color: var(--ul-text-main); + border-color: var(--ul-border); +} + +.ul-btn-outline:hover { + background: #f1f5f9; +} + +.ul-btn-link { + background: none; + border: none; + color: var(--ul-cgt-red); + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.ul-btn-link:hover { + text-decoration: underline; +} + +.ul-field-label { + display: block; + font-size: 12.5px; + font-weight: 700; + color: #374151; + margin-bottom: 4px; +} + +.ul-field-label-inline { + font-size: 12.5px; + font-weight: 600; + color: #374151; +} + +.ul-field-help { + display: block; + font-size: 11.5px; + color: var(--ul-text-muted); + margin-top: 3px; +} + +.ul-input, +.ul-select, +.ul-textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--ul-border); + border-radius: 6px; + font-size: 13.5px; + box-sizing: border-box; + background: #ffffff; + color: var(--ul-text-main); +} + +.ul-input-lg { + font-size: 16px; + font-weight: 700; + padding: 9px 12px; +} + +.ul-textarea { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + line-height: 1.5; + resize: vertical; +} + +.ul-form-group { + margin-bottom: 12px; +} + +.ul-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.ul-form-row-3 { + grid-template-columns: 1fr 1fr 1fr; +} + +.ul-span-2 { + grid-column: span 2; +} + +.ul-checkbox-group-row { + padding: 8px 0; +} + +/* ========================================================================== + Fenêtres Modales (#due-modal & #member-modal) + ========================================================================== */ +.ul-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.ul-modal-dialog { + background: #ffffff; + border-radius: 10px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.25); + width: 100%; + max-height: 92vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ul-modal-sm { + max-width: 480px; +} + +.ul-modal-lg { + max-width: 720px; +} + +.ul-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 18px; + background: #f8fafc; + border-bottom: 1px solid var(--ul-border); +} + +.ul-modal-title { + margin: 0; + font-size: 16px; + font-weight: 800; + color: #0f172a; +} + +.ul-modal-subtitle { + margin: 3px 0 0 0; + font-size: 13px; + font-weight: 600; + color: var(--ul-cgt-red-dark); +} + +.ul-modal-close { + background: none; + border: none; + font-size: 22px; + line-height: 1; + cursor: pointer; + color: var(--ul-text-muted); +} + +.ul-modal-body { + padding: 16px 18px; + overflow-y: auto; +} + +.ul-prefill-banner { + background: #eff6ff; + border-left: 4px solid #2563eb; + color: #1e40af; + padding: 8px 10px; + border-radius: 4px; + font-size: 12px; + margin-bottom: 14px; +} + +.ul-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 12px; + margin-top: 8px; + border-top: 1px solid var(--ul-border); +} + +.ul-modal-footer-right { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +/* ========================================================================== + Vue 2 : Outil de Publipostage & Courriers + ========================================================================== */ +.ul-section-heading { + margin: 0; + font-size: 16px; + font-weight: 800; +} + +.ul-mailing-workspace { + display: grid; + grid-template-columns: 360px 1fr; + gap: 16px; + flex: 1; + overflow: hidden; +} + +.ul-mailing-sidebar, +.ul-mailing-main { + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; + padding-right: 4px; +} + +.ul-card { + background: #ffffff; + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 14px; +} + +.ul-card-header-flex { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.ul-card-title { + margin: 0; + font-size: 14px; + font-weight: 800; +} + +.ul-card-actions { + display: flex; + gap: 8px; +} + +.ul-hint-text { + font-size: 12px; + color: var(--ul-text-muted); + margin: 8px 0 0 0; +} + +.ul-recipients-card { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ul-recipients-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.ul-recipients-filters { + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid #e5e7eb; +} + +.ul-recipients-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 440px; +} + +.ul-recipient-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 8px; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + font-size: 12.5px; +} + +.ul-recipient-item.selected { + background: #fef2f2; + border-color: #fca5a5; +} + +.ul-recipient-info { + flex: 1; + min-width: 0; +} + +.ul-recipient-name { + font-weight: 700; + color: #111827; +} + +.ul-recipient-meta { + font-size: 11px; + color: var(--ul-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ul-variables-box { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 10px; + margin-bottom: 12px; +} + +.ul-variables-title { + display: block; + font-size: 11.5px; + font-weight: 700; + color: var(--ul-text-muted); + margin-bottom: 6px; +} + +.ul-variables-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ul-var-pill { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 3px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11.5px; + color: var(--ul-cgt-red-dark); + cursor: pointer; +} + +.ul-var-pill:hover { + background: #fee2e2; + border-color: var(--ul-cgt-red); +} + +/* Aperçu de courrier A4 à l'écran */ +.ul-letter-preview { + background: #e2e8f0; + padding: 16px; + border-radius: 6px; +} + +.ul-print-page { + background: #ffffff; + color: #111827; + padding: 28px 34px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + max-width: 720px; + margin: 0 auto; + font-family: "Liberation Serif", Georgia, "Times New Roman", serif; + line-height: 1.55; +} + +.ul-letter-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + border-bottom: 3px solid var(--ul-cgt-red); + padding-bottom: 12px; + margin-bottom: 18px; + font-family: Arial, Helvetica, sans-serif; +} + +.ul-letter-banner-img { + max-width: 390px; + width: 100%; + max-height: 76px; + object-fit: contain; + display: block; +} + +.ul-letter-logo-img { + height: 54px; + width: auto; + object-fit: contain; +} + +.ul-letter-org-sub { + font-size: 12px; + font-weight: 600; + color: #374151; +} + +.ul-letter-subject { + font-family: Arial, Helvetica, sans-serif; + font-weight: 700; + font-size: 14px; + background: #f8fafc; + border-left: 4px solid var(--ul-cgt-red); + padding: 8px 12px; + margin-bottom: 16px; +} + +.ul-letter-body { + white-space: pre-wrap; + font-size: 13.5px; +} + +/* ========================================================================== + Vue Tableau de Bord & Comparaison Pluriannuelle (#view-dashboard) + ========================================================================== */ +.ul-toolbar-cgt-logo { + width: 34px; + height: 34px; + object-fit: contain; +} + +.ul-dashboard-subtitle { + display: block; + font-size: 11.5px; + color: var(--ul-text-muted); +} + +.ul-dash-control-group { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ul-dash-compare-select { + width: auto; + min-width: 155px; + font-weight: 700; +} + +.ul-dashboard-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding-right: 2px; +} + +/* Ligne 1 : 5 cartes KPI */ +.ul-kpi-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + flex-shrink: 0; +} + +.ul-kpi-card { + background: #ffffff; + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.ul-kpi-card-primary { + border-left: 4px solid var(--ul-cgt-red); + background: linear-gradient(180deg, #fffafa 0%, #ffffff 100%); +} + +.ul-kpi-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 4px; +} + +.ul-kpi-label { + font-size: 11.5px; + font-weight: 700; + color: var(--ul-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.ul-kpi-icon { + font-size: 14px; +} + +.ul-kpi-trend { + font-size: 11px; + font-weight: 700; + padding: 1.5px 6px; + border-radius: 999px; + background: #f1f5f9; + color: #334155; + white-space: nowrap; +} + +.ul-kpi-trend.ul-trend-up { + background: #dcfce7; + color: #15803d; +} + +.ul-kpi-trend.ul-trend-down { + background: #fee2e2; + color: #b91c1c; +} + +.ul-kpi-value { + font-size: 20px; + font-weight: 800; + color: #0f172a; + line-height: 1.2; +} + +.ul-kpi-value-orange { + color: #c2410c; +} + +.ul-kpi-sub { + font-size: 11px; + color: var(--ul-text-muted); + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Ligne 2 : Grille des graphiques */ +.ul-dash-charts-grid { + display: grid; + grid-template-columns: 1.65fr 1fr; + gap: 10px; + flex: 1; + min-height: 270px; +} + +.ul-dash-chart-card { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 11px 14px; +} + +.ul-dash-side-charts { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ul-chart-subtitle { + display: block; + font-size: 11px; + color: var(--ul-text-muted); +} + +.ul-chart-legend { + display: flex; + align-items: center; + gap: 12px; + font-size: 11.5px; + font-weight: 600; +} + +.ul-chart-legend-item { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.ul-legend-dot { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; +} + +.ul-dot-red { + background: var(--ul-cgt-red); +} + +.ul-dot-slate { + background: #94a3b8; +} + +.ul-svg-chart-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 180px; +} + +.ul-svg-chart-compact { + min-height: 112px; +} + +.ul-svg-chart { + width: 100%; + height: 100%; + max-height: 245px; + overflow: visible; +} + +.ul-chart-bar { + transition: opacity 0.15s ease; +} + +.ul-chart-bar:hover { + opacity: 0.82; +} + +.ul-multiyear-bar-group { + cursor: pointer; +} + +.ul-multiyear-bar-group:hover rect { + opacity: 0.85; +} + +/* Modes de paiement & Statuts dans le Dashboard */ +.ul-methods-status-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ul-methods-breakdown { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 12px; +} + +.ul-method-stat-row { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ul-method-stat-head { + display: flex; + justify-content: space-between; + font-size: 11px; +} + +.ul-method-stat-name { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 600; +} + +.ul-method-color-dot { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; +} + +.ul-progress-track { + width: 100%; + height: 5px; + background: #e2e8f0; + border-radius: 999px; + overflow: hidden; +} + +.ul-progress-fill { + height: 100%; + border-radius: 999px; +} + +.ul-status-breakdown { + border-top: 1px solid #e2e8f0; + padding-top: 6px; +} + +.ul-status-breakdown-title { + font-size: 11px; + font-weight: 700; + color: #334155; + margin-bottom: 4px; +} + +.ul-stacked-status-bar { + display: flex; + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: #e2e8f0; + margin-bottom: 5px; +} + +.ul-stacked-segment { + height: 100%; +} + +.ul-status-mini-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ul-status-mini-pill { + font-size: 10.5px; + font-weight: 700; + background: #f1f5f9; + color: #334155; + padding: 1px 6px; + border-radius: 4px; +} + +/* Ligne 3 : Tableau comparatif mensuel */ +.ul-dash-table-card { + padding: 8px 12px; + flex-shrink: 0; +} + +.ul-table-responsive { + overflow-x: auto; +} + +.ul-dash-comp-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.ul-dash-comp-table th, +.ul-dash-comp-table td { + padding: 5px 6px; + border-bottom: 1px solid #e2e8f0; + text-align: right; + white-space: nowrap; +} + +.ul-dash-comp-table thead th { + background: #f8fafc; + color: #334155; + font-weight: 700; + text-align: right; +} + +.ul-dash-comp-table thead th:first-child, +.ul-dash-comp-table tbody th { + text-align: left; + font-weight: 700; +} + +.ul-comp-row-curr th, +.ul-comp-row-curr td { + font-weight: 700; + color: #0f172a; +} + +.ul-comp-row-prev th, +.ul-comp-row-prev td { + color: #64748b; +} + +.ul-comp-col-total { + font-weight: 800 !important; + background: #f8fafc; + border-left: 2px solid #cbd5e1; +} + +.ul-diff-pos { + color: #15803d !important; + font-weight: 700; +} + +.ul-diff-neg { + color: #b91c1c !important; + font-weight: 700; +} + +.ul-diff-zero { + color: #64748b; +} + +/* ========================================================================== + Vue 4 : Gestion des Permissions d'accès (#view-permissions) & Accès Restreint + ========================================================================== */ +.ul-permissions-workspace { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; + overflow-y: auto; +} + +.ul-perm-mode-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 10px; +} + +.ul-perm-mode-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px; + border: 1px solid var(--ul-border); + border-radius: 8px; + cursor: pointer; + background: #f8fafc; +} + +.ul-perm-mode-option input[type="radio"] { + margin-top: 3px; +} + +.ul-perm-mode-option strong { + display: block; + font-size: 13px; + color: #0f172a; + margin-bottom: 3px; +} + +.ul-perm-mode-option span { + display: block; + font-size: 12px; + color: var(--ul-text-muted); + line-height: 1.4; +} + +.ul-permissions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + flex: 1; + min-height: 300px; +} + +.ul-perm-disabled-look { + opacity: 0.55; +} + +.ul-perm-list-card { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ul-perm-search { + margin-bottom: 10px; +} + +.ul-perm-checklist { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 5px; + max-height: 420px; +} + +.ul-perm-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} + +.ul-perm-item.selected { + background: #fef2f2; + border-color: #fca5a5; +} + +.ul-perm-item-info { + flex: 1; + min-width: 0; +} + +.ul-perm-item-title { + font-weight: 700; + color: #0f172a; +} + +.ul-perm-item-sub { + font-size: 11.5px; + color: var(--ul-text-muted); +} + +.ul-perm-empty { + text-align: center; + padding: 24px; + color: var(--ul-text-muted); + font-size: 13px; +} + +/* Écran d'accès refusé */ +.ul-access-denied-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 24px; + box-sizing: border-box; +} + +.ul-access-denied-card { + max-width: 560px; + width: 100%; + background: #ffffff; + border: 1px solid var(--ul-border); + border-top: 5px solid var(--ul-cgt-red); + border-radius: 12px; + padding: 28px 32px; + text-align: center; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08); +} + +.ul-access-denied-banner { + max-width: 340px; + width: 100%; + height: auto; + margin: 0 auto 16px; + display: block; +} + +.ul-access-denied-badge { + display: inline-block; + background: #fee2e2; + color: #991b1b; + font-weight: 800; + font-size: 12px; + padding: 4px 12px; + border-radius: 999px; + margin-bottom: 12px; +} + +.ul-access-denied-card h2 { + margin: 0 0 10px; + font-size: 18px; + font-weight: 800; + color: #0f172a; +} + +.ul-access-denied-card p { + font-size: 13.5px; + color: #374151; + line-height: 1.5; + margin: 0 0 14px; +} + +.ul-access-denied-userbox { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 10px 14px; + font-size: 13px; + margin-bottom: 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.ul-access-denied-help { + font-size: 12.5px !important; + color: var(--ul-text-muted) !important; +} + +/* Notifications Toast */ +.ul-toast { + position: fixed; + top: 62px; + right: 24px; + z-index: 1200; + padding: 10px 16px; + border-radius: 6px; + font-size: 13.5px; + font-weight: 600; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2); +} + +.ul-toast-success { + background: #15803d; + color: #ffffff; +} + +.ul-toast-error { + background: #b91c1c; + color: #ffffff; +} + +.ul-toast-info { + background: #1d4ed8; + color: #ffffff; +} + +/* ========================================================================== + Feuille de style d'impression dédiée (@media print) — Format A4 par syndiqué + ========================================================================== */ +@media print { + @page { + size: A4 portrait; + margin: 15mm; + } + + body, + html, + #content, + .ul-app-wrapper { + background: #ffffff !important; + height: auto !important; + overflow: visible !important; + display: block !important; + } + + #header, + #navigation, + #app-navigation, + #app-content, + .ul-no-print { + display: none !important; + } + + #ul-print-container.ul-print-only { + display: block !important; + width: 100% !important; + } + + .ul-print-page { + box-shadow: none !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; + max-width: 100% !important; + page-break-after: always; + break-after: page; + } + + .ul-print-page:last-child { + page-break-after: auto; + break-after: auto; + } +} diff --git a/ul_tresorerie/css/style.css b/ul_tresorerie/css/style.css new file mode 100644 index 0000000..d1f0a9e --- /dev/null +++ b/ul_tresorerie/css/style.css @@ -0,0 +1,1823 @@ +/* ========================================================================== + UL Trésorerie CGT (OCA\ULTresorerie) — Feuille de style Nextcloud 34 + ========================================================================== */ + +:root { + --ul-cgt-red: #d32f2f; + --ul-cgt-red-dark: #991b1b; + --ul-cgt-red-soft: #fef2f2; + --ul-green-paid: #15803d; + --ul-green-paid-hover: #166534; + --ul-green-soft: #dcfce7; + --ul-orange-alert: #d97706; + --ul-orange-soft: #fffbeb; + --ul-gray-empty: #f3f4f6; + --ul-gray-empty-hover: #e5e7eb; + --ul-border: #d1d5db; + --ul-text-main: #111827; + --ul-text-muted: #4b5563; + --ul-bg-surface: #ffffff; + --ul-bg-app: #f8fafc; +} + +.hidden { + display: none !important; +} + +.ul-print-only { + display: none; +} + +/* Disposition principale dans Nextcloud */ +.ul-app-wrapper { + display: flex; + width: 100%; + height: calc(100vh - 50px); + overflow: hidden; + background-color: var(--ul-bg-app); + color: var(--ul-text-main); + font-family: var(--font-face, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); +} + +/* ========================================================================== + Barre latérale gauche (#app-navigation) + ========================================================================== */ +#app-navigation { + width: 290px; + min-width: 290px; + background: var(--ul-bg-surface); + border-right: 1px solid var(--ul-border); + display: flex; + flex-direction: column; + padding: 14px; + box-sizing: border-box; + overflow-y: auto; + transition: width 0.2s ease, min-width 0.2s ease, padding 0.2s ease; +} + +.ul-app-wrapper.ul-sidebar-collapsed #app-navigation { + width: 0 !important; + min-width: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + border-right: none !important; + overflow: hidden !important; +} + +.ul-brand-header { + display: flex; + flex-direction: column; + background: #ffffff; + border: 1px solid #e2e8f0; + border-top: 4px solid var(--ul-cgt-red); + border-radius: 8px; + margin-bottom: 14px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + flex-shrink: 0; +} + +.ul-brand-banner-box { + padding: 8px 8px 6px 10px; + background: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.ul-brand-banner-img { + max-width: 205px; + width: 100%; + height: auto; + max-height: 46px !important; + object-fit: contain; + display: block; +} + +.ul-sidebar-close-btn { + background: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 6px; + width: 28px; + height: 28px; + min-width: 28px; + font-size: 16px; + font-weight: 800; + line-height: 1; + color: #334155; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.ul-sidebar-close-btn:hover { + background: #fee2e2; + border-color: #fca5a5; + color: var(--ul-cgt-red-dark); +} + +.ul-btn-toggle-sidebar { + background: #f8fafc; + border: 1px solid var(--ul-border); + border-radius: 6px; + width: 34px; + height: 34px; + min-width: 34px; + font-size: 16px; + font-weight: 700; + color: #1e293b; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.ul-btn-toggle-sidebar:hover { + background: #fee2e2; + border-color: #fca5a5; + color: var(--ul-cgt-red-dark); +} + +.ul-brand-subtitle-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + background: linear-gradient(135deg, var(--ul-cgt-red), var(--ul-cgt-red-dark)); + color: #ffffff; + font-size: 11.5px; + font-weight: 700; + padding: 5px 10px; + letter-spacing: 0.02em; +} + +.ul-nav-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ul-text-muted); + margin: 10px 6px 6px; +} + +.ul-nav-list { + list-style: none; + padding: 0; + margin: 0 0 12px 0; +} + +.ul-nav-item, +.ul-filter-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 9px 10px; + border-radius: 6px; + color: var(--ul-text-main); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background-color 0.15s ease; + margin-bottom: 3px; +} + +.ul-nav-item { + justify-content: flex-start; +} + +.ul-nav-item:hover, +.ul-filter-item:hover { + background-color: #f1f5f9; +} + +.ul-nav-item.active { + background-color: #fee2e2; + color: var(--ul-cgt-red-dark); + font-weight: 700; +} + +.ul-filter-item.active { + background-color: #e2e8f0; + font-weight: 700; +} + +.ul-counter { + background: #e5e7eb; + color: #1f2937; + font-size: 11.5px; + font-weight: 700; + padding: 2px 7px; + border-radius: 999px; + min-width: 22px; + text-align: center; +} + +.ul-counter-green { + background: var(--ul-green-soft); + color: var(--ul-green-paid); +} + +.ul-counter-orange { + background: #ffedd5; + color: #c2410c; +} + +.ul-counter-red { + background: #fee2e2; + color: #b91c1c; +} + +.ul-nav-summary-card { + background: #f8fafc; + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 10px 12px; + margin-top: auto; + margin-bottom: 12px; + font-size: 12.5px; +} + +.ul-summary-row { + display: flex; + justify-content: space-between; + padding: 3px 0; +} + +.ul-summary-total { + border-top: 1px solid var(--ul-border); + margin-top: 6px; + padding-top: 6px; + font-size: 13.5px; + color: var(--ul-green-paid); +} + +.ul-nav-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* ========================================================================== + Zone centrale (#app-content) & Barre d'outils + ========================================================================== */ +#app-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 16px 20px; + box-sizing: border-box; + position: relative; +} + +.ul-view-section { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.ul-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--ul-bg-surface); + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 10px 14px; + margin-bottom: 10px; + flex-shrink: 0; +} + +.ul-toolbar-left, +.ul-toolbar-right { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.ul-year-selector { + display: inline-flex; + align-items: center; + background: #f1f5f9; + border: 1px solid var(--ul-border); + border-radius: 6px; + padding: 2px; +} + +.ul-year-label { + font-size: 16px; + font-weight: 800; + padding: 0 12px; + color: var(--ul-cgt-red-dark); +} + +.ul-btn-icon { + background: #ffffff; + border: 1px solid var(--ul-border); + border-radius: 4px; + width: 30px; + height: 30px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ul-btn-icon:hover { + background: #e2e8f0; +} + +.ul-search-input { + width: 310px; + max-width: 100%; + padding: 7px 12px; + border: 1px solid var(--ul-border); + border-radius: 6px; + font-size: 13.5px; +} + +.ul-checkbox-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; + user-select: none; +} + +/* Légende au-dessus de la grille */ +.ul-legend-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + font-size: 12px; + color: var(--ul-text-muted); + margin-bottom: 8px; + flex-shrink: 0; +} + +.ul-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ul-legend-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + display: inline-block; +} + +.ul-swatch-paid { + background: var(--ul-green-paid); +} + +.ul-swatch-empty { + background: var(--ul-gray-empty); + border: 1px solid var(--ul-border); +} + +.ul-active-filter-pill { + background: #fef3c7; + color: #92400e; + border: 1px solid #f59e0b; + padding: 2px 10px; + border-radius: 999px; + font-weight: 600; + cursor: pointer; +} + +/* ========================================================================== + Grille Annuelle des Cotisations (Sticky left, right, top & bottom) + ========================================================================== */ +.ul-grid-container { + flex: 1; + overflow: auto; + background: var(--ul-bg-surface); + border: 1px solid var(--ul-border); + border-radius: 8px; + position: relative; +} + +.ul-dues-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} + +.ul-dues-table th, +.ul-dues-table td { + border-bottom: 1px solid #e5e7eb; + border-right: 1px solid #e5e7eb; + padding: 6px 8px; + box-sizing: border-box; +} + +/* En-tête figé en haut */ +.ul-dues-table thead th { + position: sticky; + top: 0; + z-index: 4; + background: #1e293b; + color: #ffffff; + font-weight: 700; + text-align: center; + padding: 10px 6px; + white-space: nowrap; +} + +.ul-dues-table thead th.col-sticky-left { + left: 0; + z-index: 6; + text-align: left; + padding-left: 12px; +} + +.ul-dues-table thead th.col-sticky-right { + right: 0; + z-index: 6; + background: #0f172a; +} + +/* Colonne 1 figée à gauche */ +.col-sticky-left { + position: sticky; + left: 0; + z-index: 3; + min-width: 265px; + max-width: 295px; + background: #ffffff; + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.04); +} + +/* Colonne 14 figée à droite */ +.col-sticky-right { + position: sticky; + right: 0; + z-index: 3; + min-width: 112px; + text-align: right; + font-weight: 800; + background: #f8fafc; + box-shadow: -2px 0 4px rgba(0, 0, 0, 0.04); + white-space: nowrap; +} + +/* Pied de tableau figé en bas (Total Mensuel & Grand Total Annuel) */ +.ul-tfoot-row th { + position: sticky; + bottom: 0; + z-index: 4; + background: #f1f5f9; + color: #0f172a; + font-weight: 800; + border-top: 2px solid #94a3b8; + padding: 10px 6px; + text-align: right; + white-space: nowrap; +} + +.ul-tfoot-row th.col-sticky-left { + left: 0; + z-index: 6; + text-align: left; + padding-left: 12px; + background: #e2e8f0; +} + +.ul-tfoot-row th.col-sticky-right.col-grand-total { + right: 0; + z-index: 6; + background: var(--ul-cgt-red); + color: #ffffff; + font-size: 14px; +} + +/* Cellule Syndiqué (Colonne 1) */ +.ul-member-cell { + padding: 7px 10px !important; +} + +.ul-member-top-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 6px; +} + +.ul-member-name-wrap { + display: flex; + flex-direction: column; +} + +.ul-member-fullname { + font-weight: 700; + color: #0f172a; + font-size: 13px; +} + +.ul-member-submeta { + font-size: 11px; + color: var(--ul-text-muted); +} + +.ul-btn-edit-member { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + padding: 2px 5px; + font-size: 12px; + opacity: 0.65; +} + +.ul-btn-edit-member:hover { + opacity: 1; + background: #f1f5f9; + border-color: var(--ul-border); +} + +.ul-badges-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.ul-badge { + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 700; + padding: 1.5px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.ul-badge-green { + background: var(--ul-green-soft); + color: var(--ul-green-paid); +} + +.ul-badge-orange { + background: #ffedd5; + color: #c2410c; + border: 1px solid #fdba74; +} + +.ul-badge-red { + background: #fee2e2; + color: #b91c1c; + border: 1px solid #fca5a5; +} + +.ul-badge-archived { + background: #e5e7eb; + color: #374151; +} + +/* Lignes en Alerte Rouge Grave (0,00 € sur l'année) */ +.ul-row-alert-red td { + border-top: 2px solid #ef4444 !important; + border-bottom: 2px solid #ef4444 !important; +} + +.ul-row-alert-red td.col-sticky-left { + border-left: 5px solid #dc2626 !important; + background: #fff5f5; +} + +.ul-row-alert-red td.col-sticky-right { + background: #fef2f2; + color: #b91c1c; +} + +/* Lignes en Alerte Orange (Mois manquants) */ +.ul-row-alert-orange td.col-sticky-left { + border-left: 4px solid #f59e0b !important; +} + +.ul-row-archived { + opacity: 0.65; +} + +/* Cellules mensuelles (Colonnes 2 à 13) */ +.col-month { + min-width: 78px; +} + +.ul-due-cell { + text-align: center; + vertical-align: middle; + cursor: pointer; + user-select: none; + position: relative; + min-width: 78px; + height: 46px; + transition: transform 0.08s ease, background-color 0.12s ease; +} + +/* Cellule vide : gris clair neutre */ +.ul-due-empty { + background-color: var(--ul-gray-empty); + color: #9ca3af; +} + +.ul-due-empty:hover { + background-color: var(--ul-gray-empty-hover); + color: #374151; +} + +.ul-due-empty-plus { + font-size: 15px; + font-weight: 700; + opacity: 0.35; +} + +.ul-due-empty:hover .ul-due-empty-plus { + opacity: 0.9; +} + +/* Cellule cotisée : fond vert franc, montant en euros + badge mode de paiement */ +.ul-due-paid { + background-color: var(--ul-green-paid); + color: #ffffff; + font-weight: 700; +} + +.ul-due-paid:hover { + background-color: var(--ul-green-paid-hover); +} + +.ul-due-amount { + display: block; + font-size: 12.5px; + line-height: 1.2; +} + +.ul-due-method-badge { + display: inline-block; + margin-top: 2px; + font-size: 9.5px; + font-weight: 800; + background: rgba(255, 255, 255, 0.24); + color: #ffffff; + padding: 0 5px; + border-radius: 3px; +} + +.ul-due-note-dot { + position: absolute; + top: 2px; + right: 4px; + color: #fde047; + font-size: 14px; + line-height: 1; +} + +.ul-member-total-cell { + font-size: 13.5px; + color: var(--ul-green-paid); +} + +.ul-member-total-cell.ul-total-zero { + color: #dc2626; +} + +.ul-empty-cell { + text-align: center; + padding: 40px 20px !important; + color: var(--ul-text-muted); + font-size: 14px; +} + +/* ========================================================================== + Boutons & Formulaires génériques + ========================================================================== */ +.ul-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 14px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: background-color 0.15s ease, border-color 0.15s ease; + white-space: nowrap; +} + +.ul-btn-block { + width: 100%; +} + +.ul-btn-sm { + padding: 5px 10px; + font-size: 12px; +} + +.ul-btn-primary { + background: var(--ul-cgt-red); + color: #ffffff; +} + +.ul-btn-primary:hover { + background: var(--ul-cgt-red-dark); +} + +.ul-btn-secondary { + background: #1e293b; + color: #ffffff; +} + +.ul-btn-secondary:hover { + background: #0f172a; +} + +.ul-btn-success { + background: var(--ul-green-paid); + color: #ffffff; +} + +.ul-btn-success:hover { + background: var(--ul-green-paid-hover); +} + +.ul-btn-danger { + background: #fee2e2; + color: #b91c1c; + border-color: #fca5a5; +} + +.ul-btn-danger:hover { + background: #fecaca; +} + +.ul-btn-outline { + background: #ffffff; + color: var(--ul-text-main); + border-color: var(--ul-border); +} + +.ul-btn-outline:hover { + background: #f1f5f9; +} + +.ul-btn-link { + background: none; + border: none; + color: var(--ul-cgt-red); + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.ul-btn-link:hover { + text-decoration: underline; +} + +.ul-field-label { + display: block; + font-size: 12.5px; + font-weight: 700; + color: #374151; + margin-bottom: 4px; +} + +.ul-field-label-inline { + font-size: 12.5px; + font-weight: 600; + color: #374151; +} + +.ul-field-help { + display: block; + font-size: 11.5px; + color: var(--ul-text-muted); + margin-top: 3px; +} + +.ul-input, +.ul-select, +.ul-textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--ul-border); + border-radius: 6px; + font-size: 13.5px; + box-sizing: border-box; + background: #ffffff; + color: var(--ul-text-main); +} + +.ul-input-lg { + font-size: 16px; + font-weight: 700; + padding: 9px 12px; +} + +.ul-textarea { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + line-height: 1.5; + resize: vertical; +} + +.ul-form-group { + margin-bottom: 12px; +} + +.ul-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.ul-form-row-3 { + grid-template-columns: 1fr 1fr 1fr; +} + +.ul-span-2 { + grid-column: span 2; +} + +.ul-checkbox-group-row { + padding: 8px 0; +} + +/* ========================================================================== + Fenêtres Modales (#due-modal & #member-modal) + ========================================================================== */ +.ul-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.ul-modal-dialog { + background: #ffffff; + border-radius: 10px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.25); + width: 100%; + max-height: 92vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ul-modal-sm { + max-width: 480px; +} + +.ul-modal-lg { + max-width: 720px; +} + +.ul-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 18px; + background: #f8fafc; + border-bottom: 1px solid var(--ul-border); +} + +.ul-modal-title { + margin: 0; + font-size: 16px; + font-weight: 800; + color: #0f172a; +} + +.ul-modal-subtitle { + margin: 3px 0 0 0; + font-size: 13px; + font-weight: 600; + color: var(--ul-cgt-red-dark); +} + +.ul-modal-close { + background: none; + border: none; + font-size: 22px; + line-height: 1; + cursor: pointer; + color: var(--ul-text-muted); +} + +.ul-modal-body { + padding: 16px 18px; + overflow-y: auto; +} + +.ul-prefill-banner { + background: #eff6ff; + border-left: 4px solid #2563eb; + color: #1e40af; + padding: 8px 10px; + border-radius: 4px; + font-size: 12px; + margin-bottom: 14px; +} + +.ul-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 12px; + margin-top: 8px; + border-top: 1px solid var(--ul-border); +} + +.ul-modal-footer-right { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +/* ========================================================================== + Vue 2 : Outil de Publipostage & Courriers + ========================================================================== */ +.ul-section-heading { + margin: 0; + font-size: 16px; + font-weight: 800; +} + +.ul-mailing-workspace { + display: grid; + grid-template-columns: 360px 1fr; + gap: 16px; + flex: 1; + overflow: hidden; +} + +.ul-mailing-sidebar, +.ul-mailing-main { + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; + padding-right: 4px; +} + +.ul-card { + background: #ffffff; + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 14px; +} + +.ul-card-header-flex { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.ul-card-title { + margin: 0; + font-size: 14px; + font-weight: 800; +} + +.ul-card-actions { + display: flex; + gap: 8px; +} + +.ul-hint-text { + font-size: 12px; + color: var(--ul-text-muted); + margin: 8px 0 0 0; +} + +.ul-recipients-card { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ul-recipients-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.ul-recipients-filters { + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid #e5e7eb; +} + +.ul-recipients-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 440px; +} + +.ul-recipient-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 8px; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + font-size: 12.5px; +} + +.ul-recipient-item.selected { + background: #fef2f2; + border-color: #fca5a5; +} + +.ul-recipient-info { + flex: 1; + min-width: 0; +} + +.ul-recipient-name { + font-weight: 700; + color: #111827; +} + +.ul-recipient-meta { + font-size: 11px; + color: var(--ul-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ul-variables-box { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 10px; + margin-bottom: 12px; +} + +.ul-variables-title { + display: block; + font-size: 11.5px; + font-weight: 700; + color: var(--ul-text-muted); + margin-bottom: 6px; +} + +.ul-variables-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ul-var-pill { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 3px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11.5px; + color: var(--ul-cgt-red-dark); + cursor: pointer; +} + +.ul-var-pill:hover { + background: #fee2e2; + border-color: var(--ul-cgt-red); +} + +/* Aperçu de courrier A4 à l'écran */ +.ul-letter-preview { + background: #e2e8f0; + padding: 16px; + border-radius: 6px; +} + +.ul-print-page { + background: #ffffff; + color: #111827; + padding: 28px 34px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + max-width: 720px; + margin: 0 auto; + font-family: "Liberation Serif", Georgia, "Times New Roman", serif; + line-height: 1.55; +} + +.ul-letter-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + border-bottom: 3px solid var(--ul-cgt-red); + padding-bottom: 12px; + margin-bottom: 18px; + font-family: Arial, Helvetica, sans-serif; +} + +.ul-letter-banner-img { + max-width: 390px; + width: 100%; + max-height: 76px; + object-fit: contain; + display: block; +} + +.ul-letter-logo-img { + height: 54px; + width: auto; + object-fit: contain; +} + +.ul-letter-org-sub { + font-size: 12px; + font-weight: 600; + color: #374151; +} + +.ul-letter-subject { + font-family: Arial, Helvetica, sans-serif; + font-weight: 700; + font-size: 14px; + background: #f8fafc; + border-left: 4px solid var(--ul-cgt-red); + padding: 8px 12px; + margin-bottom: 16px; +} + +.ul-letter-body { + white-space: pre-wrap; + font-size: 13.5px; +} + +/* ========================================================================== + Vue Tableau de Bord & Comparaison Pluriannuelle (#view-dashboard) + ========================================================================== */ +.ul-toolbar-cgt-logo { + width: 34px; + height: 34px; + object-fit: contain; +} + +.ul-dashboard-subtitle { + display: block; + font-size: 11.5px; + color: var(--ul-text-muted); +} + +.ul-dash-control-group { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ul-dash-compare-select { + width: auto; + min-width: 155px; + font-weight: 700; +} + +.ul-dashboard-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding-right: 2px; +} + +/* Ligne 1 : 5 cartes KPI */ +.ul-kpi-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + flex-shrink: 0; +} + +.ul-kpi-card { + background: #ffffff; + border: 1px solid var(--ul-border); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.ul-kpi-card-primary { + border-left: 4px solid var(--ul-cgt-red); + background: linear-gradient(180deg, #fffafa 0%, #ffffff 100%); +} + +.ul-kpi-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 4px; +} + +.ul-kpi-label { + font-size: 11.5px; + font-weight: 700; + color: var(--ul-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.ul-kpi-icon { + font-size: 14px; +} + +.ul-kpi-trend { + font-size: 11px; + font-weight: 700; + padding: 1.5px 6px; + border-radius: 999px; + background: #f1f5f9; + color: #334155; + white-space: nowrap; +} + +.ul-kpi-trend.ul-trend-up { + background: #dcfce7; + color: #15803d; +} + +.ul-kpi-trend.ul-trend-down { + background: #fee2e2; + color: #b91c1c; +} + +.ul-kpi-value { + font-size: 20px; + font-weight: 800; + color: #0f172a; + line-height: 1.2; +} + +.ul-kpi-value-orange { + color: #c2410c; +} + +.ul-kpi-sub { + font-size: 11px; + color: var(--ul-text-muted); + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Ligne 2 : Grille des graphiques */ +.ul-dash-charts-grid { + display: grid; + grid-template-columns: 1.65fr 1fr; + gap: 10px; + flex: 1; + min-height: 270px; +} + +.ul-dash-chart-card { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 11px 14px; +} + +.ul-dash-side-charts { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ul-chart-subtitle { + display: block; + font-size: 11px; + color: var(--ul-text-muted); +} + +.ul-chart-legend { + display: flex; + align-items: center; + gap: 12px; + font-size: 11.5px; + font-weight: 600; +} + +.ul-chart-legend-item { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.ul-legend-dot { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; +} + +.ul-dot-red { + background: var(--ul-cgt-red); +} + +.ul-dot-slate { + background: #94a3b8; +} + +.ul-svg-chart-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 180px; +} + +.ul-svg-chart-compact { + min-height: 112px; +} + +.ul-svg-chart { + width: 100%; + height: 100%; + max-height: 245px; + overflow: visible; +} + +.ul-chart-bar { + transition: opacity 0.15s ease; +} + +.ul-chart-bar:hover { + opacity: 0.82; +} + +.ul-multiyear-bar-group { + cursor: pointer; +} + +.ul-multiyear-bar-group:hover rect { + opacity: 0.85; +} + +/* Modes de paiement & Statuts dans le Dashboard */ +.ul-methods-status-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ul-methods-breakdown { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 12px; +} + +.ul-method-stat-row { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ul-method-stat-head { + display: flex; + justify-content: space-between; + font-size: 11px; +} + +.ul-method-stat-name { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 600; +} + +.ul-method-color-dot { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; +} + +.ul-progress-track { + width: 100%; + height: 5px; + background: #e2e8f0; + border-radius: 999px; + overflow: hidden; +} + +.ul-progress-fill { + height: 100%; + border-radius: 999px; +} + +.ul-status-breakdown { + border-top: 1px solid #e2e8f0; + padding-top: 6px; +} + +.ul-status-breakdown-title { + font-size: 11px; + font-weight: 700; + color: #334155; + margin-bottom: 4px; +} + +.ul-stacked-status-bar { + display: flex; + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: #e2e8f0; + margin-bottom: 5px; +} + +.ul-stacked-segment { + height: 100%; +} + +.ul-status-mini-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ul-status-mini-pill { + font-size: 10.5px; + font-weight: 700; + background: #f1f5f9; + color: #334155; + padding: 1px 6px; + border-radius: 4px; +} + +/* Ligne 3 : Tableau comparatif mensuel */ +.ul-dash-table-card { + padding: 8px 12px; + flex-shrink: 0; +} + +.ul-table-responsive { + overflow-x: auto; +} + +.ul-dash-comp-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.ul-dash-comp-table th, +.ul-dash-comp-table td { + padding: 5px 6px; + border-bottom: 1px solid #e2e8f0; + text-align: right; + white-space: nowrap; +} + +.ul-dash-comp-table thead th { + background: #f8fafc; + color: #334155; + font-weight: 700; + text-align: right; +} + +.ul-dash-comp-table thead th:first-child, +.ul-dash-comp-table tbody th { + text-align: left; + font-weight: 700; +} + +.ul-comp-row-curr th, +.ul-comp-row-curr td { + font-weight: 700; + color: #0f172a; +} + +.ul-comp-row-prev th, +.ul-comp-row-prev td { + color: #64748b; +} + +.ul-comp-col-total { + font-weight: 800 !important; + background: #f8fafc; + border-left: 2px solid #cbd5e1; +} + +.ul-diff-pos { + color: #15803d !important; + font-weight: 700; +} + +.ul-diff-neg { + color: #b91c1c !important; + font-weight: 700; +} + +.ul-diff-zero { + color: #64748b; +} + +/* ========================================================================== + Vue 4 : Gestion des Permissions d'accès (#view-permissions) & Accès Restreint + ========================================================================== */ +.ul-permissions-workspace { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; + overflow-y: auto; +} + +.ul-perm-mode-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 10px; +} + +.ul-perm-mode-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px; + border: 1px solid var(--ul-border); + border-radius: 8px; + cursor: pointer; + background: #f8fafc; +} + +.ul-perm-mode-option input[type="radio"] { + margin-top: 3px; +} + +.ul-perm-mode-option strong { + display: block; + font-size: 13px; + color: #0f172a; + margin-bottom: 3px; +} + +.ul-perm-mode-option span { + display: block; + font-size: 12px; + color: var(--ul-text-muted); + line-height: 1.4; +} + +.ul-permissions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + flex: 1; + min-height: 300px; +} + +.ul-perm-disabled-look { + opacity: 0.55; +} + +.ul-perm-list-card { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ul-perm-search { + margin-bottom: 10px; +} + +.ul-perm-checklist { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 5px; + max-height: 420px; +} + +.ul-perm-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} + +.ul-perm-item.selected { + background: #fef2f2; + border-color: #fca5a5; +} + +.ul-perm-item-info { + flex: 1; + min-width: 0; +} + +.ul-perm-item-title { + font-weight: 700; + color: #0f172a; +} + +.ul-perm-item-sub { + font-size: 11.5px; + color: var(--ul-text-muted); +} + +.ul-perm-empty { + text-align: center; + padding: 24px; + color: var(--ul-text-muted); + font-size: 13px; +} + +/* Écran d'accès refusé */ +.ul-access-denied-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 24px; + box-sizing: border-box; +} + +.ul-access-denied-card { + max-width: 560px; + width: 100%; + background: #ffffff; + border: 1px solid var(--ul-border); + border-top: 5px solid var(--ul-cgt-red); + border-radius: 12px; + padding: 28px 32px; + text-align: center; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08); +} + +.ul-access-denied-banner { + max-width: 340px; + width: 100%; + height: auto; + margin: 0 auto 16px; + display: block; +} + +.ul-access-denied-badge { + display: inline-block; + background: #fee2e2; + color: #991b1b; + font-weight: 800; + font-size: 12px; + padding: 4px 12px; + border-radius: 999px; + margin-bottom: 12px; +} + +.ul-access-denied-card h2 { + margin: 0 0 10px; + font-size: 18px; + font-weight: 800; + color: #0f172a; +} + +.ul-access-denied-card p { + font-size: 13.5px; + color: #374151; + line-height: 1.5; + margin: 0 0 14px; +} + +.ul-access-denied-userbox { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 10px 14px; + font-size: 13px; + margin-bottom: 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.ul-access-denied-help { + font-size: 12.5px !important; + color: var(--ul-text-muted) !important; +} + +/* Notifications Toast */ +.ul-toast { + position: fixed; + top: 62px; + right: 24px; + z-index: 1200; + padding: 10px 16px; + border-radius: 6px; + font-size: 13.5px; + font-weight: 600; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2); +} + +.ul-toast-success { + background: #15803d; + color: #ffffff; +} + +.ul-toast-error { + background: #b91c1c; + color: #ffffff; +} + +.ul-toast-info { + background: #1d4ed8; + color: #ffffff; +} + +/* ========================================================================== + Feuille de style d'impression dédiée (@media print) — Format A4 par syndiqué + ========================================================================== */ +@media print { + @page { + size: A4 portrait; + margin: 15mm; + } + + body, + html, + #content, + .ul-app-wrapper { + background: #ffffff !important; + height: auto !important; + overflow: visible !important; + display: block !important; + } + + #header, + #navigation, + #app-navigation, + #app-content, + .ul-no-print { + display: none !important; + } + + #ul-print-container.ul-print-only { + display: block !important; + width: 100% !important; + } + + .ul-print-page { + box-shadow: none !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; + max-width: 100% !important; + page-break-after: always; + break-after: page; + } + + .ul-print-page:last-child { + page-break-after: auto; + break-after: auto; + } +} diff --git a/ul_tresorerie/img/app.svg b/ul_tresorerie/img/app.svg new file mode 100644 index 0000000..20ed60a --- /dev/null +++ b/ul_tresorerie/img/app.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + diff --git a/ul_tresorerie/img/banniere_ul.png b/ul_tresorerie/img/banniere_ul.png new file mode 100644 index 0000000000000000000000000000000000000000..280f82749517043fd1bc73bb86cdfc0e754b3114 GIT binary patch literal 80741 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 jN&K<|RF8QG`2Cv?!CdQUnY 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�}w3 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 VRotjZFG2n*LdQhAMDyCc{{l!USbhKi literal 0 HcmV?d00001 diff --git a/ul_tresorerie/img/cgt_logo.svg b/ul_tresorerie/img/cgt_logo.svg new file mode 100644 index 0000000..c5ccf70 --- /dev/null +++ b/ul_tresorerie/img/cgt_logo.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + diff --git a/ul_tresorerie/js/app_v2.js b/ul_tresorerie/js/app_v2.js new file mode 100644 index 0000000..f11cbca --- /dev/null +++ b/ul_tresorerie/js/app_v2.js @@ -0,0 +1,2617 @@ +'use strict'; + +(function () { + const MONTH_NAMES = { + 1: 'Janvier', + 2: 'Février', + 3: 'Mars', + 4: 'Avril', + 5: 'Mai', + 6: 'Juin', + 7: 'Juillet', + 8: 'Août', + 9: 'Septembre', + 10: 'Octobre', + 11: 'Novembre', + 12: 'Décembre', + }; + + const MONTH_SHORT = { + 1: 'Janv.', + 2: 'Févr.', + 3: 'Mars', + 4: 'Avr.', + 5: 'Mai', + 6: 'Juin', + 7: 'Juil.', + 8: 'Août', + 9: 'Sept.', + 10: 'Oct.', + 11: 'Nov.', + 12: 'Déc.', + }; + + const METHOD_BADGES = { + 'Virement': 'V', + 'Prélèvement': 'P', + 'Chèque': 'C', + 'Espèces': 'E', + }; + + const METHOD_COLORS = { + 'Virement': '#15803d', + 'Prélèvement': '#2563eb', + 'Chèque': '#d97706', + 'Espèces': '#9333ea', + }; + + const SVG_NS = 'http://www.w3.org/2000/svg'; + + const state = { + year: new Date().getFullYear(), + compareYear: new Date().getFullYear() - 1, + currentYear: new Date().getFullYear(), + currentMonth: new Date().getMonth() + 1, + currentDay: new Date().getDate(), + canAccess: true, + isAdmin: false, + logoUrl: '', + bannerUrl: '', + activeView: 'grid', + activeFilter: 'all', + searchQuery: '', + showArchived: false, + members: [], + dues: {}, + multiYearStats: { + availableYears: [], + byYear: {}, + }, + templates: [], + activeTemplateSlug: 'tax_receipt', + selectedRecipientIds: new Set(), + mailingNoEmailOnly: false, + previewMemberId: null, + lastFocusedTemplateField: null, + permissionsLoaded: false, + permissions: { + accessMode: 'restricted', + allowedGroups: new Set(), + allowedUsers: new Set(), + availableGroups: [], + availableUsers: [], + groupSearch: '', + userSearch: '', + }, + }; + + /** + * Génère l'URL vers un endpoint du contrôleur Nextcloud. + */ + function apiUrl(path) { + if (window.OC && typeof window.OC.generateUrl === 'function') { + return window.OC.generateUrl('/apps/ul_tresorerie' + path); + } + return '/index.php/apps/ul_tresorerie' + path; + } + + /** + * Exécute un appel fetch vers le backend avec le jeton CSRF de Nextcloud. + */ + async function apiFetch(path, options = {}) { + const headers = Object.assign( + { + 'requesttoken': (window.OC && window.OC.requestToken) ? window.OC.requestToken : '', + 'OCS-APIRequest': 'true', + }, + options.headers || {} + ); + + if (options.body && !(options.body instanceof FormData) && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(apiUrl(path), Object.assign({}, options, { headers })); + let payload = null; + try { + payload = await response.json(); + } catch (_err) { + payload = null; + } + + if (!response.ok) { + const msg = (payload && payload.error) ? payload.error : ('Erreur HTTP ' + response.status); + throw new Error(msg); + } + + return payload; + } + + /** + * Formate un nombre en euros au format français (ex: 1 234,50 €). + */ + function formatEuros(amount) { + const num = Number(amount) || 0; + return num.toLocaleString('fr-FR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + ' €'; + } + + /** + * Formate un montant court pour les axes de graphiques (ex: 1 250 €). + */ + function formatShortEuros(amount) { + const num = Math.round(Number(amount) || 0); + return num.toLocaleString('fr-FR') + ' €'; + } + + /** + * Affiche une notification temporaire en haut de l'interface. + */ + let toastTimer = null; + function showToast(message, type = 'success') { + const toast = document.getElementById('ul-toast'); + if (!toast) { + return; + } + toast.textContent = message; + toast.className = 'ul-toast ul-toast-' + type; + if (toastTimer) { + clearTimeout(toastTimer); + } + toastTimer = setTimeout(() => { + toast.classList.add('hidden'); + }, 4500); + } + + /** + * Retourne le dernier mois exigible pour une année donnée : + * - Année passée (< currentYear) : 12 + * - Année future (> currentYear) : 0 + * - Année en cours (=== currentYear) : + * - jusqu'au 10 du mois inclus (currentDay <= 10) : mois précédent (currentMonth - 1) + * - après le 10 du mois (currentDay > 10) : mois en cours (currentMonth) + */ + function getMaxRequiredMonth(year = state.year) { + if (year < state.currentYear) { + return 12; + } + if (year > state.currentYear) { + return 0; + } + return state.currentDay > 10 + ? state.currentMonth + : Math.max(0, state.currentMonth - 1); + } + + /** + * Calcule les indicateurs annuels et le niveau d'alerte d'un syndiqué pour l'année sélectionnée. + */ + function computeMemberMetrics(member) { + const memberDues = state.dues[member.id] || {}; + const maxCheckMonth = getMaxRequiredMonth(state.year); + + let totalPaid = 0; + let paidMonthsCount = 0; + let lastPaidAmount = 0; + + for (let m = 1; m <= 12; m++) { + const cell = memberDues[m]; + const amt = cell ? Number(cell.amount) || 0 : 0; + if (amt > 0) { + totalPaid += amt; + paidMonthsCount++; + lastPaidAmount = amt; + } + } + + totalPaid = Math.round(totalPaid * 100) / 100; + + const missingMonths = []; + const missingMonthsNames = []; + for (let m = 1; m <= maxCheckMonth; m++) { + const cell = memberDues[m]; + const amt = cell ? Number(cell.amount) || 0 : 0; + if (amt <= 0) { + missingMonths.push(m); + missingMonthsNames.push(MONTH_NAMES[m]); + } + } + + let alertLevel = 'none'; + if (Number(member.is_archived) === 0) { + if (maxCheckMonth > 0 && totalPaid === 0) { + alertLevel = 'red'; + } else if (missingMonths.length > 0) { + alertLevel = 'orange'; + } else { + alertLevel = 'uptodate'; + } + } + + const usualAmount = (Number(member.default_amount) > 0) + ? Number(member.default_amount) + : lastPaidAmount; + + const creditImpot = Math.round(totalPaid * 0.66 * 100) / 100; + const estimatedArrears = Math.round(missingMonths.length * usualAmount * 100) / 100; + + return { + totalPaid, + paidMonthsCount, + missingMonths, + missingMonthsNames, + alertLevel, + usualAmount, + creditImpot, + estimatedArrears, + }; + } + + /** + * Charge l'état complet depuis le serveur pour l'année active. + */ + async function loadState(year) { + try { + const previousYear = state.year; + const data = await apiFetch('/api/state?year=' + encodeURIComponent(year)); + state.year = Number(data.year) || year; + state.currentYear = Number(data.currentYear) || new Date().getFullYear(); + state.currentMonth = Number(data.currentMonth) || (new Date().getMonth() + 1); + state.currentDay = Number(data.currentDay) || new Date().getDate(); + state.members = Array.isArray(data.members) ? data.members : []; + state.dues = data.dues || {}; + state.templates = Array.isArray(data.templates) ? data.templates : []; + if (data.multiYearStats) { + state.multiYearStats = data.multiYearStats; + } + + if (state.compareYear === state.year || previousYear !== state.year) { + state.compareYear = state.year - 1; + } + + updateYearLabels(); + renderAll(); + syncTemplateEditorFromSlug(state.activeTemplateSlug, true); + } catch (err) { + showToast('Erreur lors du chargement : ' + err.message, 'error'); + } + } + + function updateYearLabels() { + const ids = [ + 'label-current-year', + 'nav-year-indicator', + 'summary-year', + 'mailing-year-label', + 'dash-label-year', + 'kpi-year-label', + 'chart-main-year-label', + 'legend-year-curr', + ]; + ids.forEach((id) => { + const el = document.getElementById(id); + if (el) { + el.textContent = String(state.year); + } + }); + } + + /** + * Met à jour les compteurs de la barre latérale, la grille, le tableau de bord et la vue publipostage. + */ + function renderAll() { + updateSidebarCounters(); + renderGrid(); + renderDashboard(); + if (state.activeView === 'mailing') { + renderMailingRecipients(); + renderMailingPreview(); + } + } + + /** + * Calcule et affiche les compteurs d'alertes sur tous les syndiqués actifs. + */ + function updateSidebarCounters() { + let countAllActive = 0; + let countUpToDate = 0; + let countOrange = 0; + let countRed = 0; + let grandTotalActive = 0; + + state.members.forEach((member) => { + const metrics = computeMemberMetrics(member); + if (Number(member.is_archived) === 0) { + countAllActive++; + grandTotalActive += metrics.totalPaid; + if (metrics.alertLevel === 'uptodate') { + countUpToDate++; + } else if (metrics.alertLevel === 'orange') { + countOrange++; + } else if (metrics.alertLevel === 'red') { + countRed++; + } + } + }); + + setText('count-all', String(countAllActive)); + setText('count-uptodate', String(countUpToDate)); + setText('count-orange', String(countOrange)); + setText('count-orange-inline', String(countOrange)); + setText('count-red', String(countRed)); + setText('count-red-inline', String(countRed)); + setText('summary-active-count', String(countAllActive)); + setText('summary-grand-total', formatEuros(grandTotalActive)); + } + + function setText(id, text) { + const el = document.getElementById(id); + if (el) { + el.textContent = text; + } + } + + /** + * Retourne la liste des syndiqués filtrés selon les critères courants de la grille. + */ + function getFilteredMembersForGrid() { + const q = state.searchQuery.trim().toLowerCase(); + + return state.members.filter((member) => { + if (!state.showArchived && Number(member.is_archived) === 1) { + return false; + } + + const metrics = computeMemberMetrics(member); + if (state.activeFilter === 'uptodate' && metrics.alertLevel !== 'uptodate') { + return false; + } + if (state.activeFilter === 'orange' && metrics.alertLevel !== 'orange') { + return false; + } + if (state.activeFilter === 'red' && metrics.alertLevel !== 'red') { + return false; + } + + if (q !== '') { + const haystack = [ + member.last_name, + member.first_name, + member.city, + member.email, + member.cogitiel_id, + member.postal_code, + ].join(' ').toLowerCase(); + if (!haystack.includes(q)) { + return false; + } + } + + return true; + }); + } + + /** + * Construit le tableau annuel des cotisations et met à jour les totaux mensuels et annuels. + */ + function renderGrid() { + const tbody = document.getElementById('dues-tbody'); + if (!tbody) { + return; + } + tbody.textContent = ''; + + const filteredMembers = getFilteredMembersForGrid(); + const monthlyTotals = { + 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, + 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, + }; + let grandTotal = 0; + + updateFilterPill(); + + if (filteredMembers.length === 0) { + const emptyRow = document.createElement('tr'); + const emptyCell = document.createElement('td'); + emptyCell.colSpan = 14; + emptyCell.className = 'ul-empty-cell'; + emptyCell.textContent = state.members.length === 0 + ? 'Aucun syndiqué en base. Cliquez sur « Importer fichier .asp » ou « + Nouveau syndiqué » pour commencer.' + : 'Aucun syndiqué ne correspond aux critères de recherche ou de filtre sélectionnés.'; + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + } else { + filteredMembers.forEach((member) => { + const metrics = computeMemberMetrics(member); + const memberDues = state.dues[member.id] || {}; + + const tr = document.createElement('tr'); + tr.dataset.memberId = String(member.id); + + if (Number(member.is_archived) === 1) { + tr.classList.add('ul-row-archived'); + } else if (metrics.alertLevel === 'red') { + tr.classList.add('ul-row-alert-red'); + } else if (metrics.alertLevel === 'orange') { + tr.classList.add('ul-row-alert-orange'); + } + + // Colonne 1 figée : Nom, Prénom, Badges d'alerte & bouton édition + const tdMember = document.createElement('td'); + tdMember.className = 'col-sticky-left ul-member-cell'; + + const memberTopRow = document.createElement('div'); + memberTopRow.className = 'ul-member-top-row'; + + const nameWrap = document.createElement('div'); + nameWrap.className = 'ul-member-name-wrap'; + + const fullName = document.createElement('span'); + fullName.className = 'ul-member-fullname'; + fullName.textContent = (member.last_name + ' ' + member.first_name).trim(); + nameWrap.appendChild(fullName); + + const subMeta = document.createElement('span'); + subMeta.className = 'ul-member-submeta'; + const metaParts = ['#' + member.cogitiel_id]; + if (member.city) { + metaParts.push(member.city); + } + subMeta.textContent = metaParts.join(' · '); + nameWrap.appendChild(subMeta); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'ul-btn-edit-member'; + editBtn.dataset.action = 'edit-member'; + editBtn.dataset.memberId = String(member.id); + editBtn.title = 'Modifier la fiche du syndiqué'; + editBtn.textContent = '✏️'; + + memberTopRow.appendChild(nameWrap); + memberTopRow.appendChild(editBtn); + tdMember.appendChild(memberTopRow); + + // Badges d'alerte et statut + const badgesRow = document.createElement('div'); + badgesRow.className = 'ul-badges-row'; + + if (Number(member.is_archived) === 1) { + const bArch = document.createElement('span'); + bArch.className = 'ul-badge ul-badge-archived'; + bArch.textContent = 'Archivé'; + badgesRow.appendChild(bArch); + } else if (metrics.alertLevel === 'red') { + const bRed = document.createElement('span'); + bRed.className = 'ul-badge ul-badge-red'; + bRed.textContent = '🚨 Aucun versement'; + badgesRow.appendChild(bRed); + } else if (metrics.alertLevel === 'orange') { + const bOrange = document.createElement('span'); + bOrange.className = 'ul-badge ul-badge-orange'; + bOrange.textContent = '🟠 ' + metrics.missingMonths.length + ' mois manquant' + (metrics.missingMonths.length > 1 ? 's' : ''); + bOrange.title = 'Mois manquants : ' + metrics.missingMonthsNames.join(', '); + badgesRow.appendChild(bOrange); + } else if (metrics.alertLevel === 'uptodate') { + const bGreen = document.createElement('span'); + bGreen.className = 'ul-badge ul-badge-green'; + bGreen.textContent = '✓ À jour'; + badgesRow.appendChild(bGreen); + } + + tdMember.appendChild(badgesRow); + tr.appendChild(tdMember); + + // Colonnes 2 à 13 : les 12 mois + for (let m = 1; m <= 12; m++) { + const tdMonth = document.createElement('td'); + tdMonth.dataset.action = 'open-due'; + tdMonth.dataset.memberId = String(member.id); + tdMonth.dataset.month = String(m); + + const due = memberDues[m]; + const amount = due ? (Number(due.amount) || 0) : 0; + + if (amount > 0) { + monthlyTotals[m] += amount; + tdMonth.className = 'ul-due-cell ul-due-paid'; + + const amountSpan = document.createElement('span'); + amountSpan.className = 'ul-due-amount'; + amountSpan.textContent = formatEuros(amount); + tdMonth.appendChild(amountSpan); + + const methodBadge = document.createElement('span'); + methodBadge.className = 'ul-due-method-badge'; + const methodLetter = METHOD_BADGES[due.payment_method] || 'V'; + methodBadge.textContent = methodLetter; + tdMonth.appendChild(methodBadge); + + let tooltip = MONTH_NAMES[m] + ' ' + state.year + ' : ' + formatEuros(amount) + ' (' + due.payment_method + ')'; + if (due.note) { + tooltip += ' — Note : ' + due.note; + const noteDot = document.createElement('span'); + noteDot.className = 'ul-due-note-dot'; + noteDot.textContent = '•'; + tdMonth.appendChild(noteDot); + } + tdMonth.title = tooltip; + } else { + tdMonth.className = 'ul-due-cell ul-due-empty'; + tdMonth.title = 'Cliquer pour saisir la cotisation de ' + MONTH_NAMES[m] + ' ' + state.year; + const plusHint = document.createElement('span'); + plusHint.className = 'ul-due-empty-plus'; + plusHint.textContent = '+'; + tdMonth.appendChild(plusHint); + } + + tr.appendChild(tdMonth); + } + + // Colonne 14 figée à droite : Total Annuel du syndiqué + grandTotal += metrics.totalPaid; + const tdTotal = document.createElement('td'); + tdTotal.className = 'col-sticky-right ul-member-total-cell'; + if (metrics.totalPaid === 0) { + tdTotal.classList.add('ul-total-zero'); + } + tdTotal.textContent = formatEuros(metrics.totalPaid); + tr.appendChild(tdTotal); + + tbody.appendChild(tr); + }); + } + + // Mise à jour du pied de tableau (Total Mensuel & Grand Total Annuel) + setText('tfoot-members-count', String(filteredMembers.length)); + for (let m = 1; m <= 12; m++) { + const roundedMonth = Math.round(monthlyTotals[m] * 100) / 100; + setText('foot-month-' + m, formatEuros(roundedMonth)); + } + const roundedGrandTotal = Math.round(grandTotal * 100) / 100; + setText('foot-grand-total', formatEuros(roundedGrandTotal)); + } + + function updateFilterPill() { + const pill = document.getElementById('active-filter-indicator'); + if (!pill) { + return; + } + const labels = { + all: '', + uptodate: 'Filtre actif : 🟢 Cotisations à jour', + orange: 'Filtre actif : 🟠 Cotisations pas à jour (Mois manquants)', + red: 'Filtre actif : 🔴 Aucun versement sur l\'année (0,00 €)', + }; + const text = labels[state.activeFilter] || ''; + if (text === '') { + pill.classList.add('hidden'); + pill.textContent = ''; + } else { + pill.classList.remove('hidden'); + pill.textContent = text + ' (Cliquer pour afficher tous)'; + } + } + + /* ====================================================================== + * TABLEAU DE BORD & COMPARAISON PLURIANNUELLE (#view-dashboard) + * ====================================================================== */ + + function getEmptyYearStats(year) { + return { + year: year, + totalAmount: 0, + duesCount: 0, + payingMembersCount: 0, + monthlyTotals: { + 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, + 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, + }, + monthlyCounts: { + 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, + 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, + }, + byMethod: { + 'Virement': 0, + 'Prélèvement': 0, + 'Chèque': 0, + 'Espèces': 0, + }, + }; + } + + function getYearStatsObject(year) { + const byYear = (state.multiYearStats && state.multiYearStats.byYear) || {}; + return byYear[year] || byYear[String(year)] || getEmptyYearStats(year); + } + + /** + * Met à jour l'intégralité du Tableau de bord (Chiffres clés, Graphiques SVG et Tableau comparatif). + */ + function renderDashboard() { + const dashSection = document.getElementById('view-dashboard'); + if (!dashSection) { + return; + } + + // 1. Synchroniser le sélecteur d'année de comparaison + const compareSelect = document.getElementById('dash-select-compare-year'); + const availYears = Array.isArray(state.multiYearStats.availableYears) && state.multiYearStats.availableYears.length > 0 + ? state.multiYearStats.availableYears + : [state.year - 2, state.year - 1, state.year, state.year + 1]; + + const compareChoices = availYears.filter((y) => Number(y) !== Number(state.year)); + if (!compareChoices.includes(state.compareYear)) { + state.compareYear = state.year - 1; + if (!compareChoices.includes(state.compareYear)) { + compareChoices.unshift(state.compareYear); + } + } + + if (compareSelect) { + compareSelect.textContent = ''; + compareChoices.sort((a, b) => Number(b) - Number(a)).forEach((y) => { + const opt = document.createElement('option'); + opt.value = String(y); + const statsY = getYearStatsObject(y); + opt.textContent = y + ' (' + formatEuros(statsY.totalAmount) + ')'; + compareSelect.appendChild(opt); + }); + compareSelect.value = String(state.compareYear); + } + + setText('chart-main-year-label', String(state.year)); + setText('chart-main-comp-label', String(state.compareYear)); + setText('legend-year-curr', String(state.year)); + setText('legend-year-comp', String(state.compareYear)); + + const currStats = getYearStatsObject(state.year); + const compStats = getYearStatsObject(state.compareYear); + + // 2. Calculer les indicateurs syndiqués sur l'année active + let activeCount = 0; + let upToDateCount = 0; + let orangeCount = 0; + let redCount = 0; + let totalMissingMonths = 0; + let totalEstimatedArrears = 0; + + state.members.forEach((member) => { + if (Number(member.is_archived) === 0) { + activeCount++; + const m = computeMemberMetrics(member); + if (m.alertLevel === 'uptodate') { + upToDateCount++; + } else if (m.alertLevel === 'orange') { + orangeCount++; + } else if (m.alertLevel === 'red') { + redCount++; + } + totalMissingMonths += m.missingMonths.length; + totalEstimatedArrears += m.estimatedArrears; + } + }); + + // 3. Remplir les 5 cartes KPI + const currTotal = Number(currStats.totalAmount) || 0; + const compTotal = Number(compStats.totalAmount) || 0; + const diffEuro = currTotal - compTotal; + const diffEl = document.getElementById('kpi-total-diff'); + if (diffEl) { + if (compTotal > 0) { + const pct = Math.round(((currTotal - compTotal) / compTotal) * 1000) / 10; + const sign = pct >= 0 ? '+' : ''; + diffEl.textContent = sign + pct.toLocaleString('fr-FR') + ' % vs ' + state.compareYear; + diffEl.className = 'ul-kpi-trend ' + (pct >= 0 ? 'ul-trend-up' : 'ul-trend-down'); + } else if (currTotal > 0) { + diffEl.textContent = 'Nouveau vs ' + state.compareYear; + diffEl.className = 'ul-kpi-trend ul-trend-up'; + } else { + diffEl.textContent = '0 % vs ' + state.compareYear; + diffEl.className = 'ul-kpi-trend'; + } + } + + setText('kpi-total-amount', formatEuros(currTotal)); + const signDiff = diffEuro >= 0 ? '+' : ''; + setText( + 'kpi-total-sub', + 'En ' + state.compareYear + ' : ' + formatEuros(compTotal) + ' (' + signDiff + formatEuros(diffEuro) + ')' + ); + + const maxReqMonth = getMaxRequiredMonth(state.year); + const activeMonthsElapsed = (state.year < state.currentYear) + ? 12 + : Math.max(1, maxReqMonth); + const monthlyAvg = currTotal / activeMonthsElapsed; + const avgDue = currStats.duesCount > 0 ? (currTotal / currStats.duesCount) : 0; + setText('kpi-monthly-avg', formatEuros(monthlyAvg)); + setText('kpi-avg-due', 'Cotisation moy. : ' + formatEuros(avgDue) + ' / mois'); + + const recoveryRate = activeCount > 0 ? Math.round((upToDateCount / activeCount) * 100) : 0; + setText('kpi-recovery-rate', recoveryRate + ' %'); + setText('kpi-recovery-sub', upToDateCount + ' à jour sur ' + activeCount + ' syndiqués actifs'); + + setText('kpi-estimated-arrears', formatEuros(totalEstimatedArrears)); + setText('kpi-arrears-sub', totalMissingMonths + ' mois manquant(s) cumulé(s)'); + + const taxCreditTotal = Math.round(currTotal * 0.66 * 100) / 100; + setText('kpi-tax-credit', formatEuros(taxCreditTotal)); + setText( + 'kpi-dues-count', + currStats.duesCount + ' mensualité(s) · ' + currStats.payingMembersCount + ' cotisant(s)' + ); + + // 4. Dessiner les graphiques SVG et le tableau comparatif + renderMonthlyComparisonSvg(currStats, compStats); + renderMultiYearSvg(availYears); + renderMethodsAndStatusCard(currStats, { + activeCount, + upToDateCount, + orangeCount, + redCount, + }); + renderMonthlyComparisonTable(currStats, compStats); + } + + /** + * Crée un élément SVG avec attributs. + */ + function createSvgEl(tag, attrs = {}) { + const el = document.createElementNS(SVG_NS, tag); + Object.entries(attrs).forEach(([k, v]) => { + el.setAttribute(k, String(v)); + }); + return el; + } + + /** + * Graphique SVG principal : Barres mensuelles (Année N vs Année comparée) + Courbes cumulées. + */ + function renderMonthlyComparisonSvg(currStats, compStats) { + const container = document.getElementById('dash-chart-monthly-comparison'); + if (!container) { + return; + } + container.textContent = ''; + + const width = 760; + const height = 250; + const padLeft = 58; + const padRight = 58; + const padTop = 20; + const padBottom = 34; + const plotW = width - padLeft - padRight; + const plotH = height - padTop - padBottom; + + let maxMonthVal = 10; + let cumCurr = 0; + let cumComp = 0; + const cumCurrArr = []; + const cumCompArr = []; + + for (let m = 1; m <= 12; m++) { + const vCurr = Number(currStats.monthlyTotals[m]) || 0; + const vComp = Number(compStats.monthlyTotals[m]) || 0; + if (vCurr > maxMonthVal) { + maxMonthVal = vCurr; + } + if (vComp > maxMonthVal) { + maxMonthVal = vComp; + } + cumCurr += vCurr; + cumComp += vComp; + cumCurrArr.push(cumCurr); + cumCompArr.push(cumComp); + } + + const maxCumVal = Math.max(cumCurr, cumComp, maxMonthVal, 50); + const niceMaxMonth = Math.ceil(maxMonthVal * 1.15 / 10) * 10 || 100; + const niceMaxCum = Math.ceil(maxCumVal * 1.1 / 50) * 50 || 500; + + const svg = createSvgEl('svg', { + viewBox: `0 0 ${width} ${height}`, + class: 'ul-svg-chart', + role: 'img', + 'aria-label': `Évolution mensuelle ${state.year} vs ${state.compareYear}`, + }); + + // Lignes de grille horizontales (4 paliers) + const steps = 4; + for (let i = 0; i <= steps; i++) { + const ratio = i / steps; + const y = padTop + plotH - ratio * plotH; + const monthAxisVal = Math.round(niceMaxMonth * ratio); + const cumAxisVal = Math.round(niceMaxCum * ratio); + + svg.appendChild(createSvgEl('line', { + x1: padLeft, + y1: y, + x2: width - padRight, + y2: y, + stroke: i === 0 ? '#94a3b8' : '#e2e8f0', + 'stroke-width': i === 0 ? 1.5 : 1, + 'stroke-dasharray': i === 0 ? '' : '3,3', + })); + + // Axe gauche (Mensuel) + const lblLeft = createSvgEl('text', { + x: padLeft - 6, + y: y + 4, + 'text-anchor': 'end', + fill: '#475569', + 'font-size': '10', + 'font-weight': '600', + }); + lblLeft.textContent = monthAxisVal + ' €'; + svg.appendChild(lblLeft); + + // Axe droit (Cumul annuel) + const lblRight = createSvgEl('text', { + x: width - padRight + 6, + y: y + 4, + 'text-anchor': 'start', + fill: '#991b1b', + 'font-size': '10', + 'font-weight': '600', + }); + lblRight.textContent = cumAxisVal + ' €'; + svg.appendChild(lblRight); + } + + const groupWidth = plotW / 12; + const barWidth = Math.min(16, (groupWidth - 12) / 2); + + const currCumPoints = []; + const compCumPoints = []; + + for (let m = 1; m <= 12; m++) { + const vCurr = Number(currStats.monthlyTotals[m]) || 0; + const vComp = Number(compStats.monthlyTotals[m]) || 0; + const groupX = padLeft + (m - 1) * groupWidth; + const centerX = groupX + groupWidth / 2; + + // Barre Année comparée (Gris ardoise à gauche) + const hComp = Math.max(0, (vComp / niceMaxMonth) * plotH); + const rectComp = createSvgEl('rect', { + x: centerX - barWidth - 1.5, + y: padTop + plotH - hComp, + width: barWidth, + height: Math.max(hComp, 2), + rx: 2, + fill: '#94a3b8', + class: 'ul-chart-bar', + }); + const titleComp = createSvgEl('title'); + titleComp.textContent = `${MONTH_NAMES[m]} ${state.compareYear} : ${formatEuros(vComp)} (Cumul : ${formatEuros(cumCompArr[m - 1])})`; + rectComp.appendChild(titleComp); + svg.appendChild(rectComp); + + // Barre Année active (Rouge CGT à droite) + const hCurr = Math.max(0, (vCurr / niceMaxMonth) * plotH); + const rectCurr = createSvgEl('rect', { + x: centerX + 1.5, + y: padTop + plotH - hCurr, + width: barWidth, + height: Math.max(hCurr, 2), + rx: 2, + fill: '#d32f2f', + class: 'ul-chart-bar', + }); + const titleCurr = createSvgEl('title'); + titleCurr.textContent = `${MONTH_NAMES[m]} ${state.year} : ${formatEuros(vCurr)} (Cumul : ${formatEuros(cumCurrArr[m - 1])})`; + rectCurr.appendChild(titleCurr); + svg.appendChild(rectCurr); + + // Étiquette du mois en bas + const lblMonth = createSvgEl('text', { + x: centerX, + y: height - 10, + 'text-anchor': 'middle', + fill: '#1e293b', + 'font-size': '11', + 'font-weight': '700', + }); + lblMonth.textContent = MONTH_SHORT[m]; + svg.appendChild(lblMonth); + + // Coordonnées des courbes de cumul (sur l'axe de droite) + const yCumComp = padTop + plotH - (cumCompArr[m - 1] / niceMaxCum) * plotH; + const yCumCurr = padTop + plotH - (cumCurrArr[m - 1] / niceMaxCum) * plotH; + compCumPoints.push({ x: centerX, y: yCumComp, val: cumCompArr[m - 1], month: m }); + currCumPoints.push({ x: centerX, y: yCumCurr, val: cumCurrArr[m - 1], month: m }); + } + + // Courbe cumulée Année comparée (pointillés gris foncé) + const compPolyline = compCumPoints.map((p) => `${p.x},${p.y}`).join(' '); + svg.appendChild(createSvgEl('polyline', { + fill: 'none', + stroke: '#475569', + 'stroke-width': 2, + 'stroke-dasharray': '4,3', + points: compPolyline, + })); + + // Courbe cumulée Année active (ligne continue rouge foncé) + const currPolyline = currCumPoints.map((p) => `${p.x},${p.y}`).join(' '); + svg.appendChild(createSvgEl('polyline', { + fill: 'none', + stroke: '#991b1b', + 'stroke-width': 2.5, + points: currPolyline, + })); + + // Points sur la courbe cumulée active + currCumPoints.forEach((p) => { + const dot = createSvgEl('circle', { + cx: p.x, + cy: p.y, + r: 3.5, + fill: '#ffffff', + stroke: '#991b1b', + 'stroke-width': 2, + }); + const t = createSvgEl('title'); + t.textContent = `Cumul ${MONTH_NAMES[p.month]} ${state.year} : ${formatEuros(p.val)}`; + dot.appendChild(t); + svg.appendChild(dot); + }); + + container.appendChild(svg); + } + + /** + * Graphique SVG : Comparaison pluriannuelle (Barres par année cliquables). + */ + function renderMultiYearSvg(availYears) { + const container = document.getElementById('dash-chart-multiyear'); + if (!container) { + return; + } + container.textContent = ''; + + const sortedYears = [...availYears].map(Number).sort((a, b) => a - b); + const width = 380; + const height = 128; + const padLeft = 14; + const padRight = 14; + const padTop = 22; + const padBottom = 26; + const plotW = width - padLeft - padRight; + const plotH = height - padTop - padBottom; + + let maxYearTotal = 50; + sortedYears.forEach((y) => { + const st = getYearStatsObject(y); + if (st.totalAmount > maxYearTotal) { + maxYearTotal = st.totalAmount; + } + }); + const niceMax = maxYearTotal * 1.18; + + const svg = createSvgEl('svg', { + viewBox: `0 0 ${width} ${height}`, + class: 'ul-svg-chart', + role: 'img', + 'aria-label': 'Comparaison pluriannuelle des cotisations', + }); + + const colW = plotW / Math.max(1, sortedYears.length); + const barW = Math.min(44, colW * 0.58); + + sortedYears.forEach((y, idx) => { + const st = getYearStatsObject(y); + const val = Number(st.totalAmount) || 0; + const h = Math.max(3, (val / niceMax) * plotH); + const centerX = padLeft + idx * colW + colW / 2; + const x = centerX - barW / 2; + const barY = padTop + plotH - h; + const isCurrent = y === state.year; + const isCompare = y === state.compareYear; + + const g = createSvgEl('g', { + class: 'ul-multiyear-bar-group', + 'data-year': String(y), + }); + + const rect = createSvgEl('rect', { + x: x, + y: barY, + width: barW, + height: h, + rx: 4, + fill: isCurrent ? '#d32f2f' : (isCompare ? '#475569' : '#cbd5e1'), + }); + const title = createSvgEl('title'); + title.textContent = `Année ${y} : ${formatEuros(val)} (${st.payingMembersCount} cotisants, ${st.duesCount} versements) — Cliquer pour ouvrir ${y}`; + rect.appendChild(title); + g.appendChild(rect); + + const valLbl = createSvgEl('text', { + x: centerX, + y: barY - 5, + 'text-anchor': 'middle', + fill: isCurrent ? '#991b1b' : '#334155', + 'font-size': '10', + 'font-weight': '700', + }); + valLbl.textContent = formatShortEuros(val); + g.appendChild(valLbl); + + const yearLbl = createSvgEl('text', { + x: centerX, + y: height - 7, + 'text-anchor': 'middle', + fill: isCurrent ? '#d32f2f' : '#1e293b', + 'font-size': '11.5', + 'font-weight': isCurrent ? '800' : '600', + }); + yearLbl.textContent = String(y); + g.appendChild(yearLbl); + + svg.appendChild(g); + }); + + container.appendChild(svg); + } + + /** + * Carte Modes de règlement & Répartition des statuts des syndiqués actifs. + */ + function renderMethodsAndStatusCard(currStats, counts) { + const container = document.getElementById('dash-chart-methods-status'); + if (!container) { + return; + } + container.textContent = ''; + + const totalAmount = Number(currStats.totalAmount) || 0; + const methods = ['Virement', 'Prélèvement', 'Chèque', 'Espèces']; + + const methodsBox = document.createElement('div'); + methodsBox.className = 'ul-methods-breakdown'; + + methods.forEach((method) => { + const amt = (currStats.byMethod && Number(currStats.byMethod[method])) || 0; + const pct = totalAmount > 0 ? Math.round((amt / totalAmount) * 100) : 0; + + const row = document.createElement('div'); + row.className = 'ul-method-stat-row'; + + const head = document.createElement('div'); + head.className = 'ul-method-stat-head'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'ul-method-stat-name'; + const dot = document.createElement('span'); + dot.className = 'ul-method-color-dot'; + dot.style.backgroundColor = METHOD_COLORS[method] || '#15803d'; + nameSpan.appendChild(dot); + nameSpan.appendChild(document.createTextNode(method)); + + const valSpan = document.createElement('strong'); + valSpan.textContent = formatEuros(amt) + ' (' + pct + ' %)'; + + head.appendChild(nameSpan); + head.appendChild(valSpan); + + const barTrack = document.createElement('div'); + barTrack.className = 'ul-progress-track'; + const barFill = document.createElement('div'); + barFill.className = 'ul-progress-fill'; + barFill.style.width = pct + '%'; + barFill.style.backgroundColor = METHOD_COLORS[method] || '#15803d'; + barTrack.appendChild(barFill); + + row.appendChild(head); + row.appendChild(barTrack); + methodsBox.appendChild(row); + }); + + // Barre empilée de statut des syndiqués actifs + const statusBox = document.createElement('div'); + statusBox.className = 'ul-status-breakdown'; + + const statusTitle = document.createElement('div'); + statusTitle.className = 'ul-status-breakdown-title'; + statusTitle.textContent = 'État des ' + counts.activeCount + ' syndiqués actifs (' + state.year + ')'; + statusBox.appendChild(statusTitle); + + const stackedBar = document.createElement('div'); + stackedBar.className = 'ul-stacked-status-bar'; + + const totalActive = Math.max(1, counts.activeCount); + const segments = [ + { label: 'À jour', count: counts.upToDateCount, color: '#15803d' }, + { label: 'Pas à jour', count: counts.orangeCount, color: '#d97706' }, + { label: '0 € versé', count: counts.redCount, color: '#dc2626' }, + ]; + + segments.forEach((seg) => { + if (seg.count > 0) { + const segDiv = document.createElement('div'); + segDiv.className = 'ul-stacked-segment'; + segDiv.style.width = ((seg.count / totalActive) * 100).toFixed(1) + '%'; + segDiv.style.backgroundColor = seg.color; + segDiv.title = seg.label + ' : ' + seg.count; + stackedBar.appendChild(segDiv); + } + }); + statusBox.appendChild(stackedBar); + + const pillsWrap = document.createElement('div'); + pillsWrap.className = 'ul-status-mini-pills'; + segments.forEach((seg) => { + const p = document.createElement('span'); + p.className = 'ul-status-mini-pill'; + p.textContent = seg.label + ' : ' + seg.count; + pillsWrap.appendChild(p); + }); + + statusBox.appendChild(pillsWrap); + + container.appendChild(methodsBox); + container.appendChild(statusBox); + } + + /** + * Tableau comparatif mensuel synthétique (Ligne Année N, Ligne Année comparée, Ligne Écart). + */ + function renderMonthlyComparisonTable(currStats, compStats) { + const tbody = document.getElementById('dash-comp-tbody'); + if (!tbody) { + return; + } + tbody.textContent = ''; + + // Ligne 1 : Année active + const trCurr = document.createElement('tr'); + trCurr.className = 'ul-comp-row-curr'; + const thCurr = document.createElement('th'); + thCurr.textContent = 'Année ' + state.year; + trCurr.appendChild(thCurr); + for (let m = 1; m <= 12; m++) { + const td = document.createElement('td'); + td.textContent = formatShortEuros(currStats.monthlyTotals[m] || 0); + trCurr.appendChild(td); + } + const tdTotalCurr = document.createElement('td'); + tdTotalCurr.className = 'ul-comp-col-total'; + tdTotalCurr.textContent = formatEuros(currStats.totalAmount || 0); + trCurr.appendChild(tdTotalCurr); + tbody.appendChild(trCurr); + + // Ligne 2 : Année de comparaison + const trComp = document.createElement('tr'); + trComp.className = 'ul-comp-row-prev'; + const thComp = document.createElement('th'); + thComp.textContent = 'Année ' + state.compareYear; + trComp.appendChild(thComp); + for (let m = 1; m <= 12; m++) { + const td = document.createElement('td'); + td.textContent = formatShortEuros(compStats.monthlyTotals[m] || 0); + trComp.appendChild(td); + } + const tdTotalComp = document.createElement('td'); + tdTotalComp.className = 'ul-comp-col-total'; + tdTotalComp.textContent = formatEuros(compStats.totalAmount || 0); + trComp.appendChild(tdTotalComp); + tbody.appendChild(trComp); + + // Ligne 3 : Écart mensuel (€) + const trDiff = document.createElement('tr'); + trDiff.className = 'ul-comp-row-diff'; + const thDiff = document.createElement('th'); + thDiff.textContent = 'Écart (' + state.year + ' - ' + state.compareYear + ')'; + trDiff.appendChild(thDiff); + for (let m = 1; m <= 12; m++) { + const diff = (Number(currStats.monthlyTotals[m]) || 0) - (Number(compStats.monthlyTotals[m]) || 0); + const td = document.createElement('td'); + const sign = diff > 0 ? '+' : ''; + td.textContent = sign + formatShortEuros(diff); + td.className = diff > 0 ? 'ul-diff-pos' : (diff < 0 ? 'ul-diff-neg' : 'ul-diff-zero'); + trDiff.appendChild(td); + } + const totalDiff = (Number(currStats.totalAmount) || 0) - (Number(compStats.totalAmount) || 0); + const tdTotalDiff = document.createElement('td'); + const totalSign = totalDiff > 0 ? '+' : ''; + tdTotalDiff.textContent = totalSign + formatEuros(totalDiff); + tdTotalDiff.className = 'ul-comp-col-total ' + (totalDiff >= 0 ? 'ul-diff-pos' : 'ul-diff-neg'); + trDiff.appendChild(tdTotalDiff); + tbody.appendChild(trDiff); + } + + /* ====================================================================== + * SAISIE DE COTISATIONS & GESTION DES FICHES SYNDIQUÉS + * ====================================================================== */ + + /** + * Détermine le pré-remplissage intelligent pour un syndiqué et un mois cliqué. + */ + function computePrefillForCell(member, month) { + const memberDues = state.dues[member.id] || {}; + const existingDue = memberDues[month]; + + if (existingDue && Number(existingDue.amount) > 0) { + return { + isExisting: true, + amount: Number(existingDue.amount).toFixed(2), + method: existingDue.payment_method || 'Virement', + note: existingDue.note || '', + hint: 'Modification de la cotisation existante de ' + MONTH_NAMES[month] + ' ' + state.year + '.', + }; + } + + // 1. Chercher le mois précédent M-1, ou en remontant vers Janvier + for (let prevM = month - 1; prevM >= 1; prevM--) { + const d = memberDues[prevM]; + if (d && Number(d.amount) > 0) { + return { + isExisting: false, + amount: Number(d.amount).toFixed(2), + method: d.payment_method || member.default_method || 'Virement', + note: '', + hint: 'Pré-rempli automatiquement d\'après ' + MONTH_NAMES[prevM] + ' (' + formatEuros(d.amount) + ' — ' + d.payment_method + '). Appuyez sur Entrée pour valider.', + }; + } + } + + // 2. À défaut, chercher n'importe quel autre mois payé dans l'année + for (let m = 12; m >= 1; m--) { + const d = memberDues[m]; + if (d && Number(d.amount) > 0) { + return { + isExisting: false, + amount: Number(d.amount).toFixed(2), + method: d.payment_method || member.default_method || 'Virement', + note: '', + hint: 'Pré-rempli d\'après ' + MONTH_NAMES[m] + ' (' + formatEuros(d.amount) + ' — ' + d.payment_method + '). Appuyez sur Entrée pour valider.', + }; + } + } + + // 3. À défaut, utiliser default_amount et default_method de la fiche du syndiqué + const fallbackAmount = Number(member.default_amount) > 0 ? Number(member.default_amount).toFixed(2) : ''; + const fallbackMethod = member.default_method || 'Virement'; + + return { + isExisting: false, + amount: fallbackAmount, + method: fallbackMethod, + note: '', + hint: fallbackAmount !== '' + ? 'Pré-rempli avec la cotisation habituelle du syndiqué (' + formatEuros(fallbackAmount) + ' — ' + fallbackMethod + '). Appuyez sur Entrée pour valider.' + : 'Première saisie pour ce syndiqué : indiquez le montant mensuel puis appuyez sur Entrée.', + }; + } + + /** + * Ouvre la fenêtre modale légère de saisie de cotisation au simple clic sur une cellule. + */ + function openDueModal(memberId, month) { + const member = state.members.find((m) => Number(m.id) === Number(memberId)); + if (!member) { + return; + } + + const prefill = computePrefillForCell(member, month); + const modal = document.getElementById('due-modal'); + const subtitle = document.getElementById('due-modal-subtitle'); + const hintEl = document.getElementById('due-modal-prefill-hint'); + const inputMemberId = document.getElementById('due-member-id'); + const inputYear = document.getElementById('due-year'); + const inputMonthStart = document.getElementById('due-month-start'); + const inputAmount = document.getElementById('due-input-amount'); + const inputMethod = document.getElementById('due-input-method'); + const selectMonthEnd = document.getElementById('due-input-month-end'); + const inputNote = document.getElementById('due-input-note'); + const btnDelete = document.getElementById('btn-delete-due'); + + subtitle.textContent = (member.civility ? member.civility + ' ' : '') + + member.last_name + ' ' + member.first_name + ' — ' + MONTH_NAMES[month] + ' ' + state.year; + hintEl.textContent = prefill.hint; + + inputMemberId.value = String(member.id); + inputYear.value = String(state.year); + inputMonthStart.value = String(month); + inputAmount.value = prefill.amount; + inputMethod.value = prefill.method; + inputNote.value = prefill.note; + + // Remplir le sélecteur d'extension multi-mois (de month à 12) + selectMonthEnd.textContent = ''; + for (let m = month; m <= 12; m++) { + const opt = document.createElement('option'); + opt.value = String(m); + opt.textContent = m === month + ? (MONTH_NAMES[m] + ' uniquement (1 mois)') + : ('Jusqu\'à ' + MONTH_NAMES[m] + ' inclus (' + (m - month + 1) + ' mois)'); + selectMonthEnd.appendChild(opt); + } + selectMonthEnd.value = String(month); + + if (prefill.isExisting) { + btnDelete.classList.remove('hidden'); + } else { + btnDelete.classList.add('hidden'); + } + + modal.classList.remove('hidden'); + + // Focus immédiat et sélection du montant pour validation directe avec Entrée + requestAnimationFrame(() => { + inputAmount.focus(); + inputAmount.select(); + }); + } + + function closeDueModal() { + const modal = document.getElementById('due-modal'); + if (modal) { + modal.classList.add('hidden'); + } + } + + /** + * Enregistre la cotisation (1 ou plusieurs mois) via l'API. + */ + async function handleDueFormSubmit(event) { + event.preventDefault(); + + const memberId = Number(document.getElementById('due-member-id').value); + const year = Number(document.getElementById('due-year').value); + const monthStart = Number(document.getElementById('due-month-start').value); + const monthEnd = Number(document.getElementById('due-input-month-end').value); + const amount = parseFloat(document.getElementById('due-input-amount').value); + const paymentMethod = document.getElementById('due-input-method').value; + const note = document.getElementById('due-input-note').value; + + if (isNaN(amount) || amount <= 0) { + showToast('Veuillez saisir un montant valide supérieur à 0,00 €.', 'error'); + return; + } + + try { + const res = await apiFetch('/api/dues', { + method: 'POST', + body: JSON.stringify({ + member_id: memberId, + year: year, + month_start: monthStart, + month_end: monthEnd, + amount: amount, + payment_method: paymentMethod, + note: note, + }), + }); + + if (!state.dues[memberId]) { + state.dues[memberId] = {}; + } + Object.entries(res.dues || {}).forEach(([m, dueObj]) => { + state.dues[memberId][Number(m)] = dueObj; + }); + + if (res.multiYearStats) { + state.multiYearStats = res.multiYearStats; + } + + // Mettre à jour les valeurs de référence locales du syndiqué + const member = state.members.find((m) => Number(m.id) === memberId); + if (member) { + member.default_amount = amount; + member.default_method = paymentMethod; + } + + closeDueModal(); + renderAll(); + + const countMonths = monthEnd - monthStart + 1; + showToast( + countMonths > 1 + ? ('Cotisations enregistrées (' + countMonths + ' mois : ' + MONTH_SHORT[monthStart] + ' → ' + MONTH_SHORT[monthEnd] + ').') + : ('Cotisation de ' + MONTH_NAMES[monthStart] + ' enregistrée (' + formatEuros(amount) + ').') + ); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Efface la cotisation du mois sélectionné. + */ + async function handleDeleteDue() { + const memberId = Number(document.getElementById('due-member-id').value); + const year = Number(document.getElementById('due-year').value); + const month = Number(document.getElementById('due-month-start').value); + + try { + const res = await apiFetch('/api/dues/delete', { + method: 'POST', + body: JSON.stringify({ + member_id: memberId, + year: year, + month: month, + }), + }); + + if (state.dues[memberId]) { + delete state.dues[memberId][month]; + } + if (res && res.multiYearStats) { + state.multiYearStats = res.multiYearStats; + } + + closeDueModal(); + renderAll(); + showToast('Cotisation de ' + MONTH_NAMES[month] + ' effacée.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Ouvre la modale d'ajout ou d'édition d'une fiche syndiqué. + */ + function openMemberModal(memberId = null) { + const modal = document.getElementById('member-modal'); + const title = document.getElementById('member-modal-title'); + const btnDelete = document.getElementById('btn-delete-member'); + + const member = memberId !== null + ? state.members.find((m) => Number(m.id) === Number(memberId)) + : null; + + document.getElementById('mbr-id').value = member ? String(member.id) : ''; + document.getElementById('mbr-civility').value = member ? (member.civility || '') : 'M.'; + document.getElementById('mbr-last-name').value = member ? member.last_name : ''; + document.getElementById('mbr-first-name').value = member ? member.first_name : ''; + document.getElementById('mbr-cogitiel-id').value = member ? member.cogitiel_id : ''; + document.getElementById('mbr-default-amount').value = member ? Number(member.default_amount).toFixed(2) : '0.00'; + document.getElementById('mbr-default-method').value = member ? (member.default_method || 'Virement') : 'Virement'; + document.getElementById('mbr-email').value = member ? (member.email || '') : ''; + document.getElementById('mbr-phone-personal').value = member ? (member.phone_personal || '') : ''; + document.getElementById('mbr-phone-pro').value = member ? (member.phone_pro || '') : ''; + document.getElementById('mbr-address').value = member ? (member.address || '') : ''; + document.getElementById('mbr-postal-code').value = member ? (member.postal_code || '') : ''; + document.getElementById('mbr-city').value = member ? (member.city || '') : ''; + document.getElementById('mbr-base-code').value = member ? (member.base_code || '90045110520251341') : '90045110520251341'; + document.getElementById('mbr-base-name').value = member ? (member.base_name || 'INDIVIDUELS UL FOIX HAUTE-ARIEGE') : 'INDIVIDUELS UL FOIX HAUTE-ARIEGE'; + document.getElementById('mbr-is-archived').checked = member ? Boolean(Number(member.is_archived)) : false; + + if (member) { + title.textContent = 'Modifier la fiche : ' + member.last_name + ' ' + member.first_name; + btnDelete.classList.remove('hidden'); + } else { + title.textContent = 'Nouveau syndiqué (UL CGT Foix Haute-Ariège)'; + btnDelete.classList.add('hidden'); + } + + modal.classList.remove('hidden'); + requestAnimationFrame(() => { + document.getElementById('mbr-last-name').focus(); + }); + } + + function closeMemberModal() { + const modal = document.getElementById('member-modal'); + if (modal) { + modal.classList.add('hidden'); + } + } + + async function handleMemberFormSubmit(event) { + event.preventDefault(); + + const idVal = document.getElementById('mbr-id').value; + const payload = { + cogitiel_id: document.getElementById('mbr-cogitiel-id').value.trim(), + civility: document.getElementById('mbr-civility').value, + last_name: document.getElementById('mbr-last-name').value.trim().toUpperCase(), + first_name: document.getElementById('mbr-first-name').value.trim(), + default_amount: parseFloat(document.getElementById('mbr-default-amount').value) || 0, + default_method: document.getElementById('mbr-default-method').value, + email: document.getElementById('mbr-email').value.trim(), + phone_personal: document.getElementById('mbr-phone-personal').value.trim(), + phone_pro: document.getElementById('mbr-phone-pro').value.trim(), + address: document.getElementById('mbr-address').value.trim(), + postal_code: document.getElementById('mbr-postal-code').value.trim(), + city: document.getElementById('mbr-city').value.trim().toUpperCase(), + base_code: document.getElementById('mbr-base-code').value.trim(), + base_name: document.getElementById('mbr-base-name').value.trim(), + is_archived: document.getElementById('mbr-is-archived').checked ? 1 : 0, + }; + + try { + if (idVal !== '') { + const res = await apiFetch('/api/members/' + encodeURIComponent(idVal), { + method: 'PUT', + body: JSON.stringify(payload), + }); + const idx = state.members.findIndex((m) => Number(m.id) === Number(idVal)); + if (idx !== -1 && res.member) { + state.members[idx] = res.member; + } + showToast('Fiche syndiqué mise à jour.'); + } else { + const res = await apiFetch('/api/members', { + method: 'POST', + body: JSON.stringify(payload), + }); + if (res.member) { + state.members.push(res.member); + } + showToast('Nouveau syndiqué ajouté avec succès.'); + } + + state.members.sort((a, b) => { + const cmp = a.last_name.localeCompare(b.last_name, 'fr'); + return cmp !== 0 ? cmp : a.first_name.localeCompare(b.first_name, 'fr'); + }); + + closeMemberModal(); + renderAll(); + } catch (err) { + showToast(err.message, 'error'); + } + } + + async function handleDeleteMember() { + const idVal = document.getElementById('mbr-id').value; + if (!idVal) { + return; + } + const member = state.members.find((m) => Number(m.id) === Number(idVal)); + const label = member ? (member.last_name + ' ' + member.first_name) : ('#' + idVal); + if (!window.confirm('Confirmer la suppression définitive de ' + label + ' et de son historique de cotisations ?')) { + return; + } + + try { + const res = await apiFetch('/api/members/' + encodeURIComponent(idVal), { + method: 'DELETE', + }); + state.members = state.members.filter((m) => Number(m.id) !== Number(idVal)); + delete state.dues[Number(idVal)]; + if (res && res.multiYearStats) { + state.multiYearStats = res.multiYearStats; + } + closeMemberModal(); + renderAll(); + showToast('Syndiqué supprimé.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Gère l'importation d'un fichier d'export CoGétise (.asp / CSV Windows-1252). + */ + async function handleImportFileChange(event) { + const fileInput = event.target; + if (!fileInput.files || fileInput.files.length === 0) { + return; + } + + const file = fileInput.files[0]; + const formData = new FormData(); + formData.append('file', file); + + try { + showToast('Importation du fichier ' + file.name + ' en cours…', 'info'); + const summary = await apiFetch('/api/import', { + method: 'POST', + body: formData, + }); + + await loadState(state.year); + showToast( + 'Import CoGétise réussi : ' + summary.inserted + ' créé(s), ' + + summary.updated + ' mis à jour (Total traité : ' + summary.total + ').', + 'success' + ); + } catch (err) { + showToast('Erreur d\'importation : ' + err.message, 'error'); + } finally { + fileInput.value = ''; + } + } + + /* ====================================================================== + * OUTIL DE PUBLIPOSTAGE & MODÈLES PERSONNALISABLES + * ====================================================================== */ + + /** + * Charge le modèle choisi dans l'éditeur et applique la règle de sélection automatique des destinataires. + */ + function syncTemplateEditorFromSlug(slug, autoSelectRecipients = true) { + state.activeTemplateSlug = slug; + const selectEl = document.getElementById('select-template'); + if (selectEl && selectEl.value !== slug) { + selectEl.value = slug; + } + + const tpl = state.templates.find((t) => t.slug === slug); + if (tpl) { + document.getElementById('tpl-title').value = tpl.title || ''; + document.getElementById('tpl-subject').value = tpl.subject || ''; + document.getElementById('tpl-body').value = tpl.body || ''; + } + + const ruleHints = { + tax_receipt: 'Sélection automatique : tous les syndiqués actifs ayant versé plus de 0,00 € sur l\'année.', + missing_months: 'Sélection automatique : tous les syndiqués actifs en Alerte Orange (cotisations pas à jour).', + annual_zero_warning: 'Sélection automatique : tous les syndiqués actifs en Alerte Rouge (0,00 € versé sur l\'année).', + }; + setText('mailing-auto-rule-hint', ruleHints[slug] || ''); + + if (autoSelectRecipients) { + applyAutoRecipientSelection(slug); + } + renderMailingRecipients(); + renderMailingPreview(); + } + + /** + * Coche automatiquement les destinataires correspondant au modèle actif. + */ + function applyAutoRecipientSelection(slug) { + state.selectedRecipientIds.clear(); + + state.members.forEach((member) => { + if (Number(member.is_archived) === 1) { + return; + } + if (state.mailingNoEmailOnly && member.email && member.email.trim() !== '') { + return; + } + + const metrics = computeMemberMetrics(member); + if (slug === 'tax_receipt' && metrics.totalPaid > 0) { + state.selectedRecipientIds.add(Number(member.id)); + } else if (slug === 'missing_months' && metrics.alertLevel === 'orange') { + state.selectedRecipientIds.add(Number(member.id)); + } else if (slug === 'annual_zero_warning' && metrics.alertLevel === 'red') { + state.selectedRecipientIds.add(Number(member.id)); + } + }); + + const firstSelected = Array.from(state.selectedRecipientIds)[0] || null; + state.previewMemberId = firstSelected; + } + + function getEligibleMailingMembers() { + return state.members.filter((member) => { + if (Number(member.is_archived) === 1) { + return false; + } + if (state.mailingNoEmailOnly && member.email && member.email.trim() !== '') { + return false; + } + return true; + }); + } + + /** + * Affiche la liste des destinataires dans l'onglet Publipostage. + */ + function renderMailingRecipients() { + const listEl = document.getElementById('mailing-recipients-list'); + const previewSelect = document.getElementById('select-preview-member'); + if (!listEl || !previewSelect) { + return; + } + + listEl.textContent = ''; + previewSelect.textContent = ''; + + const eligibleMembers = getEligibleMailingMembers(); + let emailCapableSelectedCount = 0; + + eligibleMembers.forEach((member) => { + const id = Number(member.id); + const isChecked = state.selectedRecipientIds.has(id); + const hasEmail = Boolean(member.email && member.email.includes('@')); + if (isChecked && hasEmail) { + emailCapableSelectedCount++; + } + + const metrics = computeMemberMetrics(member); + + const item = document.createElement('label'); + item.className = 'ul-recipient-item' + (isChecked ? ' selected' : ''); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.action = 'toggle-recipient'; + checkbox.dataset.memberId = String(id); + checkbox.checked = isChecked; + + const infoDiv = document.createElement('div'); + infoDiv.className = 'ul-recipient-info'; + + const nameLine = document.createElement('div'); + nameLine.className = 'ul-recipient-name'; + nameLine.textContent = member.last_name + ' ' + member.first_name; + + const metaLine = document.createElement('div'); + metaLine.className = 'ul-recipient-meta'; + metaLine.textContent = (hasEmail ? ('📧 ' + member.email) : '📮 Courrier papier (sans e-mail)') + + ' · Payé : ' + formatEuros(metrics.totalPaid); + + infoDiv.appendChild(nameLine); + infoDiv.appendChild(metaLine); + + const badgeSpan = document.createElement('span'); + if (metrics.alertLevel === 'red') { + badgeSpan.className = 'ul-badge ul-badge-red'; + badgeSpan.textContent = '0,00 €'; + } else if (metrics.alertLevel === 'orange') { + badgeSpan.className = 'ul-badge ul-badge-orange'; + badgeSpan.textContent = '-' + metrics.missingMonths.length + ' m.'; + } else { + badgeSpan.className = 'ul-badge ul-badge-green'; + badgeSpan.textContent = formatEuros(metrics.totalPaid); + } + + item.appendChild(checkbox); + item.appendChild(infoDiv); + item.appendChild(badgeSpan); + listEl.appendChild(item); + + // Ajouter au sélecteur de prévisualisation + const opt = document.createElement('option'); + opt.value = String(id); + opt.textContent = (isChecked ? '✓ ' : '') + member.last_name + ' ' + member.first_name + ' (' + formatEuros(metrics.totalPaid) + ')'; + previewSelect.appendChild(opt); + }); + + if (state.previewMemberId && eligibleMembers.some((m) => Number(m.id) === Number(state.previewMemberId))) { + previewSelect.value = String(state.previewMemberId); + } else if (eligibleMembers.length > 0) { + state.previewMemberId = Number(eligibleMembers[0].id); + previewSelect.value = String(state.previewMemberId); + } else { + state.previewMemberId = null; + } + + const totalSelected = state.selectedRecipientIds.size; + setText('mailing-selected-count', String(totalSelected)); + setText('btn-print-count', String(totalSelected)); + setText('btn-email-count', String(emailCapableSelectedCount)); + } + + /** + * Remplace toutes les balises dynamiques {{...}} côté client pour l'aperçu et l'impression PDF. + */ + function replaceVariablesForMember(templateText, member) { + const metrics = computeMemberMetrics(member); + const now = new Date(); + const dateDuJour = String(now.getDate()).padStart(2, '0') + '/' + + String(now.getMonth() + 1).padStart(2, '0') + '/' + + now.getFullYear(); + + const addrParts = []; + if (member.address && member.address.trim() !== '') { + addrParts.push(member.address.trim()); + } + const cpVille = ((member.postal_code || '') + ' ' + (member.city || '')).trim(); + if (cpVille !== '') { + addrParts.push(cpVille); + } + const adresseComplete = addrParts.length > 0 ? addrParts.join(' — ') : 'Adresse non renseignée'; + + const map = { + '{{civilite}}': member.civility || '', + '{{nom}}': member.last_name || '', + '{{prenom}}': member.first_name || '', + '{{adresse_complete}}': adresseComplete, + '{{code_responsable}}': member.cogitiel_id || '', + '{{annee}}': String(state.year), + '{{date_du_jour}}': dateDuJour, + '{{total_paye}}': formatEuros(metrics.totalPaid), + '{{credit_impot}}': formatEuros(metrics.creditImpot), + '{{nb_mois_payes}}': String(metrics.paidMonthsCount), + '{{mois_manquants_liste}}': metrics.missingMonthsNames.length > 0 ? metrics.missingMonthsNames.join(', ') : 'Aucun', + '{{nb_mois_manquants}}': String(metrics.missingMonths.length), + '{{cotisation_habituelle}}': formatEuros(metrics.usualAmount), + '{{montant_estime_retard}}': formatEuros(metrics.estimatedArrears), + }; + + let output = templateText || ''; + Object.entries(map).forEach(([tag, val]) => { + output = output.split(tag).join(val); + }); + return output; + } + + /** + * Construit un élément DOM représentant une page de courrier A4 avec la vraie bannière officielle UL CGT Foix Haute-Ariège. + */ + function createLetterDomElement(member, subjectText, bodyText) { + const renderedSubject = replaceVariablesForMember(subjectText, member); + const renderedBody = replaceVariablesForMember(bodyText, member); + + const page = document.createElement('article'); + page.className = 'ul-print-page'; + + const header = document.createElement('header'); + header.className = 'ul-letter-header'; + + if (state.bannerUrl) { + const bannerImg = document.createElement('img'); + bannerImg.src = state.bannerUrl; + bannerImg.alt = 'Union Locale CGT Foix Haute-Ariège'; + bannerImg.className = 'ul-letter-banner-img'; + bannerImg.width = 360; + bannerImg.height = 72; + header.appendChild(bannerImg); + } else if (state.logoUrl) { + const logoImg = document.createElement('img'); + logoImg.src = state.logoUrl; + logoImg.alt = 'CGT'; + logoImg.className = 'ul-letter-logo-img'; + logoImg.width = 40; + logoImg.height = 52; + header.appendChild(logoImg); + } + + const orgSub = document.createElement('div'); + orgSub.className = 'ul-letter-org-sub'; + orgSub.textContent = 'Trésorerie — Collectif des Isolés / Multipros · Bourse du Travail, 09000 FOIX'; + header.appendChild(orgSub); + + page.appendChild(header); + + const subjectBar = document.createElement('div'); + subjectBar.className = 'ul-letter-subject'; + subjectBar.textContent = renderedSubject; + page.appendChild(subjectBar); + + const bodyDiv = document.createElement('div'); + bodyDiv.className = 'ul-letter-body'; + bodyDiv.textContent = renderedBody; + page.appendChild(bodyDiv); + + return page; + } + + /** + * Met à jour l'aperçu en direct du courrier dans l'onglet Publipostage. + */ + function renderMailingPreview() { + const previewContainer = document.getElementById('mailing-live-preview'); + if (!previewContainer) { + return; + } + previewContainer.textContent = ''; + + const member = state.members.find((m) => Number(m.id) === Number(state.previewMemberId)); + if (!member) { + previewContainer.textContent = 'Sélectionnez un syndiqué pour prévisualiser son courrier personnalisé.'; + return; + } + + const subjectText = document.getElementById('tpl-subject').value; + const bodyText = document.getElementById('tpl-body').value; + + const letterEl = createLetterDomElement(member, subjectText, bodyText); + previewContainer.appendChild(letterEl); + } + + /** + * Sauvegarde le modèle de courrier en base de données. + */ + async function handleSaveTemplate() { + const slug = state.activeTemplateSlug; + const title = document.getElementById('tpl-title').value.trim(); + const subject = document.getElementById('tpl-subject').value.trim(); + const body = document.getElementById('tpl-body').value; + + if (!title || !subject || !body) { + showToast('Le titre, l\'objet et le corps du modèle sont requis.', 'error'); + return; + } + + try { + const res = await apiFetch('/api/templates/' + encodeURIComponent(slug), { + method: 'PUT', + body: JSON.stringify({ title, subject, body }), + }); + const idx = state.templates.findIndex((t) => t.slug === slug); + if (idx !== -1 && res.template) { + state.templates[idx] = res.template; + } + showToast('Modèle « ' + title + ' » enregistré en base de données.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Réinitialise les 3 modèles par défaut en base de données. + */ + async function handleResetTemplates() { + if (!window.confirm('Restaurer le texte original des 3 modèles de courriers ?')) { + return; + } + try { + const res = await apiFetch('/api/templates/reset', { + method: 'POST', + }); + state.templates = res.templates || []; + syncTemplateEditorFromSlug(state.activeTemplateSlug, false); + showToast('Les 3 modèles ont été restaurés.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Génère les pages A4 pour tous les destinataires cochés et lance l'impression / export PDF. + */ + function handlePrintMailing() { + const selectedMembers = state.members.filter((m) => state.selectedRecipientIds.has(Number(m.id))); + if (selectedMembers.length === 0) { + showToast('Veuillez cocher au moins un destinataire à imprimer.', 'error'); + return; + } + + const subjectText = document.getElementById('tpl-subject').value; + const bodyText = document.getElementById('tpl-body').value; + const printContainer = document.getElementById('ul-print-container'); + printContainer.textContent = ''; + + selectedMembers.forEach((member) => { + const pageEl = createLetterDomElement(member, subjectText, bodyText); + printContainer.appendChild(pageEl); + }); + + window.print(); + } + + /** + * Envoie les courriers personnalisés par e-mail via le service OCP\Mail\IMailer de Nextcloud. + */ + async function handleEmailMailing() { + const selectedIds = Array.from(state.selectedRecipientIds); + if (selectedIds.length === 0) { + showToast('Veuillez sélectionner au moins un destinataire.', 'error'); + return; + } + + const subject = document.getElementById('tpl-subject').value.trim(); + const body = document.getElementById('tpl-body').value; + + try { + showToast('Envoi des courriers électroniques en cours…', 'info'); + const res = await apiFetch('/api/mailing/send', { + method: 'POST', + body: JSON.stringify({ + year: state.year, + member_ids: selectedIds, + subject: subject, + body: body, + }), + }); + + let msg = 'Envoi terminé : ' + res.sent + ' e-mail(s) envoyé(s)'; + if (res.skipped_no_email > 0) { + msg += ', ' + res.skipped_no_email + ' ignoré(s) (sans adresse e-mail)'; + } + if (res.errors && res.errors.length > 0) { + msg += ', ' + res.errors.length + ' erreur(s) (' + res.errors[0] + ')'; + showToast(msg, 'error'); + } else { + showToast(msg + '.', 'success'); + } + } catch (err) { + showToast('Erreur d\'envoi : ' + err.message, 'error'); + } + } + + /** + * Insère une balise {{variable}} à la position du curseur dans l'éditeur de modèle. + */ + function insertVariableAtCursor(variableTag) { + const target = state.lastFocusedTemplateField || document.getElementById('tpl-body'); + if (!target) { + return; + } + + const start = target.selectionStart || 0; + const end = target.selectionEnd || 0; + const currentValue = target.value; + + target.value = currentValue.substring(0, start) + variableTag + currentValue.substring(end); + target.focus(); + const newPos = start + variableTag.length; + target.setSelectionRange(newPos, newPos); + renderMailingPreview(); + } + + /* ====================================================================== + * GESTION DES PERMISSIONS D'ACCÈS (#view-permissions — Administrateurs) + * ====================================================================== */ + + async function loadPermissions() { + if (!state.isAdmin) { + return; + } + try { + const data = await apiFetch('/api/permissions'); + state.permissionsLoaded = true; + state.permissions.accessMode = data.access_mode === 'all_users' ? 'all_users' : 'restricted'; + state.permissions.allowedGroups = new Set(Array.isArray(data.allowed_groups) ? data.allowed_groups : []); + state.permissions.allowedUsers = new Set(Array.isArray(data.allowed_users) ? data.allowed_users : []); + state.permissions.availableGroups = Array.isArray(data.available_groups) ? data.available_groups : []; + state.permissions.availableUsers = Array.isArray(data.available_users) ? data.available_users : []; + renderPermissions(); + } catch (err) { + showToast('Erreur chargement des permissions : ' + err.message, 'error'); + } + } + + function renderPermissions() { + if (!state.isAdmin) { + return; + } + + const radioRestricted = document.getElementById('perm-mode-restricted'); + const radioAll = document.getElementById('perm-mode-all'); + const gridEl = document.getElementById('perm-selectors-grid'); + + if (radioRestricted && radioAll) { + radioRestricted.checked = state.permissions.accessMode === 'restricted'; + radioAll.checked = state.permissions.accessMode === 'all_users'; + } + if (gridEl) { + gridEl.classList.toggle('ul-perm-disabled-look', state.permissions.accessMode === 'all_users'); + } + + // Rendu de la liste des groupes + const groupsList = document.getElementById('perm-groups-list'); + if (groupsList) { + groupsList.textContent = ''; + const qG = state.permissions.groupSearch.trim().toLowerCase(); + const filteredGroups = state.permissions.availableGroups.filter((g) => { + if (qG === '') { + return true; + } + return (g.id + ' ' + g.displayName).toLowerCase().includes(qG); + }); + + if (filteredGroups.length === 0) { + const empty = document.createElement('div'); + empty.className = 'ul-perm-empty'; + empty.textContent = 'Aucun groupe Nextcloud trouvé.'; + groupsList.appendChild(empty); + } else { + filteredGroups.forEach((group) => { + const isAdminGroup = group.id === 'admin'; + const isChecked = isAdminGroup || state.permissions.allowedGroups.has(group.id); + const lbl = document.createElement('label'); + lbl.className = 'ul-perm-item' + (isChecked ? ' selected' : ''); + + const chk = document.createElement('input'); + chk.type = 'checkbox'; + chk.dataset.permGroup = group.id; + chk.checked = isChecked; + if (isAdminGroup) { + chk.disabled = true; + } + + const info = document.createElement('div'); + info.className = 'ul-perm-item-info'; + + const title = document.createElement('div'); + title.className = 'ul-perm-item-title'; + title.textContent = group.displayName || group.id; + + const sub = document.createElement('div'); + sub.className = 'ul-perm-item-sub'; + sub.textContent = 'ID groupe : ' + group.id; + + info.appendChild(title); + info.appendChild(sub); + lbl.appendChild(chk); + lbl.appendChild(info); + + if (isAdminGroup) { + const badge = document.createElement('span'); + badge.className = 'ul-badge ul-badge-green'; + badge.textContent = 'Accès permanent'; + lbl.appendChild(badge); + } + + groupsList.appendChild(lbl); + }); + } + } + + // Rendu de la liste des utilisateurs + const usersList = document.getElementById('perm-users-list'); + if (usersList) { + usersList.textContent = ''; + const qU = state.permissions.userSearch.trim().toLowerCase(); + const filteredUsers = state.permissions.availableUsers.filter((u) => { + if (qU === '') { + return true; + } + return (u.uid + ' ' + u.displayName + ' ' + (u.email || '')).toLowerCase().includes(qU); + }); + + if (filteredUsers.length === 0) { + const empty = document.createElement('div'); + empty.className = 'ul-perm-empty'; + empty.textContent = 'Aucun utilisateur Nextcloud trouvé.'; + usersList.appendChild(empty); + } else { + filteredUsers.forEach((user) => { + const isChecked = Boolean(user.isAdmin) || state.permissions.allowedUsers.has(user.uid); + const lbl = document.createElement('label'); + lbl.className = 'ul-perm-item' + (isChecked ? ' selected' : ''); + + const chk = document.createElement('input'); + chk.type = 'checkbox'; + chk.dataset.permUser = user.uid; + chk.checked = isChecked; + if (user.isAdmin) { + chk.disabled = true; + } + + const info = document.createElement('div'); + info.className = 'ul-perm-item-info'; + + const title = document.createElement('div'); + title.className = 'ul-perm-item-title'; + title.textContent = user.displayName + ' (' + user.uid + ')'; + + const sub = document.createElement('div'); + sub.className = 'ul-perm-item-sub'; + sub.textContent = user.email ? ('📧 ' + user.email) : 'Aucun e-mail renseigné'; + + info.appendChild(title); + info.appendChild(sub); + lbl.appendChild(chk); + lbl.appendChild(info); + + if (user.isAdmin) { + const badge = document.createElement('span'); + badge.className = 'ul-badge ul-badge-green'; + badge.textContent = 'Admin Nextcloud'; + lbl.appendChild(badge); + } + + usersList.appendChild(lbl); + }); + } + } + + setText('perm-groups-count', String(state.permissions.allowedGroups.size)); + setText('perm-users-count', String(state.permissions.allowedUsers.size)); + } + + async function handleSavePermissions() { + try { + const res = await apiFetch('/api/permissions', { + method: 'PUT', + body: JSON.stringify({ + access_mode: state.permissions.accessMode, + allowed_groups: Array.from(state.permissions.allowedGroups), + allowed_users: Array.from(state.permissions.allowedUsers), + }), + }); + + if (res) { + state.permissions.accessMode = res.access_mode === 'all_users' ? 'all_users' : 'restricted'; + state.permissions.allowedGroups = new Set(Array.isArray(res.allowed_groups) ? res.allowed_groups : []); + state.permissions.allowedUsers = new Set(Array.isArray(res.allowed_users) ? res.allowed_users : []); + renderPermissions(); + } + + showToast('Permissions d\'accès enregistrées avec succès.'); + } catch (err) { + showToast('Erreur d\'enregistrement des permissions : ' + err.message, 'error'); + } + } + + /** + * Ouvre ou ferme le volet latéral gauche (#app-navigation). + */ + function toggleSidebar(forceCollapsed) { + const wrapper = document.getElementById('ul-tresorerie-app'); + if (!wrapper) { + return; + } + const isCollapsed = typeof forceCollapsed === 'boolean' + ? forceCollapsed + : !wrapper.classList.contains('ul-sidebar-collapsed'); + + wrapper.classList.toggle('ul-sidebar-collapsed', isCollapsed); + try { + window.localStorage.setItem('ul_tresorerie_sidebar_collapsed', isCollapsed ? '1' : '0'); + } catch (_err) { + // Ignorer si localStorage est indisponible + } + } + + /** + * Bascule entre les vues (Grille, Tableau de bord, Publipostage, Permissions). + */ + function switchView(viewName) { + const allowedViews = ['grid', 'dashboard', 'mailing', 'permissions']; + if (!allowedViews.includes(viewName)) { + viewName = 'grid'; + } + if (viewName === 'permissions' && !state.isAdmin) { + viewName = 'grid'; + } + + state.activeView = viewName; + + allowedViews.forEach((v) => { + const sec = document.getElementById('view-' + v); + const nav = document.getElementById('nav-view-' + v); + if (sec) { + sec.classList.toggle('hidden', v !== viewName); + } + if (nav) { + nav.classList.toggle('active', v === viewName); + } + }); + + if (viewName === 'mailing') { + syncTemplateEditorFromSlug(state.activeTemplateSlug, state.selectedRecipientIds.size === 0); + } else if (viewName === 'dashboard') { + renderDashboard(); + } else if (viewName === 'permissions') { + if (!state.permissionsLoaded) { + loadPermissions(); + } else { + renderPermissions(); + } + } else { + renderGrid(); + } + } + + function on(id, eventName, handler) { + const el = document.getElementById(id); + if (el) { + el.addEventListener(eventName, handler); + } + } + + /** + * Attache tous les écouteurs d'événements DOM (100 % conforme CSP Nextcloud 34). + */ + function bindEvents() { + // Boutons d'ouverture / fermeture du volet latéral gauche + document.querySelectorAll('[data-action="toggle-sidebar"]').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + toggleSidebar(); + }); + }); + + // Navigation entre vues (Grille, Tableau de bord, Publipostage, Permissions) + document.querySelectorAll('.ul-nav-item[data-view]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const targetView = link.getAttribute('data-view') || 'grid'; + switchView(targetView); + }); + }); + + // Support du changement de hash dans l'URL (#dashboard, #permissions, #mailing, #grid) + window.addEventListener('hashchange', () => { + const hash = (window.location.hash || '').replace(/^#/, ''); + if (['grid', 'dashboard', 'mailing', 'permissions'].includes(hash)) { + switchView(hash); + } + }); + + // Filtres d'alertes dans la barre latérale + document.querySelectorAll('.ul-filter-item[data-filter]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + document.querySelectorAll('.ul-filter-item').forEach((el) => el.classList.remove('active')); + link.classList.add('active'); + state.activeFilter = link.dataset.filter || 'all'; + if (state.activeView !== 'grid') { + switchView('grid'); + } else { + renderGrid(); + } + }); + }); + + on('active-filter-indicator', 'click', () => { + state.activeFilter = 'all'; + document.querySelectorAll('.ul-filter-item').forEach((el) => { + el.classList.toggle('active', el.dataset.filter === 'all'); + }); + renderGrid(); + }); + + // Sélecteur d'année (< 2026 >) dans la grille et dans le tableau de bord + const prevYearFn = () => loadState(state.year - 1); + const nextYearFn = () => loadState(state.year + 1); + on('btn-prev-year', 'click', prevYearFn); + on('btn-next-year', 'click', nextYearFn); + on('btn-dash-prev-year', 'click', prevYearFn); + on('btn-dash-next-year', 'click', nextYearFn); + + on('dash-select-compare-year', 'change', (e) => { + state.compareYear = Number(e.target.value) || (state.year - 1); + renderDashboard(); + }); + + on('dash-chart-multiyear', 'click', (e) => { + const grp = e.target.closest('[data-year]'); + if (grp && grp.dataset.year) { + const targetYear = Number(grp.dataset.year); + if (targetYear && targetYear !== state.year) { + loadState(targetYear); + } + } + }); + + // Barre de recherche instantanée + on('input-search', 'input', (e) => { + state.searchQuery = e.target.value || ''; + renderGrid(); + }); + + // Case à cocher « Afficher les syndiqués archivés » + on('chk-show-archived', 'change', (e) => { + state.showArchived = Boolean(e.target.checked); + renderGrid(); + }); + + // Boutons « + Nouveau syndiqué » et « Importer fichier .asp » + const openNewMemberFn = () => openMemberModal(null); + on('btn-new-member', 'click', openNewMemberFn); + on('btn-nav-new-member', 'click', openNewMemberFn); + + const triggerFileImportFn = () => { + const fileInput = document.getElementById('input-import-file'); + if (fileInput) { + fileInput.click(); + } + }; + on('btn-import-asp', 'click', triggerFileImportFn); + on('btn-nav-import-asp', 'click', triggerFileImportFn); + on('input-import-file', 'change', handleImportFileChange); + + // Délégation d'événements sur la grille des cotisations (clic cellule ou édition fiche) + on('dues-tbody', 'click', (e) => { + const editBtn = e.target.closest('[data-action="edit-member"]'); + if (editBtn) { + e.stopPropagation(); + openMemberModal(Number(editBtn.dataset.memberId)); + return; + } + + const dueCell = e.target.closest('[data-action="open-due"]'); + if (dueCell) { + openDueModal(Number(dueCell.dataset.memberId), Number(dueCell.dataset.month)); + } + }); + + // Modale de saisie de cotisation + on('due-form', 'submit', handleDueFormSubmit); + on('btn-delete-due', 'click', handleDeleteDue); + on('btn-cancel-due', 'click', closeDueModal); + on('btn-close-due-modal', 'click', closeDueModal); + + // Modale de fiche syndiqué + on('member-form', 'submit', handleMemberFormSubmit); + on('btn-delete-member', 'click', handleDeleteMember); + on('btn-cancel-member', 'click', closeMemberModal); + on('btn-close-member-modal', 'click', closeMemberModal); + + // Fermeture des modales au clic sur l'arrière-plan ou touche Escape + on('due-modal', 'click', (e) => { + if (e.target.id === 'due-modal') { + closeDueModal(); + } + }); + on('member-modal', 'click', (e) => { + if (e.target.id === 'member-modal') { + closeMemberModal(); + } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeDueModal(); + closeMemberModal(); + } + }); + + // Événements de l'outil de Publipostage + on('select-template', 'change', (e) => { + syncTemplateEditorFromSlug(e.target.value, true); + }); + + on('chk-mailing-no-email-only', 'change', (e) => { + state.mailingNoEmailOnly = Boolean(e.target.checked); + applyAutoRecipientSelection(state.activeTemplateSlug); + renderMailingRecipients(); + renderMailingPreview(); + }); + + on('btn-mailing-check-all', 'click', () => { + getEligibleMailingMembers().forEach((m) => state.selectedRecipientIds.add(Number(m.id))); + renderMailingRecipients(); + }); + + on('btn-mailing-uncheck-all', 'click', () => { + state.selectedRecipientIds.clear(); + renderMailingRecipients(); + }); + + on('mailing-recipients-list', 'change', (e) => { + const chk = e.target.closest('[data-action="toggle-recipient"]'); + if (!chk) { + return; + } + const id = Number(chk.dataset.memberId); + if (chk.checked) { + state.selectedRecipientIds.add(id); + state.previewMemberId = id; + } else { + state.selectedRecipientIds.delete(id); + } + renderMailingRecipients(); + renderMailingPreview(); + }); + + on('select-preview-member', 'change', (e) => { + state.previewMemberId = Number(e.target.value); + renderMailingPreview(); + }); + + const tplSubject = document.getElementById('tpl-subject'); + const tplBody = document.getElementById('tpl-body'); + if (tplSubject) { + tplSubject.addEventListener('focus', () => { + state.lastFocusedTemplateField = tplSubject; + }); + tplSubject.addEventListener('input', renderMailingPreview); + } + if (tplBody) { + tplBody.addEventListener('focus', () => { + state.lastFocusedTemplateField = tplBody; + }); + tplBody.addEventListener('input', renderMailingPreview); + } + + on('tpl-variables-bar', 'click', (e) => { + const pill = e.target.closest('.ul-var-pill'); + if (pill && pill.dataset.var) { + insertVariableAtCursor(pill.dataset.var); + } + }); + + on('btn-save-template', 'click', handleSaveTemplate); + on('btn-reset-templates', 'click', handleResetTemplates); + on('btn-print-mailing', 'click', handlePrintMailing); + on('btn-email-mailing', 'click', handleEmailMailing); + + // Événements de la vue Permissions (si administrateur) + on('btn-save-permissions', 'click', handleSavePermissions); + + document.querySelectorAll('input[name="perm-access-mode"]').forEach((radio) => { + radio.addEventListener('change', (e) => { + if (e.target.checked) { + state.permissions.accessMode = e.target.value === 'all_users' ? 'all_users' : 'restricted'; + renderPermissions(); + } + }); + }); + + on('input-perm-search-groups', 'input', (e) => { + state.permissions.groupSearch = e.target.value || ''; + renderPermissions(); + }); + + on('input-perm-search-users', 'input', (e) => { + state.permissions.userSearch = e.target.value || ''; + renderPermissions(); + }); + + on('perm-groups-list', 'change', (e) => { + const chk = e.target.closest('input[data-perm-group]'); + if (!chk) { + return; + } + const gid = chk.dataset.permGroup; + if (chk.checked) { + state.permissions.allowedGroups.add(gid); + } else { + state.permissions.allowedGroups.delete(gid); + } + renderPermissions(); + }); + + on('perm-users-list', 'change', (e) => { + const chk = e.target.closest('input[data-perm-user]'); + if (!chk) { + return; + } + const uid = chk.dataset.permUser; + if (chk.checked) { + state.permissions.allowedUsers.add(uid); + } else { + state.permissions.allowedUsers.delete(uid); + } + renderPermissions(); + }); + + on('btn-perm-groups-all', 'click', () => { + state.permissions.availableGroups.forEach((g) => { + if (g.id !== 'admin') { + state.permissions.allowedGroups.add(g.id); + } + }); + renderPermissions(); + }); + + on('btn-perm-groups-none', 'click', () => { + state.permissions.allowedGroups.clear(); + renderPermissions(); + }); + + on('btn-perm-users-all', 'click', () => { + state.permissions.availableUsers.forEach((u) => { + if (!u.isAdmin) { + state.permissions.allowedUsers.add(u.uid); + } + }); + renderPermissions(); + }); + + on('btn-perm-users-none', 'click', () => { + state.permissions.allowedUsers.clear(); + renderPermissions(); + }); + } + + document.addEventListener('DOMContentLoaded', () => { + const wrapper = document.getElementById('ul-tresorerie-app'); + if (!wrapper) { + return; + } + const initialYear = Number(wrapper.dataset.initialYear) || new Date().getFullYear(); + state.year = initialYear; + state.compareYear = initialYear - 1; + state.canAccess = wrapper.dataset.canAccess !== '0'; + state.isAdmin = wrapper.dataset.isAdmin === '1'; + state.logoUrl = wrapper.dataset.logoUrl || ''; + state.bannerUrl = wrapper.dataset.bannerUrl || ''; + + if (!state.canAccess) { + return; + } + + try { + if (window.localStorage.getItem('ul_tresorerie_sidebar_collapsed') === '1') { + wrapper.classList.add('ul-sidebar-collapsed'); + } + } catch (_err) { + // Ignorer + } + + bindEvents(); + loadState(initialYear); + + const initialHash = (window.location.hash || '').replace(/^#/, ''); + if (['dashboard', 'mailing', 'permissions'].includes(initialHash)) { + switchView(initialHash); + } + }); +})(); diff --git a/ul_tresorerie/js/main.js b/ul_tresorerie/js/main.js new file mode 100644 index 0000000..f11cbca --- /dev/null +++ b/ul_tresorerie/js/main.js @@ -0,0 +1,2617 @@ +'use strict'; + +(function () { + const MONTH_NAMES = { + 1: 'Janvier', + 2: 'Février', + 3: 'Mars', + 4: 'Avril', + 5: 'Mai', + 6: 'Juin', + 7: 'Juillet', + 8: 'Août', + 9: 'Septembre', + 10: 'Octobre', + 11: 'Novembre', + 12: 'Décembre', + }; + + const MONTH_SHORT = { + 1: 'Janv.', + 2: 'Févr.', + 3: 'Mars', + 4: 'Avr.', + 5: 'Mai', + 6: 'Juin', + 7: 'Juil.', + 8: 'Août', + 9: 'Sept.', + 10: 'Oct.', + 11: 'Nov.', + 12: 'Déc.', + }; + + const METHOD_BADGES = { + 'Virement': 'V', + 'Prélèvement': 'P', + 'Chèque': 'C', + 'Espèces': 'E', + }; + + const METHOD_COLORS = { + 'Virement': '#15803d', + 'Prélèvement': '#2563eb', + 'Chèque': '#d97706', + 'Espèces': '#9333ea', + }; + + const SVG_NS = 'http://www.w3.org/2000/svg'; + + const state = { + year: new Date().getFullYear(), + compareYear: new Date().getFullYear() - 1, + currentYear: new Date().getFullYear(), + currentMonth: new Date().getMonth() + 1, + currentDay: new Date().getDate(), + canAccess: true, + isAdmin: false, + logoUrl: '', + bannerUrl: '', + activeView: 'grid', + activeFilter: 'all', + searchQuery: '', + showArchived: false, + members: [], + dues: {}, + multiYearStats: { + availableYears: [], + byYear: {}, + }, + templates: [], + activeTemplateSlug: 'tax_receipt', + selectedRecipientIds: new Set(), + mailingNoEmailOnly: false, + previewMemberId: null, + lastFocusedTemplateField: null, + permissionsLoaded: false, + permissions: { + accessMode: 'restricted', + allowedGroups: new Set(), + allowedUsers: new Set(), + availableGroups: [], + availableUsers: [], + groupSearch: '', + userSearch: '', + }, + }; + + /** + * Génère l'URL vers un endpoint du contrôleur Nextcloud. + */ + function apiUrl(path) { + if (window.OC && typeof window.OC.generateUrl === 'function') { + return window.OC.generateUrl('/apps/ul_tresorerie' + path); + } + return '/index.php/apps/ul_tresorerie' + path; + } + + /** + * Exécute un appel fetch vers le backend avec le jeton CSRF de Nextcloud. + */ + async function apiFetch(path, options = {}) { + const headers = Object.assign( + { + 'requesttoken': (window.OC && window.OC.requestToken) ? window.OC.requestToken : '', + 'OCS-APIRequest': 'true', + }, + options.headers || {} + ); + + if (options.body && !(options.body instanceof FormData) && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(apiUrl(path), Object.assign({}, options, { headers })); + let payload = null; + try { + payload = await response.json(); + } catch (_err) { + payload = null; + } + + if (!response.ok) { + const msg = (payload && payload.error) ? payload.error : ('Erreur HTTP ' + response.status); + throw new Error(msg); + } + + return payload; + } + + /** + * Formate un nombre en euros au format français (ex: 1 234,50 €). + */ + function formatEuros(amount) { + const num = Number(amount) || 0; + return num.toLocaleString('fr-FR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + ' €'; + } + + /** + * Formate un montant court pour les axes de graphiques (ex: 1 250 €). + */ + function formatShortEuros(amount) { + const num = Math.round(Number(amount) || 0); + return num.toLocaleString('fr-FR') + ' €'; + } + + /** + * Affiche une notification temporaire en haut de l'interface. + */ + let toastTimer = null; + function showToast(message, type = 'success') { + const toast = document.getElementById('ul-toast'); + if (!toast) { + return; + } + toast.textContent = message; + toast.className = 'ul-toast ul-toast-' + type; + if (toastTimer) { + clearTimeout(toastTimer); + } + toastTimer = setTimeout(() => { + toast.classList.add('hidden'); + }, 4500); + } + + /** + * Retourne le dernier mois exigible pour une année donnée : + * - Année passée (< currentYear) : 12 + * - Année future (> currentYear) : 0 + * - Année en cours (=== currentYear) : + * - jusqu'au 10 du mois inclus (currentDay <= 10) : mois précédent (currentMonth - 1) + * - après le 10 du mois (currentDay > 10) : mois en cours (currentMonth) + */ + function getMaxRequiredMonth(year = state.year) { + if (year < state.currentYear) { + return 12; + } + if (year > state.currentYear) { + return 0; + } + return state.currentDay > 10 + ? state.currentMonth + : Math.max(0, state.currentMonth - 1); + } + + /** + * Calcule les indicateurs annuels et le niveau d'alerte d'un syndiqué pour l'année sélectionnée. + */ + function computeMemberMetrics(member) { + const memberDues = state.dues[member.id] || {}; + const maxCheckMonth = getMaxRequiredMonth(state.year); + + let totalPaid = 0; + let paidMonthsCount = 0; + let lastPaidAmount = 0; + + for (let m = 1; m <= 12; m++) { + const cell = memberDues[m]; + const amt = cell ? Number(cell.amount) || 0 : 0; + if (amt > 0) { + totalPaid += amt; + paidMonthsCount++; + lastPaidAmount = amt; + } + } + + totalPaid = Math.round(totalPaid * 100) / 100; + + const missingMonths = []; + const missingMonthsNames = []; + for (let m = 1; m <= maxCheckMonth; m++) { + const cell = memberDues[m]; + const amt = cell ? Number(cell.amount) || 0 : 0; + if (amt <= 0) { + missingMonths.push(m); + missingMonthsNames.push(MONTH_NAMES[m]); + } + } + + let alertLevel = 'none'; + if (Number(member.is_archived) === 0) { + if (maxCheckMonth > 0 && totalPaid === 0) { + alertLevel = 'red'; + } else if (missingMonths.length > 0) { + alertLevel = 'orange'; + } else { + alertLevel = 'uptodate'; + } + } + + const usualAmount = (Number(member.default_amount) > 0) + ? Number(member.default_amount) + : lastPaidAmount; + + const creditImpot = Math.round(totalPaid * 0.66 * 100) / 100; + const estimatedArrears = Math.round(missingMonths.length * usualAmount * 100) / 100; + + return { + totalPaid, + paidMonthsCount, + missingMonths, + missingMonthsNames, + alertLevel, + usualAmount, + creditImpot, + estimatedArrears, + }; + } + + /** + * Charge l'état complet depuis le serveur pour l'année active. + */ + async function loadState(year) { + try { + const previousYear = state.year; + const data = await apiFetch('/api/state?year=' + encodeURIComponent(year)); + state.year = Number(data.year) || year; + state.currentYear = Number(data.currentYear) || new Date().getFullYear(); + state.currentMonth = Number(data.currentMonth) || (new Date().getMonth() + 1); + state.currentDay = Number(data.currentDay) || new Date().getDate(); + state.members = Array.isArray(data.members) ? data.members : []; + state.dues = data.dues || {}; + state.templates = Array.isArray(data.templates) ? data.templates : []; + if (data.multiYearStats) { + state.multiYearStats = data.multiYearStats; + } + + if (state.compareYear === state.year || previousYear !== state.year) { + state.compareYear = state.year - 1; + } + + updateYearLabels(); + renderAll(); + syncTemplateEditorFromSlug(state.activeTemplateSlug, true); + } catch (err) { + showToast('Erreur lors du chargement : ' + err.message, 'error'); + } + } + + function updateYearLabels() { + const ids = [ + 'label-current-year', + 'nav-year-indicator', + 'summary-year', + 'mailing-year-label', + 'dash-label-year', + 'kpi-year-label', + 'chart-main-year-label', + 'legend-year-curr', + ]; + ids.forEach((id) => { + const el = document.getElementById(id); + if (el) { + el.textContent = String(state.year); + } + }); + } + + /** + * Met à jour les compteurs de la barre latérale, la grille, le tableau de bord et la vue publipostage. + */ + function renderAll() { + updateSidebarCounters(); + renderGrid(); + renderDashboard(); + if (state.activeView === 'mailing') { + renderMailingRecipients(); + renderMailingPreview(); + } + } + + /** + * Calcule et affiche les compteurs d'alertes sur tous les syndiqués actifs. + */ + function updateSidebarCounters() { + let countAllActive = 0; + let countUpToDate = 0; + let countOrange = 0; + let countRed = 0; + let grandTotalActive = 0; + + state.members.forEach((member) => { + const metrics = computeMemberMetrics(member); + if (Number(member.is_archived) === 0) { + countAllActive++; + grandTotalActive += metrics.totalPaid; + if (metrics.alertLevel === 'uptodate') { + countUpToDate++; + } else if (metrics.alertLevel === 'orange') { + countOrange++; + } else if (metrics.alertLevel === 'red') { + countRed++; + } + } + }); + + setText('count-all', String(countAllActive)); + setText('count-uptodate', String(countUpToDate)); + setText('count-orange', String(countOrange)); + setText('count-orange-inline', String(countOrange)); + setText('count-red', String(countRed)); + setText('count-red-inline', String(countRed)); + setText('summary-active-count', String(countAllActive)); + setText('summary-grand-total', formatEuros(grandTotalActive)); + } + + function setText(id, text) { + const el = document.getElementById(id); + if (el) { + el.textContent = text; + } + } + + /** + * Retourne la liste des syndiqués filtrés selon les critères courants de la grille. + */ + function getFilteredMembersForGrid() { + const q = state.searchQuery.trim().toLowerCase(); + + return state.members.filter((member) => { + if (!state.showArchived && Number(member.is_archived) === 1) { + return false; + } + + const metrics = computeMemberMetrics(member); + if (state.activeFilter === 'uptodate' && metrics.alertLevel !== 'uptodate') { + return false; + } + if (state.activeFilter === 'orange' && metrics.alertLevel !== 'orange') { + return false; + } + if (state.activeFilter === 'red' && metrics.alertLevel !== 'red') { + return false; + } + + if (q !== '') { + const haystack = [ + member.last_name, + member.first_name, + member.city, + member.email, + member.cogitiel_id, + member.postal_code, + ].join(' ').toLowerCase(); + if (!haystack.includes(q)) { + return false; + } + } + + return true; + }); + } + + /** + * Construit le tableau annuel des cotisations et met à jour les totaux mensuels et annuels. + */ + function renderGrid() { + const tbody = document.getElementById('dues-tbody'); + if (!tbody) { + return; + } + tbody.textContent = ''; + + const filteredMembers = getFilteredMembersForGrid(); + const monthlyTotals = { + 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, + 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, + }; + let grandTotal = 0; + + updateFilterPill(); + + if (filteredMembers.length === 0) { + const emptyRow = document.createElement('tr'); + const emptyCell = document.createElement('td'); + emptyCell.colSpan = 14; + emptyCell.className = 'ul-empty-cell'; + emptyCell.textContent = state.members.length === 0 + ? 'Aucun syndiqué en base. Cliquez sur « Importer fichier .asp » ou « + Nouveau syndiqué » pour commencer.' + : 'Aucun syndiqué ne correspond aux critères de recherche ou de filtre sélectionnés.'; + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + } else { + filteredMembers.forEach((member) => { + const metrics = computeMemberMetrics(member); + const memberDues = state.dues[member.id] || {}; + + const tr = document.createElement('tr'); + tr.dataset.memberId = String(member.id); + + if (Number(member.is_archived) === 1) { + tr.classList.add('ul-row-archived'); + } else if (metrics.alertLevel === 'red') { + tr.classList.add('ul-row-alert-red'); + } else if (metrics.alertLevel === 'orange') { + tr.classList.add('ul-row-alert-orange'); + } + + // Colonne 1 figée : Nom, Prénom, Badges d'alerte & bouton édition + const tdMember = document.createElement('td'); + tdMember.className = 'col-sticky-left ul-member-cell'; + + const memberTopRow = document.createElement('div'); + memberTopRow.className = 'ul-member-top-row'; + + const nameWrap = document.createElement('div'); + nameWrap.className = 'ul-member-name-wrap'; + + const fullName = document.createElement('span'); + fullName.className = 'ul-member-fullname'; + fullName.textContent = (member.last_name + ' ' + member.first_name).trim(); + nameWrap.appendChild(fullName); + + const subMeta = document.createElement('span'); + subMeta.className = 'ul-member-submeta'; + const metaParts = ['#' + member.cogitiel_id]; + if (member.city) { + metaParts.push(member.city); + } + subMeta.textContent = metaParts.join(' · '); + nameWrap.appendChild(subMeta); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'ul-btn-edit-member'; + editBtn.dataset.action = 'edit-member'; + editBtn.dataset.memberId = String(member.id); + editBtn.title = 'Modifier la fiche du syndiqué'; + editBtn.textContent = '✏️'; + + memberTopRow.appendChild(nameWrap); + memberTopRow.appendChild(editBtn); + tdMember.appendChild(memberTopRow); + + // Badges d'alerte et statut + const badgesRow = document.createElement('div'); + badgesRow.className = 'ul-badges-row'; + + if (Number(member.is_archived) === 1) { + const bArch = document.createElement('span'); + bArch.className = 'ul-badge ul-badge-archived'; + bArch.textContent = 'Archivé'; + badgesRow.appendChild(bArch); + } else if (metrics.alertLevel === 'red') { + const bRed = document.createElement('span'); + bRed.className = 'ul-badge ul-badge-red'; + bRed.textContent = '🚨 Aucun versement'; + badgesRow.appendChild(bRed); + } else if (metrics.alertLevel === 'orange') { + const bOrange = document.createElement('span'); + bOrange.className = 'ul-badge ul-badge-orange'; + bOrange.textContent = '🟠 ' + metrics.missingMonths.length + ' mois manquant' + (metrics.missingMonths.length > 1 ? 's' : ''); + bOrange.title = 'Mois manquants : ' + metrics.missingMonthsNames.join(', '); + badgesRow.appendChild(bOrange); + } else if (metrics.alertLevel === 'uptodate') { + const bGreen = document.createElement('span'); + bGreen.className = 'ul-badge ul-badge-green'; + bGreen.textContent = '✓ À jour'; + badgesRow.appendChild(bGreen); + } + + tdMember.appendChild(badgesRow); + tr.appendChild(tdMember); + + // Colonnes 2 à 13 : les 12 mois + for (let m = 1; m <= 12; m++) { + const tdMonth = document.createElement('td'); + tdMonth.dataset.action = 'open-due'; + tdMonth.dataset.memberId = String(member.id); + tdMonth.dataset.month = String(m); + + const due = memberDues[m]; + const amount = due ? (Number(due.amount) || 0) : 0; + + if (amount > 0) { + monthlyTotals[m] += amount; + tdMonth.className = 'ul-due-cell ul-due-paid'; + + const amountSpan = document.createElement('span'); + amountSpan.className = 'ul-due-amount'; + amountSpan.textContent = formatEuros(amount); + tdMonth.appendChild(amountSpan); + + const methodBadge = document.createElement('span'); + methodBadge.className = 'ul-due-method-badge'; + const methodLetter = METHOD_BADGES[due.payment_method] || 'V'; + methodBadge.textContent = methodLetter; + tdMonth.appendChild(methodBadge); + + let tooltip = MONTH_NAMES[m] + ' ' + state.year + ' : ' + formatEuros(amount) + ' (' + due.payment_method + ')'; + if (due.note) { + tooltip += ' — Note : ' + due.note; + const noteDot = document.createElement('span'); + noteDot.className = 'ul-due-note-dot'; + noteDot.textContent = '•'; + tdMonth.appendChild(noteDot); + } + tdMonth.title = tooltip; + } else { + tdMonth.className = 'ul-due-cell ul-due-empty'; + tdMonth.title = 'Cliquer pour saisir la cotisation de ' + MONTH_NAMES[m] + ' ' + state.year; + const plusHint = document.createElement('span'); + plusHint.className = 'ul-due-empty-plus'; + plusHint.textContent = '+'; + tdMonth.appendChild(plusHint); + } + + tr.appendChild(tdMonth); + } + + // Colonne 14 figée à droite : Total Annuel du syndiqué + grandTotal += metrics.totalPaid; + const tdTotal = document.createElement('td'); + tdTotal.className = 'col-sticky-right ul-member-total-cell'; + if (metrics.totalPaid === 0) { + tdTotal.classList.add('ul-total-zero'); + } + tdTotal.textContent = formatEuros(metrics.totalPaid); + tr.appendChild(tdTotal); + + tbody.appendChild(tr); + }); + } + + // Mise à jour du pied de tableau (Total Mensuel & Grand Total Annuel) + setText('tfoot-members-count', String(filteredMembers.length)); + for (let m = 1; m <= 12; m++) { + const roundedMonth = Math.round(monthlyTotals[m] * 100) / 100; + setText('foot-month-' + m, formatEuros(roundedMonth)); + } + const roundedGrandTotal = Math.round(grandTotal * 100) / 100; + setText('foot-grand-total', formatEuros(roundedGrandTotal)); + } + + function updateFilterPill() { + const pill = document.getElementById('active-filter-indicator'); + if (!pill) { + return; + } + const labels = { + all: '', + uptodate: 'Filtre actif : 🟢 Cotisations à jour', + orange: 'Filtre actif : 🟠 Cotisations pas à jour (Mois manquants)', + red: 'Filtre actif : 🔴 Aucun versement sur l\'année (0,00 €)', + }; + const text = labels[state.activeFilter] || ''; + if (text === '') { + pill.classList.add('hidden'); + pill.textContent = ''; + } else { + pill.classList.remove('hidden'); + pill.textContent = text + ' (Cliquer pour afficher tous)'; + } + } + + /* ====================================================================== + * TABLEAU DE BORD & COMPARAISON PLURIANNUELLE (#view-dashboard) + * ====================================================================== */ + + function getEmptyYearStats(year) { + return { + year: year, + totalAmount: 0, + duesCount: 0, + payingMembersCount: 0, + monthlyTotals: { + 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, + 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, + }, + monthlyCounts: { + 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, + 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, + }, + byMethod: { + 'Virement': 0, + 'Prélèvement': 0, + 'Chèque': 0, + 'Espèces': 0, + }, + }; + } + + function getYearStatsObject(year) { + const byYear = (state.multiYearStats && state.multiYearStats.byYear) || {}; + return byYear[year] || byYear[String(year)] || getEmptyYearStats(year); + } + + /** + * Met à jour l'intégralité du Tableau de bord (Chiffres clés, Graphiques SVG et Tableau comparatif). + */ + function renderDashboard() { + const dashSection = document.getElementById('view-dashboard'); + if (!dashSection) { + return; + } + + // 1. Synchroniser le sélecteur d'année de comparaison + const compareSelect = document.getElementById('dash-select-compare-year'); + const availYears = Array.isArray(state.multiYearStats.availableYears) && state.multiYearStats.availableYears.length > 0 + ? state.multiYearStats.availableYears + : [state.year - 2, state.year - 1, state.year, state.year + 1]; + + const compareChoices = availYears.filter((y) => Number(y) !== Number(state.year)); + if (!compareChoices.includes(state.compareYear)) { + state.compareYear = state.year - 1; + if (!compareChoices.includes(state.compareYear)) { + compareChoices.unshift(state.compareYear); + } + } + + if (compareSelect) { + compareSelect.textContent = ''; + compareChoices.sort((a, b) => Number(b) - Number(a)).forEach((y) => { + const opt = document.createElement('option'); + opt.value = String(y); + const statsY = getYearStatsObject(y); + opt.textContent = y + ' (' + formatEuros(statsY.totalAmount) + ')'; + compareSelect.appendChild(opt); + }); + compareSelect.value = String(state.compareYear); + } + + setText('chart-main-year-label', String(state.year)); + setText('chart-main-comp-label', String(state.compareYear)); + setText('legend-year-curr', String(state.year)); + setText('legend-year-comp', String(state.compareYear)); + + const currStats = getYearStatsObject(state.year); + const compStats = getYearStatsObject(state.compareYear); + + // 2. Calculer les indicateurs syndiqués sur l'année active + let activeCount = 0; + let upToDateCount = 0; + let orangeCount = 0; + let redCount = 0; + let totalMissingMonths = 0; + let totalEstimatedArrears = 0; + + state.members.forEach((member) => { + if (Number(member.is_archived) === 0) { + activeCount++; + const m = computeMemberMetrics(member); + if (m.alertLevel === 'uptodate') { + upToDateCount++; + } else if (m.alertLevel === 'orange') { + orangeCount++; + } else if (m.alertLevel === 'red') { + redCount++; + } + totalMissingMonths += m.missingMonths.length; + totalEstimatedArrears += m.estimatedArrears; + } + }); + + // 3. Remplir les 5 cartes KPI + const currTotal = Number(currStats.totalAmount) || 0; + const compTotal = Number(compStats.totalAmount) || 0; + const diffEuro = currTotal - compTotal; + const diffEl = document.getElementById('kpi-total-diff'); + if (diffEl) { + if (compTotal > 0) { + const pct = Math.round(((currTotal - compTotal) / compTotal) * 1000) / 10; + const sign = pct >= 0 ? '+' : ''; + diffEl.textContent = sign + pct.toLocaleString('fr-FR') + ' % vs ' + state.compareYear; + diffEl.className = 'ul-kpi-trend ' + (pct >= 0 ? 'ul-trend-up' : 'ul-trend-down'); + } else if (currTotal > 0) { + diffEl.textContent = 'Nouveau vs ' + state.compareYear; + diffEl.className = 'ul-kpi-trend ul-trend-up'; + } else { + diffEl.textContent = '0 % vs ' + state.compareYear; + diffEl.className = 'ul-kpi-trend'; + } + } + + setText('kpi-total-amount', formatEuros(currTotal)); + const signDiff = diffEuro >= 0 ? '+' : ''; + setText( + 'kpi-total-sub', + 'En ' + state.compareYear + ' : ' + formatEuros(compTotal) + ' (' + signDiff + formatEuros(diffEuro) + ')' + ); + + const maxReqMonth = getMaxRequiredMonth(state.year); + const activeMonthsElapsed = (state.year < state.currentYear) + ? 12 + : Math.max(1, maxReqMonth); + const monthlyAvg = currTotal / activeMonthsElapsed; + const avgDue = currStats.duesCount > 0 ? (currTotal / currStats.duesCount) : 0; + setText('kpi-monthly-avg', formatEuros(monthlyAvg)); + setText('kpi-avg-due', 'Cotisation moy. : ' + formatEuros(avgDue) + ' / mois'); + + const recoveryRate = activeCount > 0 ? Math.round((upToDateCount / activeCount) * 100) : 0; + setText('kpi-recovery-rate', recoveryRate + ' %'); + setText('kpi-recovery-sub', upToDateCount + ' à jour sur ' + activeCount + ' syndiqués actifs'); + + setText('kpi-estimated-arrears', formatEuros(totalEstimatedArrears)); + setText('kpi-arrears-sub', totalMissingMonths + ' mois manquant(s) cumulé(s)'); + + const taxCreditTotal = Math.round(currTotal * 0.66 * 100) / 100; + setText('kpi-tax-credit', formatEuros(taxCreditTotal)); + setText( + 'kpi-dues-count', + currStats.duesCount + ' mensualité(s) · ' + currStats.payingMembersCount + ' cotisant(s)' + ); + + // 4. Dessiner les graphiques SVG et le tableau comparatif + renderMonthlyComparisonSvg(currStats, compStats); + renderMultiYearSvg(availYears); + renderMethodsAndStatusCard(currStats, { + activeCount, + upToDateCount, + orangeCount, + redCount, + }); + renderMonthlyComparisonTable(currStats, compStats); + } + + /** + * Crée un élément SVG avec attributs. + */ + function createSvgEl(tag, attrs = {}) { + const el = document.createElementNS(SVG_NS, tag); + Object.entries(attrs).forEach(([k, v]) => { + el.setAttribute(k, String(v)); + }); + return el; + } + + /** + * Graphique SVG principal : Barres mensuelles (Année N vs Année comparée) + Courbes cumulées. + */ + function renderMonthlyComparisonSvg(currStats, compStats) { + const container = document.getElementById('dash-chart-monthly-comparison'); + if (!container) { + return; + } + container.textContent = ''; + + const width = 760; + const height = 250; + const padLeft = 58; + const padRight = 58; + const padTop = 20; + const padBottom = 34; + const plotW = width - padLeft - padRight; + const plotH = height - padTop - padBottom; + + let maxMonthVal = 10; + let cumCurr = 0; + let cumComp = 0; + const cumCurrArr = []; + const cumCompArr = []; + + for (let m = 1; m <= 12; m++) { + const vCurr = Number(currStats.monthlyTotals[m]) || 0; + const vComp = Number(compStats.monthlyTotals[m]) || 0; + if (vCurr > maxMonthVal) { + maxMonthVal = vCurr; + } + if (vComp > maxMonthVal) { + maxMonthVal = vComp; + } + cumCurr += vCurr; + cumComp += vComp; + cumCurrArr.push(cumCurr); + cumCompArr.push(cumComp); + } + + const maxCumVal = Math.max(cumCurr, cumComp, maxMonthVal, 50); + const niceMaxMonth = Math.ceil(maxMonthVal * 1.15 / 10) * 10 || 100; + const niceMaxCum = Math.ceil(maxCumVal * 1.1 / 50) * 50 || 500; + + const svg = createSvgEl('svg', { + viewBox: `0 0 ${width} ${height}`, + class: 'ul-svg-chart', + role: 'img', + 'aria-label': `Évolution mensuelle ${state.year} vs ${state.compareYear}`, + }); + + // Lignes de grille horizontales (4 paliers) + const steps = 4; + for (let i = 0; i <= steps; i++) { + const ratio = i / steps; + const y = padTop + plotH - ratio * plotH; + const monthAxisVal = Math.round(niceMaxMonth * ratio); + const cumAxisVal = Math.round(niceMaxCum * ratio); + + svg.appendChild(createSvgEl('line', { + x1: padLeft, + y1: y, + x2: width - padRight, + y2: y, + stroke: i === 0 ? '#94a3b8' : '#e2e8f0', + 'stroke-width': i === 0 ? 1.5 : 1, + 'stroke-dasharray': i === 0 ? '' : '3,3', + })); + + // Axe gauche (Mensuel) + const lblLeft = createSvgEl('text', { + x: padLeft - 6, + y: y + 4, + 'text-anchor': 'end', + fill: '#475569', + 'font-size': '10', + 'font-weight': '600', + }); + lblLeft.textContent = monthAxisVal + ' €'; + svg.appendChild(lblLeft); + + // Axe droit (Cumul annuel) + const lblRight = createSvgEl('text', { + x: width - padRight + 6, + y: y + 4, + 'text-anchor': 'start', + fill: '#991b1b', + 'font-size': '10', + 'font-weight': '600', + }); + lblRight.textContent = cumAxisVal + ' €'; + svg.appendChild(lblRight); + } + + const groupWidth = plotW / 12; + const barWidth = Math.min(16, (groupWidth - 12) / 2); + + const currCumPoints = []; + const compCumPoints = []; + + for (let m = 1; m <= 12; m++) { + const vCurr = Number(currStats.monthlyTotals[m]) || 0; + const vComp = Number(compStats.monthlyTotals[m]) || 0; + const groupX = padLeft + (m - 1) * groupWidth; + const centerX = groupX + groupWidth / 2; + + // Barre Année comparée (Gris ardoise à gauche) + const hComp = Math.max(0, (vComp / niceMaxMonth) * plotH); + const rectComp = createSvgEl('rect', { + x: centerX - barWidth - 1.5, + y: padTop + plotH - hComp, + width: barWidth, + height: Math.max(hComp, 2), + rx: 2, + fill: '#94a3b8', + class: 'ul-chart-bar', + }); + const titleComp = createSvgEl('title'); + titleComp.textContent = `${MONTH_NAMES[m]} ${state.compareYear} : ${formatEuros(vComp)} (Cumul : ${formatEuros(cumCompArr[m - 1])})`; + rectComp.appendChild(titleComp); + svg.appendChild(rectComp); + + // Barre Année active (Rouge CGT à droite) + const hCurr = Math.max(0, (vCurr / niceMaxMonth) * plotH); + const rectCurr = createSvgEl('rect', { + x: centerX + 1.5, + y: padTop + plotH - hCurr, + width: barWidth, + height: Math.max(hCurr, 2), + rx: 2, + fill: '#d32f2f', + class: 'ul-chart-bar', + }); + const titleCurr = createSvgEl('title'); + titleCurr.textContent = `${MONTH_NAMES[m]} ${state.year} : ${formatEuros(vCurr)} (Cumul : ${formatEuros(cumCurrArr[m - 1])})`; + rectCurr.appendChild(titleCurr); + svg.appendChild(rectCurr); + + // Étiquette du mois en bas + const lblMonth = createSvgEl('text', { + x: centerX, + y: height - 10, + 'text-anchor': 'middle', + fill: '#1e293b', + 'font-size': '11', + 'font-weight': '700', + }); + lblMonth.textContent = MONTH_SHORT[m]; + svg.appendChild(lblMonth); + + // Coordonnées des courbes de cumul (sur l'axe de droite) + const yCumComp = padTop + plotH - (cumCompArr[m - 1] / niceMaxCum) * plotH; + const yCumCurr = padTop + plotH - (cumCurrArr[m - 1] / niceMaxCum) * plotH; + compCumPoints.push({ x: centerX, y: yCumComp, val: cumCompArr[m - 1], month: m }); + currCumPoints.push({ x: centerX, y: yCumCurr, val: cumCurrArr[m - 1], month: m }); + } + + // Courbe cumulée Année comparée (pointillés gris foncé) + const compPolyline = compCumPoints.map((p) => `${p.x},${p.y}`).join(' '); + svg.appendChild(createSvgEl('polyline', { + fill: 'none', + stroke: '#475569', + 'stroke-width': 2, + 'stroke-dasharray': '4,3', + points: compPolyline, + })); + + // Courbe cumulée Année active (ligne continue rouge foncé) + const currPolyline = currCumPoints.map((p) => `${p.x},${p.y}`).join(' '); + svg.appendChild(createSvgEl('polyline', { + fill: 'none', + stroke: '#991b1b', + 'stroke-width': 2.5, + points: currPolyline, + })); + + // Points sur la courbe cumulée active + currCumPoints.forEach((p) => { + const dot = createSvgEl('circle', { + cx: p.x, + cy: p.y, + r: 3.5, + fill: '#ffffff', + stroke: '#991b1b', + 'stroke-width': 2, + }); + const t = createSvgEl('title'); + t.textContent = `Cumul ${MONTH_NAMES[p.month]} ${state.year} : ${formatEuros(p.val)}`; + dot.appendChild(t); + svg.appendChild(dot); + }); + + container.appendChild(svg); + } + + /** + * Graphique SVG : Comparaison pluriannuelle (Barres par année cliquables). + */ + function renderMultiYearSvg(availYears) { + const container = document.getElementById('dash-chart-multiyear'); + if (!container) { + return; + } + container.textContent = ''; + + const sortedYears = [...availYears].map(Number).sort((a, b) => a - b); + const width = 380; + const height = 128; + const padLeft = 14; + const padRight = 14; + const padTop = 22; + const padBottom = 26; + const plotW = width - padLeft - padRight; + const plotH = height - padTop - padBottom; + + let maxYearTotal = 50; + sortedYears.forEach((y) => { + const st = getYearStatsObject(y); + if (st.totalAmount > maxYearTotal) { + maxYearTotal = st.totalAmount; + } + }); + const niceMax = maxYearTotal * 1.18; + + const svg = createSvgEl('svg', { + viewBox: `0 0 ${width} ${height}`, + class: 'ul-svg-chart', + role: 'img', + 'aria-label': 'Comparaison pluriannuelle des cotisations', + }); + + const colW = plotW / Math.max(1, sortedYears.length); + const barW = Math.min(44, colW * 0.58); + + sortedYears.forEach((y, idx) => { + const st = getYearStatsObject(y); + const val = Number(st.totalAmount) || 0; + const h = Math.max(3, (val / niceMax) * plotH); + const centerX = padLeft + idx * colW + colW / 2; + const x = centerX - barW / 2; + const barY = padTop + plotH - h; + const isCurrent = y === state.year; + const isCompare = y === state.compareYear; + + const g = createSvgEl('g', { + class: 'ul-multiyear-bar-group', + 'data-year': String(y), + }); + + const rect = createSvgEl('rect', { + x: x, + y: barY, + width: barW, + height: h, + rx: 4, + fill: isCurrent ? '#d32f2f' : (isCompare ? '#475569' : '#cbd5e1'), + }); + const title = createSvgEl('title'); + title.textContent = `Année ${y} : ${formatEuros(val)} (${st.payingMembersCount} cotisants, ${st.duesCount} versements) — Cliquer pour ouvrir ${y}`; + rect.appendChild(title); + g.appendChild(rect); + + const valLbl = createSvgEl('text', { + x: centerX, + y: barY - 5, + 'text-anchor': 'middle', + fill: isCurrent ? '#991b1b' : '#334155', + 'font-size': '10', + 'font-weight': '700', + }); + valLbl.textContent = formatShortEuros(val); + g.appendChild(valLbl); + + const yearLbl = createSvgEl('text', { + x: centerX, + y: height - 7, + 'text-anchor': 'middle', + fill: isCurrent ? '#d32f2f' : '#1e293b', + 'font-size': '11.5', + 'font-weight': isCurrent ? '800' : '600', + }); + yearLbl.textContent = String(y); + g.appendChild(yearLbl); + + svg.appendChild(g); + }); + + container.appendChild(svg); + } + + /** + * Carte Modes de règlement & Répartition des statuts des syndiqués actifs. + */ + function renderMethodsAndStatusCard(currStats, counts) { + const container = document.getElementById('dash-chart-methods-status'); + if (!container) { + return; + } + container.textContent = ''; + + const totalAmount = Number(currStats.totalAmount) || 0; + const methods = ['Virement', 'Prélèvement', 'Chèque', 'Espèces']; + + const methodsBox = document.createElement('div'); + methodsBox.className = 'ul-methods-breakdown'; + + methods.forEach((method) => { + const amt = (currStats.byMethod && Number(currStats.byMethod[method])) || 0; + const pct = totalAmount > 0 ? Math.round((amt / totalAmount) * 100) : 0; + + const row = document.createElement('div'); + row.className = 'ul-method-stat-row'; + + const head = document.createElement('div'); + head.className = 'ul-method-stat-head'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'ul-method-stat-name'; + const dot = document.createElement('span'); + dot.className = 'ul-method-color-dot'; + dot.style.backgroundColor = METHOD_COLORS[method] || '#15803d'; + nameSpan.appendChild(dot); + nameSpan.appendChild(document.createTextNode(method)); + + const valSpan = document.createElement('strong'); + valSpan.textContent = formatEuros(amt) + ' (' + pct + ' %)'; + + head.appendChild(nameSpan); + head.appendChild(valSpan); + + const barTrack = document.createElement('div'); + barTrack.className = 'ul-progress-track'; + const barFill = document.createElement('div'); + barFill.className = 'ul-progress-fill'; + barFill.style.width = pct + '%'; + barFill.style.backgroundColor = METHOD_COLORS[method] || '#15803d'; + barTrack.appendChild(barFill); + + row.appendChild(head); + row.appendChild(barTrack); + methodsBox.appendChild(row); + }); + + // Barre empilée de statut des syndiqués actifs + const statusBox = document.createElement('div'); + statusBox.className = 'ul-status-breakdown'; + + const statusTitle = document.createElement('div'); + statusTitle.className = 'ul-status-breakdown-title'; + statusTitle.textContent = 'État des ' + counts.activeCount + ' syndiqués actifs (' + state.year + ')'; + statusBox.appendChild(statusTitle); + + const stackedBar = document.createElement('div'); + stackedBar.className = 'ul-stacked-status-bar'; + + const totalActive = Math.max(1, counts.activeCount); + const segments = [ + { label: 'À jour', count: counts.upToDateCount, color: '#15803d' }, + { label: 'Pas à jour', count: counts.orangeCount, color: '#d97706' }, + { label: '0 € versé', count: counts.redCount, color: '#dc2626' }, + ]; + + segments.forEach((seg) => { + if (seg.count > 0) { + const segDiv = document.createElement('div'); + segDiv.className = 'ul-stacked-segment'; + segDiv.style.width = ((seg.count / totalActive) * 100).toFixed(1) + '%'; + segDiv.style.backgroundColor = seg.color; + segDiv.title = seg.label + ' : ' + seg.count; + stackedBar.appendChild(segDiv); + } + }); + statusBox.appendChild(stackedBar); + + const pillsWrap = document.createElement('div'); + pillsWrap.className = 'ul-status-mini-pills'; + segments.forEach((seg) => { + const p = document.createElement('span'); + p.className = 'ul-status-mini-pill'; + p.textContent = seg.label + ' : ' + seg.count; + pillsWrap.appendChild(p); + }); + + statusBox.appendChild(pillsWrap); + + container.appendChild(methodsBox); + container.appendChild(statusBox); + } + + /** + * Tableau comparatif mensuel synthétique (Ligne Année N, Ligne Année comparée, Ligne Écart). + */ + function renderMonthlyComparisonTable(currStats, compStats) { + const tbody = document.getElementById('dash-comp-tbody'); + if (!tbody) { + return; + } + tbody.textContent = ''; + + // Ligne 1 : Année active + const trCurr = document.createElement('tr'); + trCurr.className = 'ul-comp-row-curr'; + const thCurr = document.createElement('th'); + thCurr.textContent = 'Année ' + state.year; + trCurr.appendChild(thCurr); + for (let m = 1; m <= 12; m++) { + const td = document.createElement('td'); + td.textContent = formatShortEuros(currStats.monthlyTotals[m] || 0); + trCurr.appendChild(td); + } + const tdTotalCurr = document.createElement('td'); + tdTotalCurr.className = 'ul-comp-col-total'; + tdTotalCurr.textContent = formatEuros(currStats.totalAmount || 0); + trCurr.appendChild(tdTotalCurr); + tbody.appendChild(trCurr); + + // Ligne 2 : Année de comparaison + const trComp = document.createElement('tr'); + trComp.className = 'ul-comp-row-prev'; + const thComp = document.createElement('th'); + thComp.textContent = 'Année ' + state.compareYear; + trComp.appendChild(thComp); + for (let m = 1; m <= 12; m++) { + const td = document.createElement('td'); + td.textContent = formatShortEuros(compStats.monthlyTotals[m] || 0); + trComp.appendChild(td); + } + const tdTotalComp = document.createElement('td'); + tdTotalComp.className = 'ul-comp-col-total'; + tdTotalComp.textContent = formatEuros(compStats.totalAmount || 0); + trComp.appendChild(tdTotalComp); + tbody.appendChild(trComp); + + // Ligne 3 : Écart mensuel (€) + const trDiff = document.createElement('tr'); + trDiff.className = 'ul-comp-row-diff'; + const thDiff = document.createElement('th'); + thDiff.textContent = 'Écart (' + state.year + ' - ' + state.compareYear + ')'; + trDiff.appendChild(thDiff); + for (let m = 1; m <= 12; m++) { + const diff = (Number(currStats.monthlyTotals[m]) || 0) - (Number(compStats.monthlyTotals[m]) || 0); + const td = document.createElement('td'); + const sign = diff > 0 ? '+' : ''; + td.textContent = sign + formatShortEuros(diff); + td.className = diff > 0 ? 'ul-diff-pos' : (diff < 0 ? 'ul-diff-neg' : 'ul-diff-zero'); + trDiff.appendChild(td); + } + const totalDiff = (Number(currStats.totalAmount) || 0) - (Number(compStats.totalAmount) || 0); + const tdTotalDiff = document.createElement('td'); + const totalSign = totalDiff > 0 ? '+' : ''; + tdTotalDiff.textContent = totalSign + formatEuros(totalDiff); + tdTotalDiff.className = 'ul-comp-col-total ' + (totalDiff >= 0 ? 'ul-diff-pos' : 'ul-diff-neg'); + trDiff.appendChild(tdTotalDiff); + tbody.appendChild(trDiff); + } + + /* ====================================================================== + * SAISIE DE COTISATIONS & GESTION DES FICHES SYNDIQUÉS + * ====================================================================== */ + + /** + * Détermine le pré-remplissage intelligent pour un syndiqué et un mois cliqué. + */ + function computePrefillForCell(member, month) { + const memberDues = state.dues[member.id] || {}; + const existingDue = memberDues[month]; + + if (existingDue && Number(existingDue.amount) > 0) { + return { + isExisting: true, + amount: Number(existingDue.amount).toFixed(2), + method: existingDue.payment_method || 'Virement', + note: existingDue.note || '', + hint: 'Modification de la cotisation existante de ' + MONTH_NAMES[month] + ' ' + state.year + '.', + }; + } + + // 1. Chercher le mois précédent M-1, ou en remontant vers Janvier + for (let prevM = month - 1; prevM >= 1; prevM--) { + const d = memberDues[prevM]; + if (d && Number(d.amount) > 0) { + return { + isExisting: false, + amount: Number(d.amount).toFixed(2), + method: d.payment_method || member.default_method || 'Virement', + note: '', + hint: 'Pré-rempli automatiquement d\'après ' + MONTH_NAMES[prevM] + ' (' + formatEuros(d.amount) + ' — ' + d.payment_method + '). Appuyez sur Entrée pour valider.', + }; + } + } + + // 2. À défaut, chercher n'importe quel autre mois payé dans l'année + for (let m = 12; m >= 1; m--) { + const d = memberDues[m]; + if (d && Number(d.amount) > 0) { + return { + isExisting: false, + amount: Number(d.amount).toFixed(2), + method: d.payment_method || member.default_method || 'Virement', + note: '', + hint: 'Pré-rempli d\'après ' + MONTH_NAMES[m] + ' (' + formatEuros(d.amount) + ' — ' + d.payment_method + '). Appuyez sur Entrée pour valider.', + }; + } + } + + // 3. À défaut, utiliser default_amount et default_method de la fiche du syndiqué + const fallbackAmount = Number(member.default_amount) > 0 ? Number(member.default_amount).toFixed(2) : ''; + const fallbackMethod = member.default_method || 'Virement'; + + return { + isExisting: false, + amount: fallbackAmount, + method: fallbackMethod, + note: '', + hint: fallbackAmount !== '' + ? 'Pré-rempli avec la cotisation habituelle du syndiqué (' + formatEuros(fallbackAmount) + ' — ' + fallbackMethod + '). Appuyez sur Entrée pour valider.' + : 'Première saisie pour ce syndiqué : indiquez le montant mensuel puis appuyez sur Entrée.', + }; + } + + /** + * Ouvre la fenêtre modale légère de saisie de cotisation au simple clic sur une cellule. + */ + function openDueModal(memberId, month) { + const member = state.members.find((m) => Number(m.id) === Number(memberId)); + if (!member) { + return; + } + + const prefill = computePrefillForCell(member, month); + const modal = document.getElementById('due-modal'); + const subtitle = document.getElementById('due-modal-subtitle'); + const hintEl = document.getElementById('due-modal-prefill-hint'); + const inputMemberId = document.getElementById('due-member-id'); + const inputYear = document.getElementById('due-year'); + const inputMonthStart = document.getElementById('due-month-start'); + const inputAmount = document.getElementById('due-input-amount'); + const inputMethod = document.getElementById('due-input-method'); + const selectMonthEnd = document.getElementById('due-input-month-end'); + const inputNote = document.getElementById('due-input-note'); + const btnDelete = document.getElementById('btn-delete-due'); + + subtitle.textContent = (member.civility ? member.civility + ' ' : '') + + member.last_name + ' ' + member.first_name + ' — ' + MONTH_NAMES[month] + ' ' + state.year; + hintEl.textContent = prefill.hint; + + inputMemberId.value = String(member.id); + inputYear.value = String(state.year); + inputMonthStart.value = String(month); + inputAmount.value = prefill.amount; + inputMethod.value = prefill.method; + inputNote.value = prefill.note; + + // Remplir le sélecteur d'extension multi-mois (de month à 12) + selectMonthEnd.textContent = ''; + for (let m = month; m <= 12; m++) { + const opt = document.createElement('option'); + opt.value = String(m); + opt.textContent = m === month + ? (MONTH_NAMES[m] + ' uniquement (1 mois)') + : ('Jusqu\'à ' + MONTH_NAMES[m] + ' inclus (' + (m - month + 1) + ' mois)'); + selectMonthEnd.appendChild(opt); + } + selectMonthEnd.value = String(month); + + if (prefill.isExisting) { + btnDelete.classList.remove('hidden'); + } else { + btnDelete.classList.add('hidden'); + } + + modal.classList.remove('hidden'); + + // Focus immédiat et sélection du montant pour validation directe avec Entrée + requestAnimationFrame(() => { + inputAmount.focus(); + inputAmount.select(); + }); + } + + function closeDueModal() { + const modal = document.getElementById('due-modal'); + if (modal) { + modal.classList.add('hidden'); + } + } + + /** + * Enregistre la cotisation (1 ou plusieurs mois) via l'API. + */ + async function handleDueFormSubmit(event) { + event.preventDefault(); + + const memberId = Number(document.getElementById('due-member-id').value); + const year = Number(document.getElementById('due-year').value); + const monthStart = Number(document.getElementById('due-month-start').value); + const monthEnd = Number(document.getElementById('due-input-month-end').value); + const amount = parseFloat(document.getElementById('due-input-amount').value); + const paymentMethod = document.getElementById('due-input-method').value; + const note = document.getElementById('due-input-note').value; + + if (isNaN(amount) || amount <= 0) { + showToast('Veuillez saisir un montant valide supérieur à 0,00 €.', 'error'); + return; + } + + try { + const res = await apiFetch('/api/dues', { + method: 'POST', + body: JSON.stringify({ + member_id: memberId, + year: year, + month_start: monthStart, + month_end: monthEnd, + amount: amount, + payment_method: paymentMethod, + note: note, + }), + }); + + if (!state.dues[memberId]) { + state.dues[memberId] = {}; + } + Object.entries(res.dues || {}).forEach(([m, dueObj]) => { + state.dues[memberId][Number(m)] = dueObj; + }); + + if (res.multiYearStats) { + state.multiYearStats = res.multiYearStats; + } + + // Mettre à jour les valeurs de référence locales du syndiqué + const member = state.members.find((m) => Number(m.id) === memberId); + if (member) { + member.default_amount = amount; + member.default_method = paymentMethod; + } + + closeDueModal(); + renderAll(); + + const countMonths = monthEnd - monthStart + 1; + showToast( + countMonths > 1 + ? ('Cotisations enregistrées (' + countMonths + ' mois : ' + MONTH_SHORT[monthStart] + ' → ' + MONTH_SHORT[monthEnd] + ').') + : ('Cotisation de ' + MONTH_NAMES[monthStart] + ' enregistrée (' + formatEuros(amount) + ').') + ); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Efface la cotisation du mois sélectionné. + */ + async function handleDeleteDue() { + const memberId = Number(document.getElementById('due-member-id').value); + const year = Number(document.getElementById('due-year').value); + const month = Number(document.getElementById('due-month-start').value); + + try { + const res = await apiFetch('/api/dues/delete', { + method: 'POST', + body: JSON.stringify({ + member_id: memberId, + year: year, + month: month, + }), + }); + + if (state.dues[memberId]) { + delete state.dues[memberId][month]; + } + if (res && res.multiYearStats) { + state.multiYearStats = res.multiYearStats; + } + + closeDueModal(); + renderAll(); + showToast('Cotisation de ' + MONTH_NAMES[month] + ' effacée.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Ouvre la modale d'ajout ou d'édition d'une fiche syndiqué. + */ + function openMemberModal(memberId = null) { + const modal = document.getElementById('member-modal'); + const title = document.getElementById('member-modal-title'); + const btnDelete = document.getElementById('btn-delete-member'); + + const member = memberId !== null + ? state.members.find((m) => Number(m.id) === Number(memberId)) + : null; + + document.getElementById('mbr-id').value = member ? String(member.id) : ''; + document.getElementById('mbr-civility').value = member ? (member.civility || '') : 'M.'; + document.getElementById('mbr-last-name').value = member ? member.last_name : ''; + document.getElementById('mbr-first-name').value = member ? member.first_name : ''; + document.getElementById('mbr-cogitiel-id').value = member ? member.cogitiel_id : ''; + document.getElementById('mbr-default-amount').value = member ? Number(member.default_amount).toFixed(2) : '0.00'; + document.getElementById('mbr-default-method').value = member ? (member.default_method || 'Virement') : 'Virement'; + document.getElementById('mbr-email').value = member ? (member.email || '') : ''; + document.getElementById('mbr-phone-personal').value = member ? (member.phone_personal || '') : ''; + document.getElementById('mbr-phone-pro').value = member ? (member.phone_pro || '') : ''; + document.getElementById('mbr-address').value = member ? (member.address || '') : ''; + document.getElementById('mbr-postal-code').value = member ? (member.postal_code || '') : ''; + document.getElementById('mbr-city').value = member ? (member.city || '') : ''; + document.getElementById('mbr-base-code').value = member ? (member.base_code || '90045110520251341') : '90045110520251341'; + document.getElementById('mbr-base-name').value = member ? (member.base_name || 'INDIVIDUELS UL FOIX HAUTE-ARIEGE') : 'INDIVIDUELS UL FOIX HAUTE-ARIEGE'; + document.getElementById('mbr-is-archived').checked = member ? Boolean(Number(member.is_archived)) : false; + + if (member) { + title.textContent = 'Modifier la fiche : ' + member.last_name + ' ' + member.first_name; + btnDelete.classList.remove('hidden'); + } else { + title.textContent = 'Nouveau syndiqué (UL CGT Foix Haute-Ariège)'; + btnDelete.classList.add('hidden'); + } + + modal.classList.remove('hidden'); + requestAnimationFrame(() => { + document.getElementById('mbr-last-name').focus(); + }); + } + + function closeMemberModal() { + const modal = document.getElementById('member-modal'); + if (modal) { + modal.classList.add('hidden'); + } + } + + async function handleMemberFormSubmit(event) { + event.preventDefault(); + + const idVal = document.getElementById('mbr-id').value; + const payload = { + cogitiel_id: document.getElementById('mbr-cogitiel-id').value.trim(), + civility: document.getElementById('mbr-civility').value, + last_name: document.getElementById('mbr-last-name').value.trim().toUpperCase(), + first_name: document.getElementById('mbr-first-name').value.trim(), + default_amount: parseFloat(document.getElementById('mbr-default-amount').value) || 0, + default_method: document.getElementById('mbr-default-method').value, + email: document.getElementById('mbr-email').value.trim(), + phone_personal: document.getElementById('mbr-phone-personal').value.trim(), + phone_pro: document.getElementById('mbr-phone-pro').value.trim(), + address: document.getElementById('mbr-address').value.trim(), + postal_code: document.getElementById('mbr-postal-code').value.trim(), + city: document.getElementById('mbr-city').value.trim().toUpperCase(), + base_code: document.getElementById('mbr-base-code').value.trim(), + base_name: document.getElementById('mbr-base-name').value.trim(), + is_archived: document.getElementById('mbr-is-archived').checked ? 1 : 0, + }; + + try { + if (idVal !== '') { + const res = await apiFetch('/api/members/' + encodeURIComponent(idVal), { + method: 'PUT', + body: JSON.stringify(payload), + }); + const idx = state.members.findIndex((m) => Number(m.id) === Number(idVal)); + if (idx !== -1 && res.member) { + state.members[idx] = res.member; + } + showToast('Fiche syndiqué mise à jour.'); + } else { + const res = await apiFetch('/api/members', { + method: 'POST', + body: JSON.stringify(payload), + }); + if (res.member) { + state.members.push(res.member); + } + showToast('Nouveau syndiqué ajouté avec succès.'); + } + + state.members.sort((a, b) => { + const cmp = a.last_name.localeCompare(b.last_name, 'fr'); + return cmp !== 0 ? cmp : a.first_name.localeCompare(b.first_name, 'fr'); + }); + + closeMemberModal(); + renderAll(); + } catch (err) { + showToast(err.message, 'error'); + } + } + + async function handleDeleteMember() { + const idVal = document.getElementById('mbr-id').value; + if (!idVal) { + return; + } + const member = state.members.find((m) => Number(m.id) === Number(idVal)); + const label = member ? (member.last_name + ' ' + member.first_name) : ('#' + idVal); + if (!window.confirm('Confirmer la suppression définitive de ' + label + ' et de son historique de cotisations ?')) { + return; + } + + try { + const res = await apiFetch('/api/members/' + encodeURIComponent(idVal), { + method: 'DELETE', + }); + state.members = state.members.filter((m) => Number(m.id) !== Number(idVal)); + delete state.dues[Number(idVal)]; + if (res && res.multiYearStats) { + state.multiYearStats = res.multiYearStats; + } + closeMemberModal(); + renderAll(); + showToast('Syndiqué supprimé.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Gère l'importation d'un fichier d'export CoGétise (.asp / CSV Windows-1252). + */ + async function handleImportFileChange(event) { + const fileInput = event.target; + if (!fileInput.files || fileInput.files.length === 0) { + return; + } + + const file = fileInput.files[0]; + const formData = new FormData(); + formData.append('file', file); + + try { + showToast('Importation du fichier ' + file.name + ' en cours…', 'info'); + const summary = await apiFetch('/api/import', { + method: 'POST', + body: formData, + }); + + await loadState(state.year); + showToast( + 'Import CoGétise réussi : ' + summary.inserted + ' créé(s), ' + + summary.updated + ' mis à jour (Total traité : ' + summary.total + ').', + 'success' + ); + } catch (err) { + showToast('Erreur d\'importation : ' + err.message, 'error'); + } finally { + fileInput.value = ''; + } + } + + /* ====================================================================== + * OUTIL DE PUBLIPOSTAGE & MODÈLES PERSONNALISABLES + * ====================================================================== */ + + /** + * Charge le modèle choisi dans l'éditeur et applique la règle de sélection automatique des destinataires. + */ + function syncTemplateEditorFromSlug(slug, autoSelectRecipients = true) { + state.activeTemplateSlug = slug; + const selectEl = document.getElementById('select-template'); + if (selectEl && selectEl.value !== slug) { + selectEl.value = slug; + } + + const tpl = state.templates.find((t) => t.slug === slug); + if (tpl) { + document.getElementById('tpl-title').value = tpl.title || ''; + document.getElementById('tpl-subject').value = tpl.subject || ''; + document.getElementById('tpl-body').value = tpl.body || ''; + } + + const ruleHints = { + tax_receipt: 'Sélection automatique : tous les syndiqués actifs ayant versé plus de 0,00 € sur l\'année.', + missing_months: 'Sélection automatique : tous les syndiqués actifs en Alerte Orange (cotisations pas à jour).', + annual_zero_warning: 'Sélection automatique : tous les syndiqués actifs en Alerte Rouge (0,00 € versé sur l\'année).', + }; + setText('mailing-auto-rule-hint', ruleHints[slug] || ''); + + if (autoSelectRecipients) { + applyAutoRecipientSelection(slug); + } + renderMailingRecipients(); + renderMailingPreview(); + } + + /** + * Coche automatiquement les destinataires correspondant au modèle actif. + */ + function applyAutoRecipientSelection(slug) { + state.selectedRecipientIds.clear(); + + state.members.forEach((member) => { + if (Number(member.is_archived) === 1) { + return; + } + if (state.mailingNoEmailOnly && member.email && member.email.trim() !== '') { + return; + } + + const metrics = computeMemberMetrics(member); + if (slug === 'tax_receipt' && metrics.totalPaid > 0) { + state.selectedRecipientIds.add(Number(member.id)); + } else if (slug === 'missing_months' && metrics.alertLevel === 'orange') { + state.selectedRecipientIds.add(Number(member.id)); + } else if (slug === 'annual_zero_warning' && metrics.alertLevel === 'red') { + state.selectedRecipientIds.add(Number(member.id)); + } + }); + + const firstSelected = Array.from(state.selectedRecipientIds)[0] || null; + state.previewMemberId = firstSelected; + } + + function getEligibleMailingMembers() { + return state.members.filter((member) => { + if (Number(member.is_archived) === 1) { + return false; + } + if (state.mailingNoEmailOnly && member.email && member.email.trim() !== '') { + return false; + } + return true; + }); + } + + /** + * Affiche la liste des destinataires dans l'onglet Publipostage. + */ + function renderMailingRecipients() { + const listEl = document.getElementById('mailing-recipients-list'); + const previewSelect = document.getElementById('select-preview-member'); + if (!listEl || !previewSelect) { + return; + } + + listEl.textContent = ''; + previewSelect.textContent = ''; + + const eligibleMembers = getEligibleMailingMembers(); + let emailCapableSelectedCount = 0; + + eligibleMembers.forEach((member) => { + const id = Number(member.id); + const isChecked = state.selectedRecipientIds.has(id); + const hasEmail = Boolean(member.email && member.email.includes('@')); + if (isChecked && hasEmail) { + emailCapableSelectedCount++; + } + + const metrics = computeMemberMetrics(member); + + const item = document.createElement('label'); + item.className = 'ul-recipient-item' + (isChecked ? ' selected' : ''); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.action = 'toggle-recipient'; + checkbox.dataset.memberId = String(id); + checkbox.checked = isChecked; + + const infoDiv = document.createElement('div'); + infoDiv.className = 'ul-recipient-info'; + + const nameLine = document.createElement('div'); + nameLine.className = 'ul-recipient-name'; + nameLine.textContent = member.last_name + ' ' + member.first_name; + + const metaLine = document.createElement('div'); + metaLine.className = 'ul-recipient-meta'; + metaLine.textContent = (hasEmail ? ('📧 ' + member.email) : '📮 Courrier papier (sans e-mail)') + + ' · Payé : ' + formatEuros(metrics.totalPaid); + + infoDiv.appendChild(nameLine); + infoDiv.appendChild(metaLine); + + const badgeSpan = document.createElement('span'); + if (metrics.alertLevel === 'red') { + badgeSpan.className = 'ul-badge ul-badge-red'; + badgeSpan.textContent = '0,00 €'; + } else if (metrics.alertLevel === 'orange') { + badgeSpan.className = 'ul-badge ul-badge-orange'; + badgeSpan.textContent = '-' + metrics.missingMonths.length + ' m.'; + } else { + badgeSpan.className = 'ul-badge ul-badge-green'; + badgeSpan.textContent = formatEuros(metrics.totalPaid); + } + + item.appendChild(checkbox); + item.appendChild(infoDiv); + item.appendChild(badgeSpan); + listEl.appendChild(item); + + // Ajouter au sélecteur de prévisualisation + const opt = document.createElement('option'); + opt.value = String(id); + opt.textContent = (isChecked ? '✓ ' : '') + member.last_name + ' ' + member.first_name + ' (' + formatEuros(metrics.totalPaid) + ')'; + previewSelect.appendChild(opt); + }); + + if (state.previewMemberId && eligibleMembers.some((m) => Number(m.id) === Number(state.previewMemberId))) { + previewSelect.value = String(state.previewMemberId); + } else if (eligibleMembers.length > 0) { + state.previewMemberId = Number(eligibleMembers[0].id); + previewSelect.value = String(state.previewMemberId); + } else { + state.previewMemberId = null; + } + + const totalSelected = state.selectedRecipientIds.size; + setText('mailing-selected-count', String(totalSelected)); + setText('btn-print-count', String(totalSelected)); + setText('btn-email-count', String(emailCapableSelectedCount)); + } + + /** + * Remplace toutes les balises dynamiques {{...}} côté client pour l'aperçu et l'impression PDF. + */ + function replaceVariablesForMember(templateText, member) { + const metrics = computeMemberMetrics(member); + const now = new Date(); + const dateDuJour = String(now.getDate()).padStart(2, '0') + '/' + + String(now.getMonth() + 1).padStart(2, '0') + '/' + + now.getFullYear(); + + const addrParts = []; + if (member.address && member.address.trim() !== '') { + addrParts.push(member.address.trim()); + } + const cpVille = ((member.postal_code || '') + ' ' + (member.city || '')).trim(); + if (cpVille !== '') { + addrParts.push(cpVille); + } + const adresseComplete = addrParts.length > 0 ? addrParts.join(' — ') : 'Adresse non renseignée'; + + const map = { + '{{civilite}}': member.civility || '', + '{{nom}}': member.last_name || '', + '{{prenom}}': member.first_name || '', + '{{adresse_complete}}': adresseComplete, + '{{code_responsable}}': member.cogitiel_id || '', + '{{annee}}': String(state.year), + '{{date_du_jour}}': dateDuJour, + '{{total_paye}}': formatEuros(metrics.totalPaid), + '{{credit_impot}}': formatEuros(metrics.creditImpot), + '{{nb_mois_payes}}': String(metrics.paidMonthsCount), + '{{mois_manquants_liste}}': metrics.missingMonthsNames.length > 0 ? metrics.missingMonthsNames.join(', ') : 'Aucun', + '{{nb_mois_manquants}}': String(metrics.missingMonths.length), + '{{cotisation_habituelle}}': formatEuros(metrics.usualAmount), + '{{montant_estime_retard}}': formatEuros(metrics.estimatedArrears), + }; + + let output = templateText || ''; + Object.entries(map).forEach(([tag, val]) => { + output = output.split(tag).join(val); + }); + return output; + } + + /** + * Construit un élément DOM représentant une page de courrier A4 avec la vraie bannière officielle UL CGT Foix Haute-Ariège. + */ + function createLetterDomElement(member, subjectText, bodyText) { + const renderedSubject = replaceVariablesForMember(subjectText, member); + const renderedBody = replaceVariablesForMember(bodyText, member); + + const page = document.createElement('article'); + page.className = 'ul-print-page'; + + const header = document.createElement('header'); + header.className = 'ul-letter-header'; + + if (state.bannerUrl) { + const bannerImg = document.createElement('img'); + bannerImg.src = state.bannerUrl; + bannerImg.alt = 'Union Locale CGT Foix Haute-Ariège'; + bannerImg.className = 'ul-letter-banner-img'; + bannerImg.width = 360; + bannerImg.height = 72; + header.appendChild(bannerImg); + } else if (state.logoUrl) { + const logoImg = document.createElement('img'); + logoImg.src = state.logoUrl; + logoImg.alt = 'CGT'; + logoImg.className = 'ul-letter-logo-img'; + logoImg.width = 40; + logoImg.height = 52; + header.appendChild(logoImg); + } + + const orgSub = document.createElement('div'); + orgSub.className = 'ul-letter-org-sub'; + orgSub.textContent = 'Trésorerie — Collectif des Isolés / Multipros · Bourse du Travail, 09000 FOIX'; + header.appendChild(orgSub); + + page.appendChild(header); + + const subjectBar = document.createElement('div'); + subjectBar.className = 'ul-letter-subject'; + subjectBar.textContent = renderedSubject; + page.appendChild(subjectBar); + + const bodyDiv = document.createElement('div'); + bodyDiv.className = 'ul-letter-body'; + bodyDiv.textContent = renderedBody; + page.appendChild(bodyDiv); + + return page; + } + + /** + * Met à jour l'aperçu en direct du courrier dans l'onglet Publipostage. + */ + function renderMailingPreview() { + const previewContainer = document.getElementById('mailing-live-preview'); + if (!previewContainer) { + return; + } + previewContainer.textContent = ''; + + const member = state.members.find((m) => Number(m.id) === Number(state.previewMemberId)); + if (!member) { + previewContainer.textContent = 'Sélectionnez un syndiqué pour prévisualiser son courrier personnalisé.'; + return; + } + + const subjectText = document.getElementById('tpl-subject').value; + const bodyText = document.getElementById('tpl-body').value; + + const letterEl = createLetterDomElement(member, subjectText, bodyText); + previewContainer.appendChild(letterEl); + } + + /** + * Sauvegarde le modèle de courrier en base de données. + */ + async function handleSaveTemplate() { + const slug = state.activeTemplateSlug; + const title = document.getElementById('tpl-title').value.trim(); + const subject = document.getElementById('tpl-subject').value.trim(); + const body = document.getElementById('tpl-body').value; + + if (!title || !subject || !body) { + showToast('Le titre, l\'objet et le corps du modèle sont requis.', 'error'); + return; + } + + try { + const res = await apiFetch('/api/templates/' + encodeURIComponent(slug), { + method: 'PUT', + body: JSON.stringify({ title, subject, body }), + }); + const idx = state.templates.findIndex((t) => t.slug === slug); + if (idx !== -1 && res.template) { + state.templates[idx] = res.template; + } + showToast('Modèle « ' + title + ' » enregistré en base de données.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Réinitialise les 3 modèles par défaut en base de données. + */ + async function handleResetTemplates() { + if (!window.confirm('Restaurer le texte original des 3 modèles de courriers ?')) { + return; + } + try { + const res = await apiFetch('/api/templates/reset', { + method: 'POST', + }); + state.templates = res.templates || []; + syncTemplateEditorFromSlug(state.activeTemplateSlug, false); + showToast('Les 3 modèles ont été restaurés.'); + } catch (err) { + showToast(err.message, 'error'); + } + } + + /** + * Génère les pages A4 pour tous les destinataires cochés et lance l'impression / export PDF. + */ + function handlePrintMailing() { + const selectedMembers = state.members.filter((m) => state.selectedRecipientIds.has(Number(m.id))); + if (selectedMembers.length === 0) { + showToast('Veuillez cocher au moins un destinataire à imprimer.', 'error'); + return; + } + + const subjectText = document.getElementById('tpl-subject').value; + const bodyText = document.getElementById('tpl-body').value; + const printContainer = document.getElementById('ul-print-container'); + printContainer.textContent = ''; + + selectedMembers.forEach((member) => { + const pageEl = createLetterDomElement(member, subjectText, bodyText); + printContainer.appendChild(pageEl); + }); + + window.print(); + } + + /** + * Envoie les courriers personnalisés par e-mail via le service OCP\Mail\IMailer de Nextcloud. + */ + async function handleEmailMailing() { + const selectedIds = Array.from(state.selectedRecipientIds); + if (selectedIds.length === 0) { + showToast('Veuillez sélectionner au moins un destinataire.', 'error'); + return; + } + + const subject = document.getElementById('tpl-subject').value.trim(); + const body = document.getElementById('tpl-body').value; + + try { + showToast('Envoi des courriers électroniques en cours…', 'info'); + const res = await apiFetch('/api/mailing/send', { + method: 'POST', + body: JSON.stringify({ + year: state.year, + member_ids: selectedIds, + subject: subject, + body: body, + }), + }); + + let msg = 'Envoi terminé : ' + res.sent + ' e-mail(s) envoyé(s)'; + if (res.skipped_no_email > 0) { + msg += ', ' + res.skipped_no_email + ' ignoré(s) (sans adresse e-mail)'; + } + if (res.errors && res.errors.length > 0) { + msg += ', ' + res.errors.length + ' erreur(s) (' + res.errors[0] + ')'; + showToast(msg, 'error'); + } else { + showToast(msg + '.', 'success'); + } + } catch (err) { + showToast('Erreur d\'envoi : ' + err.message, 'error'); + } + } + + /** + * Insère une balise {{variable}} à la position du curseur dans l'éditeur de modèle. + */ + function insertVariableAtCursor(variableTag) { + const target = state.lastFocusedTemplateField || document.getElementById('tpl-body'); + if (!target) { + return; + } + + const start = target.selectionStart || 0; + const end = target.selectionEnd || 0; + const currentValue = target.value; + + target.value = currentValue.substring(0, start) + variableTag + currentValue.substring(end); + target.focus(); + const newPos = start + variableTag.length; + target.setSelectionRange(newPos, newPos); + renderMailingPreview(); + } + + /* ====================================================================== + * GESTION DES PERMISSIONS D'ACCÈS (#view-permissions — Administrateurs) + * ====================================================================== */ + + async function loadPermissions() { + if (!state.isAdmin) { + return; + } + try { + const data = await apiFetch('/api/permissions'); + state.permissionsLoaded = true; + state.permissions.accessMode = data.access_mode === 'all_users' ? 'all_users' : 'restricted'; + state.permissions.allowedGroups = new Set(Array.isArray(data.allowed_groups) ? data.allowed_groups : []); + state.permissions.allowedUsers = new Set(Array.isArray(data.allowed_users) ? data.allowed_users : []); + state.permissions.availableGroups = Array.isArray(data.available_groups) ? data.available_groups : []; + state.permissions.availableUsers = Array.isArray(data.available_users) ? data.available_users : []; + renderPermissions(); + } catch (err) { + showToast('Erreur chargement des permissions : ' + err.message, 'error'); + } + } + + function renderPermissions() { + if (!state.isAdmin) { + return; + } + + const radioRestricted = document.getElementById('perm-mode-restricted'); + const radioAll = document.getElementById('perm-mode-all'); + const gridEl = document.getElementById('perm-selectors-grid'); + + if (radioRestricted && radioAll) { + radioRestricted.checked = state.permissions.accessMode === 'restricted'; + radioAll.checked = state.permissions.accessMode === 'all_users'; + } + if (gridEl) { + gridEl.classList.toggle('ul-perm-disabled-look', state.permissions.accessMode === 'all_users'); + } + + // Rendu de la liste des groupes + const groupsList = document.getElementById('perm-groups-list'); + if (groupsList) { + groupsList.textContent = ''; + const qG = state.permissions.groupSearch.trim().toLowerCase(); + const filteredGroups = state.permissions.availableGroups.filter((g) => { + if (qG === '') { + return true; + } + return (g.id + ' ' + g.displayName).toLowerCase().includes(qG); + }); + + if (filteredGroups.length === 0) { + const empty = document.createElement('div'); + empty.className = 'ul-perm-empty'; + empty.textContent = 'Aucun groupe Nextcloud trouvé.'; + groupsList.appendChild(empty); + } else { + filteredGroups.forEach((group) => { + const isAdminGroup = group.id === 'admin'; + const isChecked = isAdminGroup || state.permissions.allowedGroups.has(group.id); + const lbl = document.createElement('label'); + lbl.className = 'ul-perm-item' + (isChecked ? ' selected' : ''); + + const chk = document.createElement('input'); + chk.type = 'checkbox'; + chk.dataset.permGroup = group.id; + chk.checked = isChecked; + if (isAdminGroup) { + chk.disabled = true; + } + + const info = document.createElement('div'); + info.className = 'ul-perm-item-info'; + + const title = document.createElement('div'); + title.className = 'ul-perm-item-title'; + title.textContent = group.displayName || group.id; + + const sub = document.createElement('div'); + sub.className = 'ul-perm-item-sub'; + sub.textContent = 'ID groupe : ' + group.id; + + info.appendChild(title); + info.appendChild(sub); + lbl.appendChild(chk); + lbl.appendChild(info); + + if (isAdminGroup) { + const badge = document.createElement('span'); + badge.className = 'ul-badge ul-badge-green'; + badge.textContent = 'Accès permanent'; + lbl.appendChild(badge); + } + + groupsList.appendChild(lbl); + }); + } + } + + // Rendu de la liste des utilisateurs + const usersList = document.getElementById('perm-users-list'); + if (usersList) { + usersList.textContent = ''; + const qU = state.permissions.userSearch.trim().toLowerCase(); + const filteredUsers = state.permissions.availableUsers.filter((u) => { + if (qU === '') { + return true; + } + return (u.uid + ' ' + u.displayName + ' ' + (u.email || '')).toLowerCase().includes(qU); + }); + + if (filteredUsers.length === 0) { + const empty = document.createElement('div'); + empty.className = 'ul-perm-empty'; + empty.textContent = 'Aucun utilisateur Nextcloud trouvé.'; + usersList.appendChild(empty); + } else { + filteredUsers.forEach((user) => { + const isChecked = Boolean(user.isAdmin) || state.permissions.allowedUsers.has(user.uid); + const lbl = document.createElement('label'); + lbl.className = 'ul-perm-item' + (isChecked ? ' selected' : ''); + + const chk = document.createElement('input'); + chk.type = 'checkbox'; + chk.dataset.permUser = user.uid; + chk.checked = isChecked; + if (user.isAdmin) { + chk.disabled = true; + } + + const info = document.createElement('div'); + info.className = 'ul-perm-item-info'; + + const title = document.createElement('div'); + title.className = 'ul-perm-item-title'; + title.textContent = user.displayName + ' (' + user.uid + ')'; + + const sub = document.createElement('div'); + sub.className = 'ul-perm-item-sub'; + sub.textContent = user.email ? ('📧 ' + user.email) : 'Aucun e-mail renseigné'; + + info.appendChild(title); + info.appendChild(sub); + lbl.appendChild(chk); + lbl.appendChild(info); + + if (user.isAdmin) { + const badge = document.createElement('span'); + badge.className = 'ul-badge ul-badge-green'; + badge.textContent = 'Admin Nextcloud'; + lbl.appendChild(badge); + } + + usersList.appendChild(lbl); + }); + } + } + + setText('perm-groups-count', String(state.permissions.allowedGroups.size)); + setText('perm-users-count', String(state.permissions.allowedUsers.size)); + } + + async function handleSavePermissions() { + try { + const res = await apiFetch('/api/permissions', { + method: 'PUT', + body: JSON.stringify({ + access_mode: state.permissions.accessMode, + allowed_groups: Array.from(state.permissions.allowedGroups), + allowed_users: Array.from(state.permissions.allowedUsers), + }), + }); + + if (res) { + state.permissions.accessMode = res.access_mode === 'all_users' ? 'all_users' : 'restricted'; + state.permissions.allowedGroups = new Set(Array.isArray(res.allowed_groups) ? res.allowed_groups : []); + state.permissions.allowedUsers = new Set(Array.isArray(res.allowed_users) ? res.allowed_users : []); + renderPermissions(); + } + + showToast('Permissions d\'accès enregistrées avec succès.'); + } catch (err) { + showToast('Erreur d\'enregistrement des permissions : ' + err.message, 'error'); + } + } + + /** + * Ouvre ou ferme le volet latéral gauche (#app-navigation). + */ + function toggleSidebar(forceCollapsed) { + const wrapper = document.getElementById('ul-tresorerie-app'); + if (!wrapper) { + return; + } + const isCollapsed = typeof forceCollapsed === 'boolean' + ? forceCollapsed + : !wrapper.classList.contains('ul-sidebar-collapsed'); + + wrapper.classList.toggle('ul-sidebar-collapsed', isCollapsed); + try { + window.localStorage.setItem('ul_tresorerie_sidebar_collapsed', isCollapsed ? '1' : '0'); + } catch (_err) { + // Ignorer si localStorage est indisponible + } + } + + /** + * Bascule entre les vues (Grille, Tableau de bord, Publipostage, Permissions). + */ + function switchView(viewName) { + const allowedViews = ['grid', 'dashboard', 'mailing', 'permissions']; + if (!allowedViews.includes(viewName)) { + viewName = 'grid'; + } + if (viewName === 'permissions' && !state.isAdmin) { + viewName = 'grid'; + } + + state.activeView = viewName; + + allowedViews.forEach((v) => { + const sec = document.getElementById('view-' + v); + const nav = document.getElementById('nav-view-' + v); + if (sec) { + sec.classList.toggle('hidden', v !== viewName); + } + if (nav) { + nav.classList.toggle('active', v === viewName); + } + }); + + if (viewName === 'mailing') { + syncTemplateEditorFromSlug(state.activeTemplateSlug, state.selectedRecipientIds.size === 0); + } else if (viewName === 'dashboard') { + renderDashboard(); + } else if (viewName === 'permissions') { + if (!state.permissionsLoaded) { + loadPermissions(); + } else { + renderPermissions(); + } + } else { + renderGrid(); + } + } + + function on(id, eventName, handler) { + const el = document.getElementById(id); + if (el) { + el.addEventListener(eventName, handler); + } + } + + /** + * Attache tous les écouteurs d'événements DOM (100 % conforme CSP Nextcloud 34). + */ + function bindEvents() { + // Boutons d'ouverture / fermeture du volet latéral gauche + document.querySelectorAll('[data-action="toggle-sidebar"]').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + toggleSidebar(); + }); + }); + + // Navigation entre vues (Grille, Tableau de bord, Publipostage, Permissions) + document.querySelectorAll('.ul-nav-item[data-view]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const targetView = link.getAttribute('data-view') || 'grid'; + switchView(targetView); + }); + }); + + // Support du changement de hash dans l'URL (#dashboard, #permissions, #mailing, #grid) + window.addEventListener('hashchange', () => { + const hash = (window.location.hash || '').replace(/^#/, ''); + if (['grid', 'dashboard', 'mailing', 'permissions'].includes(hash)) { + switchView(hash); + } + }); + + // Filtres d'alertes dans la barre latérale + document.querySelectorAll('.ul-filter-item[data-filter]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + document.querySelectorAll('.ul-filter-item').forEach((el) => el.classList.remove('active')); + link.classList.add('active'); + state.activeFilter = link.dataset.filter || 'all'; + if (state.activeView !== 'grid') { + switchView('grid'); + } else { + renderGrid(); + } + }); + }); + + on('active-filter-indicator', 'click', () => { + state.activeFilter = 'all'; + document.querySelectorAll('.ul-filter-item').forEach((el) => { + el.classList.toggle('active', el.dataset.filter === 'all'); + }); + renderGrid(); + }); + + // Sélecteur d'année (< 2026 >) dans la grille et dans le tableau de bord + const prevYearFn = () => loadState(state.year - 1); + const nextYearFn = () => loadState(state.year + 1); + on('btn-prev-year', 'click', prevYearFn); + on('btn-next-year', 'click', nextYearFn); + on('btn-dash-prev-year', 'click', prevYearFn); + on('btn-dash-next-year', 'click', nextYearFn); + + on('dash-select-compare-year', 'change', (e) => { + state.compareYear = Number(e.target.value) || (state.year - 1); + renderDashboard(); + }); + + on('dash-chart-multiyear', 'click', (e) => { + const grp = e.target.closest('[data-year]'); + if (grp && grp.dataset.year) { + const targetYear = Number(grp.dataset.year); + if (targetYear && targetYear !== state.year) { + loadState(targetYear); + } + } + }); + + // Barre de recherche instantanée + on('input-search', 'input', (e) => { + state.searchQuery = e.target.value || ''; + renderGrid(); + }); + + // Case à cocher « Afficher les syndiqués archivés » + on('chk-show-archived', 'change', (e) => { + state.showArchived = Boolean(e.target.checked); + renderGrid(); + }); + + // Boutons « + Nouveau syndiqué » et « Importer fichier .asp » + const openNewMemberFn = () => openMemberModal(null); + on('btn-new-member', 'click', openNewMemberFn); + on('btn-nav-new-member', 'click', openNewMemberFn); + + const triggerFileImportFn = () => { + const fileInput = document.getElementById('input-import-file'); + if (fileInput) { + fileInput.click(); + } + }; + on('btn-import-asp', 'click', triggerFileImportFn); + on('btn-nav-import-asp', 'click', triggerFileImportFn); + on('input-import-file', 'change', handleImportFileChange); + + // Délégation d'événements sur la grille des cotisations (clic cellule ou édition fiche) + on('dues-tbody', 'click', (e) => { + const editBtn = e.target.closest('[data-action="edit-member"]'); + if (editBtn) { + e.stopPropagation(); + openMemberModal(Number(editBtn.dataset.memberId)); + return; + } + + const dueCell = e.target.closest('[data-action="open-due"]'); + if (dueCell) { + openDueModal(Number(dueCell.dataset.memberId), Number(dueCell.dataset.month)); + } + }); + + // Modale de saisie de cotisation + on('due-form', 'submit', handleDueFormSubmit); + on('btn-delete-due', 'click', handleDeleteDue); + on('btn-cancel-due', 'click', closeDueModal); + on('btn-close-due-modal', 'click', closeDueModal); + + // Modale de fiche syndiqué + on('member-form', 'submit', handleMemberFormSubmit); + on('btn-delete-member', 'click', handleDeleteMember); + on('btn-cancel-member', 'click', closeMemberModal); + on('btn-close-member-modal', 'click', closeMemberModal); + + // Fermeture des modales au clic sur l'arrière-plan ou touche Escape + on('due-modal', 'click', (e) => { + if (e.target.id === 'due-modal') { + closeDueModal(); + } + }); + on('member-modal', 'click', (e) => { + if (e.target.id === 'member-modal') { + closeMemberModal(); + } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeDueModal(); + closeMemberModal(); + } + }); + + // Événements de l'outil de Publipostage + on('select-template', 'change', (e) => { + syncTemplateEditorFromSlug(e.target.value, true); + }); + + on('chk-mailing-no-email-only', 'change', (e) => { + state.mailingNoEmailOnly = Boolean(e.target.checked); + applyAutoRecipientSelection(state.activeTemplateSlug); + renderMailingRecipients(); + renderMailingPreview(); + }); + + on('btn-mailing-check-all', 'click', () => { + getEligibleMailingMembers().forEach((m) => state.selectedRecipientIds.add(Number(m.id))); + renderMailingRecipients(); + }); + + on('btn-mailing-uncheck-all', 'click', () => { + state.selectedRecipientIds.clear(); + renderMailingRecipients(); + }); + + on('mailing-recipients-list', 'change', (e) => { + const chk = e.target.closest('[data-action="toggle-recipient"]'); + if (!chk) { + return; + } + const id = Number(chk.dataset.memberId); + if (chk.checked) { + state.selectedRecipientIds.add(id); + state.previewMemberId = id; + } else { + state.selectedRecipientIds.delete(id); + } + renderMailingRecipients(); + renderMailingPreview(); + }); + + on('select-preview-member', 'change', (e) => { + state.previewMemberId = Number(e.target.value); + renderMailingPreview(); + }); + + const tplSubject = document.getElementById('tpl-subject'); + const tplBody = document.getElementById('tpl-body'); + if (tplSubject) { + tplSubject.addEventListener('focus', () => { + state.lastFocusedTemplateField = tplSubject; + }); + tplSubject.addEventListener('input', renderMailingPreview); + } + if (tplBody) { + tplBody.addEventListener('focus', () => { + state.lastFocusedTemplateField = tplBody; + }); + tplBody.addEventListener('input', renderMailingPreview); + } + + on('tpl-variables-bar', 'click', (e) => { + const pill = e.target.closest('.ul-var-pill'); + if (pill && pill.dataset.var) { + insertVariableAtCursor(pill.dataset.var); + } + }); + + on('btn-save-template', 'click', handleSaveTemplate); + on('btn-reset-templates', 'click', handleResetTemplates); + on('btn-print-mailing', 'click', handlePrintMailing); + on('btn-email-mailing', 'click', handleEmailMailing); + + // Événements de la vue Permissions (si administrateur) + on('btn-save-permissions', 'click', handleSavePermissions); + + document.querySelectorAll('input[name="perm-access-mode"]').forEach((radio) => { + radio.addEventListener('change', (e) => { + if (e.target.checked) { + state.permissions.accessMode = e.target.value === 'all_users' ? 'all_users' : 'restricted'; + renderPermissions(); + } + }); + }); + + on('input-perm-search-groups', 'input', (e) => { + state.permissions.groupSearch = e.target.value || ''; + renderPermissions(); + }); + + on('input-perm-search-users', 'input', (e) => { + state.permissions.userSearch = e.target.value || ''; + renderPermissions(); + }); + + on('perm-groups-list', 'change', (e) => { + const chk = e.target.closest('input[data-perm-group]'); + if (!chk) { + return; + } + const gid = chk.dataset.permGroup; + if (chk.checked) { + state.permissions.allowedGroups.add(gid); + } else { + state.permissions.allowedGroups.delete(gid); + } + renderPermissions(); + }); + + on('perm-users-list', 'change', (e) => { + const chk = e.target.closest('input[data-perm-user]'); + if (!chk) { + return; + } + const uid = chk.dataset.permUser; + if (chk.checked) { + state.permissions.allowedUsers.add(uid); + } else { + state.permissions.allowedUsers.delete(uid); + } + renderPermissions(); + }); + + on('btn-perm-groups-all', 'click', () => { + state.permissions.availableGroups.forEach((g) => { + if (g.id !== 'admin') { + state.permissions.allowedGroups.add(g.id); + } + }); + renderPermissions(); + }); + + on('btn-perm-groups-none', 'click', () => { + state.permissions.allowedGroups.clear(); + renderPermissions(); + }); + + on('btn-perm-users-all', 'click', () => { + state.permissions.availableUsers.forEach((u) => { + if (!u.isAdmin) { + state.permissions.allowedUsers.add(u.uid); + } + }); + renderPermissions(); + }); + + on('btn-perm-users-none', 'click', () => { + state.permissions.allowedUsers.clear(); + renderPermissions(); + }); + } + + document.addEventListener('DOMContentLoaded', () => { + const wrapper = document.getElementById('ul-tresorerie-app'); + if (!wrapper) { + return; + } + const initialYear = Number(wrapper.dataset.initialYear) || new Date().getFullYear(); + state.year = initialYear; + state.compareYear = initialYear - 1; + state.canAccess = wrapper.dataset.canAccess !== '0'; + state.isAdmin = wrapper.dataset.isAdmin === '1'; + state.logoUrl = wrapper.dataset.logoUrl || ''; + state.bannerUrl = wrapper.dataset.bannerUrl || ''; + + if (!state.canAccess) { + return; + } + + try { + if (window.localStorage.getItem('ul_tresorerie_sidebar_collapsed') === '1') { + wrapper.classList.add('ul-sidebar-collapsed'); + } + } catch (_err) { + // Ignorer + } + + bindEvents(); + loadState(initialYear); + + const initialHash = (window.location.hash || '').replace(/^#/, ''); + if (['dashboard', 'mailing', 'permissions'].includes(initialHash)) { + switchView(initialHash); + } + }); +})(); diff --git a/ul_tresorerie/lib/AppInfo/Application.php b/ul_tresorerie/lib/AppInfo/Application.php new file mode 100644 index 0000000..2e6d1af --- /dev/null +++ b/ul_tresorerie/lib/AppInfo/Application.php @@ -0,0 +1,26 @@ +permissionService->canAccessCurrentUser()) { + return new JSONResponse( + ['error' => 'Accès refusé : vous ne disposez pas des droits nécessaires pour accéder à la Trésorerie UL CGT.'], + Http::STATUS_FORBIDDEN, + ); + } + return null; + } + + /** + * @param int|null $year + */ + #[NoAdminRequired] + public function getState(?int $year = null): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + try { + $targetYear = ($year !== null && $year >= 2000 && $year <= 2100) + ? $year + : (int)date('Y'); + $state = $this->treasuryService->getFullState($targetYear); + $userContext = $this->permissionService->getCurrentUserContext(); + $state['userContext'] = $userContext; + if ($userContext['isAdmin']) { + $state['permissions'] = $this->permissionService->getPermissionsData(); + } + return new JSONResponse($state); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @param string $cogitiel_id + * @param string $civility + * @param string $last_name + * @param string $first_name + * @param string $phone_personal + * @param string $phone_pro + * @param string $address + * @param string $postal_code + * @param string $city + * @param string $email + * @param string $base_code + * @param string $base_name + * @param float $default_amount + * @param string $default_method + * @param int $is_archived + */ + #[NoAdminRequired] + public function createMember( + string $cogitiel_id = '', + string $civility = '', + string $last_name = '', + string $first_name = '', + string $phone_personal = '', + string $phone_pro = '', + string $address = '', + string $postal_code = '', + string $city = '', + string $email = '', + string $base_code = '90045110520251341', + string $base_name = 'INDIVIDUELS UL FOIX HAUTE-ARIEGE', + float $default_amount = 0.0, + string $default_method = 'Virement', + int $is_archived = 0, + ): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + if (trim($last_name) === '' || trim($first_name) === '') { + return new JSONResponse( + ['error' => 'Le nom et le prénom du syndiqué sont obligatoires.'], + Http::STATUS_BAD_REQUEST, + ); + } + + try { + $member = $this->treasuryService->createMember([ + 'cogitiel_id' => $cogitiel_id, + 'civility' => $civility, + 'last_name' => $last_name, + 'first_name' => $first_name, + 'phone_personal' => $phone_personal, + 'phone_pro' => $phone_pro, + 'address' => $address, + 'postal_code' => $postal_code, + 'city' => $city, + 'email' => $email, + 'base_code' => $base_code, + 'base_name' => $base_name, + 'default_amount' => $default_amount, + 'default_method' => $default_method, + 'is_archived' => $is_archived, + ]); + + return new JSONResponse(['member' => $member], Http::STATUS_CREATED); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param int $id + * @param string $cogitiel_id + * @param string $civility + * @param string $last_name + * @param string $first_name + * @param string $phone_personal + * @param string $phone_pro + * @param string $address + * @param string $postal_code + * @param string $city + * @param string $email + * @param string $base_code + * @param string $base_name + * @param float $default_amount + * @param string $default_method + * @param int $is_archived + */ + #[NoAdminRequired] + public function updateMember( + int $id, + string $cogitiel_id = '', + string $civility = '', + string $last_name = '', + string $first_name = '', + string $phone_personal = '', + string $phone_pro = '', + string $address = '', + string $postal_code = '', + string $city = '', + string $email = '', + string $base_code = '90045110520251341', + string $base_name = 'INDIVIDUELS UL FOIX HAUTE-ARIEGE', + float $default_amount = 0.0, + string $default_method = 'Virement', + int $is_archived = 0, + ): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + if (trim($last_name) === '' || trim($first_name) === '') { + return new JSONResponse( + ['error' => 'Le nom et le prénom du syndiqué sont obligatoires.'], + Http::STATUS_BAD_REQUEST, + ); + } + + try { + $member = $this->treasuryService->updateMember($id, [ + 'cogitiel_id' => $cogitiel_id, + 'civility' => $civility, + 'last_name' => $last_name, + 'first_name' => $first_name, + 'phone_personal' => $phone_personal, + 'phone_pro' => $phone_pro, + 'address' => $address, + 'postal_code' => $postal_code, + 'city' => $city, + 'email' => $email, + 'base_code' => $base_code, + 'base_name' => $base_name, + 'default_amount' => $default_amount, + 'default_method' => $default_method, + 'is_archived' => $is_archived, + ]); + + return new JSONResponse(['member' => $member]); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param int $id + */ + #[NoAdminRequired] + public function deleteMember(int $id): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + try { + $this->treasuryService->deleteMember($id); + return new JSONResponse([ + 'deleted' => true, + 'id' => $id, + 'multiYearStats' => $this->treasuryService->getMultiYearStats((int)date('Y')), + ]); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param string|null $content + */ + #[NoAdminRequired] + public function importAsp(?string $content = null): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + try { + $rawContent = ''; + $uploadedFile = $this->request->getUploadedFile('file'); + + if (is_array($uploadedFile) && isset($uploadedFile['tmp_name']) && is_uploaded_file((string)$uploadedFile['tmp_name'])) { + $rawContent = (string)file_get_contents((string)$uploadedFile['tmp_name']); + } elseif ($content !== null && trim($content) !== '') { + $rawContent = $content; + } else { + return new JSONResponse( + ['error' => 'Aucun fichier .asp ou contenu valide reçu pour l\'importation.'], + Http::STATUS_BAD_REQUEST, + ); + } + + $summary = $this->treasuryService->importAspContent($rawContent); + return new JSONResponse($summary); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param int $member_id + * @param int $year + * @param int $month_start + * @param int $month_end + * @param float $amount + * @param string $payment_method + * @param string|null $note + */ + #[NoAdminRequired] + public function saveDue( + int $member_id, + int $year, + int $month_start, + int $month_end, + float $amount, + string $payment_method = 'Virement', + ?string $note = null, + ): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + if ($amount <= 0.0) { + return new JSONResponse( + ['error' => 'Veuillez saisir un montant supérieur à 0,00 € (ou utilisez « Effacer ce mois »).'], + Http::STATUS_BAD_REQUEST, + ); + } + + try { + $savedDues = $this->treasuryService->saveDues( + $member_id, + $year, + $month_start, + $month_end, + $amount, + $payment_method, + $note, + ); + + return new JSONResponse([ + 'member_id' => $member_id, + 'year' => $year, + 'dues' => $savedDues, + 'multiYearStats' => $this->treasuryService->getMultiYearStats($year), + ]); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param int $member_id + * @param int $year + * @param int $month + */ + #[NoAdminRequired] + public function deleteDue(int $member_id, int $year, int $month): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + try { + $this->treasuryService->deleteDue($member_id, $year, $month); + return new JSONResponse([ + 'deleted' => true, + 'member_id' => $member_id, + 'year' => $year, + 'month' => $month, + 'multiYearStats' => $this->treasuryService->getMultiYearStats($year), + ]); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param string $slug + * @param string $title + * @param string $subject + * @param string $body + */ + #[NoAdminRequired] + public function saveTemplate( + string $slug, + string $title, + string $subject, + string $body, + ): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + try { + $template = $this->treasuryService->saveTemplate($slug, $title, $subject, $body); + return new JSONResponse(['template' => $template]); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + #[NoAdminRequired] + public function resetTemplates(): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + try { + $templates = $this->treasuryService->resetTemplates(); + return new JSONResponse(['templates' => $templates]); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param int $year + * @param array $member_ids + * @param string $subject + * @param string $body + */ + #[NoAdminRequired] + public function sendEmails( + int $year, + array $member_ids, + string $subject, + string $body, + ): JSONResponse { + if (($denied = $this->ensureAuthorized()) !== null) { + return $denied; + } + + if ($member_ids === []) { + return new JSONResponse( + ['error' => 'Veuillez sélectionner au moins un destinataire.'], + Http::STATUS_BAD_REQUEST, + ); + } + + try { + $result = $this->treasuryService->sendMailingEmails($year, $member_ids, $subject, $body); + return new JSONResponse($result); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + #[NoAdminRequired] + public function getPermissions(): JSONResponse { + if (!$this->permissionService->isCurrentUserAdmin()) { + return new JSONResponse( + ['error' => 'Seuls les administrateurs Nextcloud peuvent gérer les permissions de l\'application.'], + Http::STATUS_FORBIDDEN, + ); + } + + try { + return new JSONResponse($this->permissionService->getPermissionsData()); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @param string $access_mode + * @param array $allowed_groups + * @param array $allowed_users + */ + #[NoAdminRequired] + public function savePermissions( + string $access_mode = 'restricted', + array $allowed_groups = [], + array $allowed_users = [], + ): JSONResponse { + if (!$this->permissionService->isCurrentUserAdmin()) { + return new JSONResponse( + ['error' => 'Seuls les administrateurs Nextcloud peuvent modifier les permissions de l\'application.'], + Http::STATUS_FORBIDDEN, + ); + } + + try { + $updated = $this->permissionService->savePermissionsData( + $access_mode, + $allowed_groups, + $allowed_users, + ); + return new JSONResponse($updated); + } catch (Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } +} diff --git a/ul_tresorerie/lib/Controller/PageController.php b/ul_tresorerie/lib/Controller/PageController.php new file mode 100644 index 0000000..d31969a --- /dev/null +++ b/ul_tresorerie/lib/Controller/PageController.php @@ -0,0 +1,41 @@ +permissionService->getCurrentUserContext(); + if ($userContext['canAccess']) { + $this->treasuryService->ensureDefaultTemplates(); + } + + return new TemplateResponse(Application::APP_ID, 'main', [ + 'currentYear' => (int)date('Y'), + 'canAccess' => $userContext['canAccess'], + 'isAdmin' => $userContext['isAdmin'], + 'userUid' => $userContext['uid'], + 'userDisplayName' => $userContext['displayName'], + ]); + } +} diff --git a/ul_tresorerie/lib/Migration/Version1000Date20261031.php b/ul_tresorerie/lib/Migration/Version1000Date20261031.php new file mode 100644 index 0000000..647f07e --- /dev/null +++ b/ul_tresorerie/lib/Migration/Version1000Date20261031.php @@ -0,0 +1,209 @@ +hasTable('ul_cgt_members')) { + $table = $schema->createTable('ul_cgt_members'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('cogitiel_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('civility', Types::STRING, [ + 'notnull' => false, + 'length' => 16, + 'default' => '', + ]); + $table->addColumn('last_name', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + $table->addColumn('first_name', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + $table->addColumn('phone_personal', Types::STRING, [ + 'notnull' => false, + 'length' => 32, + 'default' => '', + ]); + $table->addColumn('phone_pro', Types::STRING, [ + 'notnull' => false, + 'length' => 32, + 'default' => '', + ]); + $table->addColumn('address', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('postal_code', Types::STRING, [ + 'notnull' => false, + 'length' => 16, + 'default' => '', + ]); + $table->addColumn('city', Types::STRING, [ + 'notnull' => false, + 'length' => 128, + 'default' => '', + ]); + $table->addColumn('email', Types::STRING, [ + 'notnull' => false, + 'length' => 190, + 'default' => '', + ]); + $table->addColumn('base_code', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => '', + ]); + $table->addColumn('base_name', Types::STRING, [ + 'notnull' => false, + 'length' => 190, + 'default' => '', + ]); + $table->addColumn('default_amount', Types::DECIMAL, [ + 'notnull' => true, + 'precision' => 10, + 'scale' => 2, + 'default' => '0.00', + ]); + $table->addColumn('default_method', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => 'Virement', + ]); + $table->addColumn('is_archived', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['cogitiel_id'], 'ul_cgt_mbr_cogitiel_uq'); + $table->addIndex(['last_name', 'first_name'], 'ul_cgt_mbr_name_idx'); + $table->addIndex(['is_archived'], 'ul_cgt_mbr_arch_idx'); + } + + // 3.2. Table ul_cgt_dues (Cotisations mensuelles) + if (!$schema->hasTable('ul_cgt_dues')) { + $table = $schema->createTable('ul_cgt_dues'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('member_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('year', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('month', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('amount', Types::DECIMAL, [ + 'notnull' => true, + 'precision' => 10, + 'scale' => 2, + ]); + $table->addColumn('payment_method', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => 'Virement', + ]); + $table->addColumn('note', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['member_id', 'year', 'month'], 'ul_cgt_dues_mbr_ym_uq'); + $table->addIndex(['year'], 'ul_cgt_dues_year_idx'); + } + + // 3.3. Table ul_cgt_templates (Modèles de publipostage) + if (!$schema->hasTable('ul_cgt_templates')) { + $table = $schema->createTable('ul_cgt_templates'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('slug', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('title', Types::STRING, [ + 'notnull' => true, + 'length' => 190, + ]); + $table->addColumn('subject', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('body', Types::TEXT, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['slug'], 'ul_cgt_tpl_slug_uq'); + } + + return $schema; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + foreach (DefaultTemplates::getAll() as $template) { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from('ul_cgt_templates') + ->where($qb->expr()->eq('slug', $qb->createNamedParameter($template['slug'], IQueryBuilder::PARAM_STR))); + $result = $qb->executeQuery(); + $existing = $result->fetchAssociative(); + $result->closeCursor(); + + if ($existing === false) { + $insertQb = $this->db->getQueryBuilder(); + $insertQb->insert('ul_cgt_templates') + ->values([ + 'slug' => $insertQb->createNamedParameter($template['slug'], IQueryBuilder::PARAM_STR), + 'title' => $insertQb->createNamedParameter($template['title'], IQueryBuilder::PARAM_STR), + 'subject' => $insertQb->createNamedParameter($template['subject'], IQueryBuilder::PARAM_STR), + 'body' => $insertQb->createNamedParameter($template['body'], IQueryBuilder::PARAM_STR), + ]) + ->executeStatement(); + } + } + } +} diff --git a/ul_tresorerie/lib/Service/AspImportParser.php b/ul_tresorerie/lib/Service/AspImportParser.php new file mode 100644 index 0000000..e00191f --- /dev/null +++ b/ul_tresorerie/lib/Service/AspImportParser.php @@ -0,0 +1,110 @@ + + */ + public function parse(string $rawContent): array { + // Suppression éventuelle du BOM UTF-8 + if (str_starts_with($rawContent, "\xEF\xBB\xBF")) { + $rawContent = substr($rawContent, 3); + } + + $rawLines = preg_split('/\r\n|\r|\n/', $rawContent) ?: []; + $parsedMembers = []; + $isHeaderSkipped = false; + + foreach ($rawLines as $rawLine) { + // 1. Supprimer les espaces/tabulations de fin et convertir Windows-1252 -> UTF-8 + $line = rtrim($rawLine, "\r\n\t"); + if (trim($line) === '') { + continue; + } + + if (!mb_check_encoding($line, 'UTF-8')) { + $line = mb_convert_encoding($line, 'UTF-8', 'Windows-1252'); + } + + // 2. Parser avec str_getcsv($line, ',', '"') et ignorer la première ligne d'en-tête + $columns = str_getcsv($line, ',', '"'); + if (!$isHeaderSkipped) { + $isHeaderSkipped = true; + $firstCol = trim((string)($columns[0] ?? '')); + if ($firstCol === 'Code Responsable' || !ctype_digit($firstCol)) { + continue; + } + } + + $cogitielId = trim((string)($columns[0] ?? '')); + if ($cogitielId === '' || $cogitielId === 'Code Responsable') { + continue; + } + + $civility = trim((string)($columns[1] ?? '')); + $lastName = mb_strtoupper(trim((string)($columns[2] ?? '')), 'UTF-8'); + $firstName = trim((string)($columns[3] ?? '')); + $phonePersonal = trim((string)($columns[4] ?? '')); + $phonePro = trim((string)($columns[5] ?? '')); + + // 3. Concaténer proprement les colonnes 6, 7 et 8 non vides dans le champ address + $addressParts = []; + foreach ([6, 7, 8] as $colIndex) { + $part = trim((string)($columns[$colIndex] ?? '')); + if ($part !== '') { + $addressParts[] = $part; + } + } + $address = implode(', ', $addressParts); + + $postalCode = trim((string)($columns[10] ?? '')); + $city = trim((string)($columns[11] ?? '')); + $email = trim((string)($columns[12] ?? '')); + $baseCode = trim((string)($columns[13] ?? '')); + $baseName = trim((string)($columns[14] ?? '')); + $isArchived = ((int)trim((string)($columns[15] ?? '0')) !== 0) ? 1 : 0; + + if ($lastName === '' && $firstName === '') { + continue; + } + + $parsedMembers[] = [ + 'cogitiel_id' => $cogitielId, + 'civility' => $civility, + 'last_name' => $lastName, + 'first_name' => $firstName, + 'phone_personal' => $phonePersonal, + 'phone_pro' => $phonePro, + 'address' => $address, + 'postal_code' => $postalCode, + 'city' => $city, + 'email' => $email, + 'base_code' => $baseCode, + 'base_name' => $baseName !== '' ? $baseName : 'INDIVIDUELS UL FOIX HAUTE-ARIEGE', + 'is_archived' => $isArchived, + ]; + } + + return $parsedMembers; + } +} diff --git a/ul_tresorerie/lib/Service/DefaultTemplates.php b/ul_tresorerie/lib/Service/DefaultTemplates.php new file mode 100644 index 0000000..689a5b5 --- /dev/null +++ b/ul_tresorerie/lib/Service/DefaultTemplates.php @@ -0,0 +1,111 @@ + + */ + public static function getAll(): array { + return [ + [ + 'slug' => 'tax_receipt', + 'title' => '1. Reçu fiscal / Crédit d\'impôt 66 %', + 'subject' => 'Attestation fiscale {{annee}} — Cotisations syndicales CGT ({{prenom}} {{nom}})', + 'body' => <<<'EOT' +Foix, le {{date_du_jour}} + +Destinataire : +{{civilite}} {{prenom}} {{nom}} +{{adresse_complete}} +Code syndiqué (CoGétise) : {{code_responsable}} + +Objet : Attestation annuelle de versement de cotisations syndicales — Année {{annee}} + +Chère camarade, cher camarade, + +L'Union Locale CGT de Foix et de la Haute-Ariège atteste par la présente que {{civilite}} {{prenom}} {{nom}} (identifiant {{code_responsable}}), rattaché(e) à la base des Isolés / Multipros de l'UL CGT Foix Haute-Ariège, a acquitté au titre de l'année civile {{annee}} ({{nb_mois_payes}} mois réglé(s)) un montant total de cotisations syndicales de : + + Montant total versé en {{annee}} : {{total_paye}} + +Conformément aux dispositions de l'article 199 quater C du Code général des impôts, les cotisations versées aux organisations syndicales représentatives ouvrent droit à un crédit d'impôt sur le revenu égal à 66 % des sommes versées : + + Montant de votre crédit d'impôt (66 %) : {{credit_impot}} + +Conservez précieusement cette attestation afin de pouvoir la produire, le cas échéant, sur demande de l'administration fiscale lors de votre déclaration de revenus (case 7AC / 7AE / 7AG). + +En te remerciant pour ta fidélité et ton engagement syndical à la CGT, nous t'adressons, chère camarade, cher camarade, nos salutations fraternelles et syndicalistes. + +Pour la Trésorerie de l'Union Locale CGT de Foix et de la Haute-Ariège +Bourse du Travail — Foix (09000) +EOT, + ], + [ + 'slug' => 'missing_months', + 'title' => '2. Rappel des mois manquants (Alerte Orange)', + 'subject' => 'Suivi de tes cotisations syndicales CGT {{annee}} — {{nb_mois_manquants}} mois en attente', + 'body' => <<<'EOT' +Foix, le {{date_du_jour}} + +Destinataire : +{{civilite}} {{prenom}} {{nom}} +{{adresse_complete}} +Code syndiqué (CoGétise) : {{code_responsable}} + +Objet : Point sur tes cotisations syndicales CGT — Année {{annee}} + +Chère camarade, cher camarade, + +Lors du pointage de la trésorerie des syndiqués isolés (Multipros) de l'Union Locale CGT de Foix et de la Haute-Ariège pour l'année {{annee}}, nous avons bien enregistré tes règlements pour {{nb_mois_payes}} mois (soit un total perçu à ce jour de {{total_paye}}), et nous t'en remercions. + +Sauf erreur ou omission de notre part (ou décalage de pointage bancaire récent), il apparaît toutefois que {{nb_mois_manquants}} échéance(s) mensuelle(s) ne figurent pas encore dans notre suivi pour l'année {{annee}} : + + Mois non pointés : {{mois_manquants_liste}} + Cotisation mensuelle habituelle : {{cotisation_habituelle}} + Montant estimé à régulariser : {{montant_estime_retard}} + +Les cotisations des syndiqués constituent la seule ressource financière garantissant l'indépendance de la CGT et les moyens d'action et de défense des salariés sur notre territoire de Foix et de la Haute-Ariège. + +Si ton règlement a été effectué tout récemment ou si ta situation professionnelle ou personnelle a évolué (changement d'emploi, retraite, perte d'emploi), n'hésite pas à nous contacter afin que nous mettions à jour ta fiche et le montant de ta cotisation. + +Restant à ton écoute, nous t'adressons, chère camarade, cher camarade, nos salutations fraternelles. + +La Trésorerie de l'Union Locale CGT de Foix et de la Haute-Ariège +EOT, + ], + [ + 'slug' => 'annual_zero_warning', + 'title' => '3. Rappel annuel d\'absence de versement (Alerte Rouge — 0 €)', + 'subject' => 'IMPORTANT : Régularisation de ton adhésion CGT pour l\'année {{annee}}', + 'body' => <<<'EOT' +Foix, le {{date_du_jour}} + +Destinataire : +{{civilite}} {{prenom}} {{nom}} +{{adresse_complete}} +Code syndiqué (CoGétise) : {{code_responsable}} + +Objet : Absence de versement de cotisation enregistrée sur l'année {{annee}} + +Chère camarade, cher camarade, + +Sauf erreur de notre part, lors de la vérification des comptes de l'Union Locale CGT de Foix et de la Haute-Ariège pour l'année {{annee}}, nous constatons qu'aucun versement de cotisation syndicale (0,00 €) n'a été enregistré sur ton dossier depuis le début de l'année ({{nb_mois_manquants}} mois échus : {{mois_manquants_liste}}). + +À titre indicatif, sur la base de ta cotisation mensuelle de référence de {{cotisation_habituelle}}, le montant cumulé représente {{montant_estime_retard}} (ouvrant droit à 66 % de crédit d'impôt). + +Nous tenons à attirer fraternellement ton attention sur le fait que le paiement régulier des cotisations conditionne le maintien de ton statut de syndiqué(e) CGT ainsi que l'accès à l'accompagnement juridique et syndical de l'Union Locale. À défaut de régularisation ou de nouvelles de ta part, nous serions malheureusement contraints de procéder à l'archivage de ton dossier d'adhérent(e). + +Si tu rencontres des difficultés financières passagères, un changement de situation (chômage, retraite, maladie, changement d'entreprise) ou un problème technique de virement/prélèvement, prends rapidement contact avec l'Union Locale : nous trouverons ensemble une solution adaptée. + +Dans l'attente de ton retour, nous te prions de croire, chère camarade, cher camarade, en nos salutations syndicalistes et fraternelles. + +La Trésorerie de l'Union Locale CGT de Foix et de la Haute-Ariège +EOT, + ], + ]; + } +} diff --git a/ul_tresorerie/lib/Service/PermissionService.php b/ul_tresorerie/lib/Service/PermissionService.php new file mode 100644 index 0000000..faa5bd9 --- /dev/null +++ b/ul_tresorerie/lib/Service/PermissionService.php @@ -0,0 +1,209 @@ +userSession->getUser(); + if ($user === null) { + return [ + 'uid' => '', + 'displayName' => 'Invité', + 'isAdmin' => false, + 'canAccess' => false, + ]; + } + + $uid = $user->getUID(); + $isAdmin = $this->groupManager->isAdmin($uid); + + return [ + 'uid' => $uid, + 'displayName' => $user->getDisplayName(), + 'isAdmin' => $isAdmin, + 'canAccess' => $this->canAccessCurrentUser(), + ]; + } + + /** + * Vérifie si l'utilisateur actuellement connecté est administrateur Nextcloud. + */ + public function isCurrentUserAdmin(): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + return $this->groupManager->isAdmin($user->getUID()); + } + + /** + * Vérifie si l'utilisateur actuellement connecté est autorisé à accéder à l'application. + */ + public function canAccessCurrentUser(): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + $uid = $user->getUID(); + + // Les administrateurs Nextcloud ont toujours accès + if ($this->groupManager->isAdmin($uid)) { + return true; + } + + // Si le mode est ouvert à tous les utilisateurs connectés + if ($this->getAccessMode() === 'all_users') { + return true; + } + + // Vérification dans la liste des utilisateurs explicitement autorisés + $allowedUsers = $this->getAllowedUsers(); + if (in_array($uid, $allowedUsers, true)) { + return true; + } + + // Vérification dans la liste des groupes autorisés + $allowedGroups = $this->getAllowedGroups(); + foreach ($allowedGroups as $gid) { + if ($this->groupManager->isInGroup($uid, $gid)) { + return true; + } + } + + return false; + } + + public function getAccessMode(): string { + $mode = $this->config->getAppValue(Application::APP_ID, self::KEY_ACCESS_MODE, 'restricted'); + return $mode === 'all_users' ? 'all_users' : 'restricted'; + } + + /** + * @return array + */ + public function getAllowedGroups(): array { + $raw = $this->config->getAppValue(Application::APP_ID, self::KEY_ALLOWED_GROUPS, '[]'); + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + return array_values(array_unique(array_filter(array_map('strval', $decoded), static fn (string $v): bool => trim($v) !== ''))); + } + + /** + * @return array + */ + public function getAllowedUsers(): array { + $raw = $this->config->getAppValue(Application::APP_ID, self::KEY_ALLOWED_USERS, '[]'); + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + return array_values(array_unique(array_filter(array_map('strval', $decoded), static fn (string $v): bool => trim($v) !== ''))); + } + + /** + * Récupère la configuration complète des permissions ainsi que la liste des groupes et utilisateurs Nextcloud. + * + * @return array + */ + public function getPermissionsData(): array { + $groups = []; + foreach ($this->groupManager->search('', 500, 0) as $group) { + $gid = $group->getGID(); + $groups[] = [ + 'gid' => $gid, + 'displayName' => method_exists($group, 'getDisplayName') ? $group->getDisplayName() : $gid, + ]; + } + usort($groups, static fn (array $a, array $b): int => strcasecmp($a['displayName'], $b['displayName'])); + + $users = []; + foreach ($this->userManager->search('', 500, 0) as $user) { + $uid = $user->getUID(); + $users[] = [ + 'uid' => $uid, + 'displayName' => $user->getDisplayName(), + 'email' => (string)($user->getEMailAddress() ?? ''), + 'isAdmin' => $this->groupManager->isAdmin($uid), + 'groups' => $this->groupManager->getUserGroupIds($user), + ]; + } + usort($users, static fn (array $a, array $b): int => strcasecmp($a['displayName'], $b['displayName'])); + + return [ + 'access_mode' => $this->getAccessMode(), + 'allowed_groups' => $this->getAllowedGroups(), + 'allowed_users' => $this->getAllowedUsers(), + 'available_groups' => $groups, + 'available_users' => $users, + ]; + } + + /** + * Enregistre la configuration des permissions d'accès à l'application. + * + * @param array $allowedGroups + * @param array $allowedUsers + * @return array + */ + public function savePermissionsData( + string $accessMode, + array $allowedGroups, + array $allowedUsers, + ): array { + $cleanMode = ($accessMode === 'all_users') ? 'all_users' : 'restricted'; + $cleanGroups = array_values(array_unique(array_filter( + array_map(static fn ($g): string => trim((string)$g), $allowedGroups), + static fn (string $v): bool => $v !== '', + ))); + $cleanUsers = array_values(array_unique(array_filter( + array_map(static fn ($u): string => trim((string)$u), $allowedUsers), + static fn (string $v): bool => $v !== '', + ))); + + $this->config->setAppValue( + Application::APP_ID, + self::KEY_ACCESS_MODE, + $cleanMode, + ); + $this->config->setAppValue( + Application::APP_ID, + self::KEY_ALLOWED_GROUPS, + (string)json_encode($cleanGroups, JSON_UNESCAPED_UNICODE), + ); + $this->config->setAppValue( + Application::APP_ID, + self::KEY_ALLOWED_USERS, + (string)json_encode($cleanUsers, JSON_UNESCAPED_UNICODE), + ); + + return $this->getPermissionsData(); + } +} diff --git a/ul_tresorerie/lib/Service/TreasuryService.php b/ul_tresorerie/lib/Service/TreasuryService.php new file mode 100644 index 0000000..c72a462 --- /dev/null +++ b/ul_tresorerie/lib/Service/TreasuryService.php @@ -0,0 +1,832 @@ + 'Janvier', + 2 => 'Février', + 3 => 'Mars', + 4 => 'Avril', + 5 => 'Mai', + 6 => 'Juin', + 7 => 'Juillet', + 8 => 'Août', + 9 => 'Septembre', + 10 => 'Octobre', + 11 => 'Novembre', + 12 => 'Décembre', + ]; + + private const array ALLOWED_METHODS = [ + 'Virement', + 'Prélèvement', + 'Chèque', + 'Espèces', + ]; + + public function __construct( + private readonly IDBConnection $db, + private readonly IMailer $mailer, + private readonly AspImportParser $importParser, + ) { + } + + /** + * S'assure que les 3 modèles de publipostage par défaut existent en base. + */ + public function ensureDefaultTemplates(): void { + foreach (DefaultTemplates::getAll() as $tpl) { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from('ul_cgt_templates') + ->where($qb->expr()->eq('slug', $qb->createNamedParameter($tpl['slug'], IQueryBuilder::PARAM_STR))); + $res = $qb->executeQuery(); + $row = $res->fetchAssociative(); + $res->closeCursor(); + + if ($row === false) { + $ins = $this->db->getQueryBuilder(); + $ins->insert('ul_cgt_templates') + ->values([ + 'slug' => $ins->createNamedParameter($tpl['slug'], IQueryBuilder::PARAM_STR), + 'title' => $ins->createNamedParameter($tpl['title'], IQueryBuilder::PARAM_STR), + 'subject' => $ins->createNamedParameter($tpl['subject'], IQueryBuilder::PARAM_STR), + 'body' => $ins->createNamedParameter($tpl['body'], IQueryBuilder::PARAM_STR), + ]) + ->executeStatement(); + } + } + } + + /** + * Récupère l'état complet pour une année donnée (syndiqués, cotisations, statistiques multi-années, modèles). + * + * @return array + */ + public function getFullState(int $year): array { + $this->ensureDefaultTemplates(); + + // 1. Syndiqués + $qbMembers = $this->db->getQueryBuilder(); + $qbMembers->select('*') + ->from('ul_cgt_members') + ->orderBy('last_name', 'ASC') + ->addOrderBy('first_name', 'ASC'); + $resMembers = $qbMembers->executeQuery(); + $rawMembers = $resMembers->fetchAllAssociative(); + $resMembers->closeCursor(); + + $members = array_map([$this, 'normalizeMemberRow'], $rawMembers); + + // 2. Cotisations de l'année sélectionnée + $qbDues = $this->db->getQueryBuilder(); + $qbDues->select('*') + ->from('ul_cgt_dues') + ->where($qbDues->expr()->eq('year', $qbDues->createNamedParameter($year, IQueryBuilder::PARAM_INT))) + ->orderBy('month', 'ASC'); + $resDues = $qbDues->executeQuery(); + $rawDues = $resDues->fetchAllAssociative(); + $resDues->closeCursor(); + + $duesByMember = []; + foreach ($rawDues as $dueRow) { + $memberId = (int)$dueRow['member_id']; + $month = (int)$dueRow['month']; + if (!isset($duesByMember[$memberId])) { + $duesByMember[$memberId] = []; + } + $duesByMember[$memberId][$month] = [ + 'id' => (int)$dueRow['id'], + 'member_id' => $memberId, + 'year' => (int)$dueRow['year'], + 'month' => $month, + 'amount' => round((float)$dueRow['amount'], 2), + 'payment_method' => (string)$dueRow['payment_method'], + 'note' => (string)($dueRow['note'] ?? ''), + ]; + } + + // 3. Modèles de publipostage + $templates = $this->getTemplates(); + + // 4. Statistiques multi-années pour le Tableau de Bord + $multiYearStats = $this->getMultiYearStats($year); + + return [ + 'year' => $year, + 'currentYear' => (int)date('Y'), + 'currentMonth' => (int)date('n'), + 'currentDay' => (int)date('j'), + 'members' => $members, + 'dues' => (object)$duesByMember, + 'templates' => $templates, + 'multiYearStats' => $multiYearStats, + ]; + } + + /** + * Calcule les agrégats mensuels, annuels et par mode de paiement sur toutes les années. + * + * @return array + */ + public function getMultiYearStats(int $focusYear): array { + $currentYear = (int)date('Y'); + $qbAll = $this->db->getQueryBuilder(); + $qbAll->select('member_id', 'year', 'month', 'amount', 'payment_method') + ->from('ul_cgt_dues'); + $resAll = $qbAll->executeQuery(); + $allDues = $resAll->fetchAllAssociative(); + $resAll->closeCursor(); + + $yearsSet = [ + $currentYear - 2 => true, + $currentYear - 1 => true, + $currentYear => true, + $focusYear - 1 => true, + $focusYear => true, + ]; + + foreach ($allDues as $row) { + $y = (int)$row['year']; + if ($y >= 2000 && $y <= 2100) { + $yearsSet[$y] = true; + } + } + + $availableYears = array_keys($yearsSet); + sort($availableYears); + + $byYear = []; + $payingMembersByYear = []; + + foreach ($availableYears as $y) { + $monthlyTotals = []; + $monthlyCounts = []; + for ($m = 1; $m <= 12; $m++) { + $monthlyTotals[$m] = 0.0; + $monthlyCounts[$m] = 0; + } + + $byMethod = []; + foreach (self::ALLOWED_METHODS as $methodName) { + $byMethod[$methodName] = [ + 'amount' => 0.0, + 'count' => 0, + ]; + } + + $byYear[$y] = [ + 'year' => $y, + 'totalAmount' => 0.0, + 'duesCount' => 0, + 'payingMembersCount' => 0, + 'monthlyTotals' => $monthlyTotals, + 'monthlyCounts' => $monthlyCounts, + 'byMethod' => $byMethod, + ]; + $payingMembersByYear[$y] = []; + } + + foreach ($allDues as $row) { + $y = (int)$row['year']; + $m = (int)$row['month']; + $amt = round((float)$row['amount'], 2); + $memberId = (int)$row['member_id']; + $method = $this->sanitizeMethod((string)($row['payment_method'] ?? 'Virement')); + + if (!isset($byYear[$y]) || $m < 1 || $m > 12 || $amt <= 0.0) { + continue; + } + + $byYear[$y]['totalAmount'] = round($byYear[$y]['totalAmount'] + $amt, 2); + $byYear[$y]['duesCount']++; + $byYear[$y]['monthlyTotals'][$m] = round($byYear[$y]['monthlyTotals'][$m] + $amt, 2); + $byYear[$y]['monthlyCounts'][$m]++; + $payingMembersByYear[$y][$memberId] = true; + + $byYear[$y]['byMethod'][$method]['amount'] = round($byYear[$y]['byMethod'][$method]['amount'] + $amt, 2); + $byYear[$y]['byMethod'][$method]['count']++; + } + + foreach ($availableYears as $y) { + $byYear[$y]['payingMembersCount'] = count($payingMembersByYear[$y]); + } + + return [ + 'availableYears' => $availableYears, + 'byYear' => (object)$byYear, + ]; + } + + /** + * @return array> + */ + public function getTemplates(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('ul_cgt_templates') + ->orderBy('id', 'ASC'); + $res = $qb->executeQuery(); + $rows = $res->fetchAllAssociative(); + $res->closeCursor(); + + return array_map(static fn (array $r): array => [ + 'id' => (int)$r['id'], + 'slug' => (string)$r['slug'], + 'title' => (string)$r['title'], + 'subject' => (string)$r['subject'], + 'body' => (string)$r['body'], + ], $rows); + } + + /** + * Crée un nouveau syndiqué. + * + * @param array $data + * @return array + */ + public function createMember(array $data): array { + $cogitielId = trim((string)($data['cogitiel_id'] ?? '')); + if ($cogitielId === '') { + $cogitielId = 'UL09-' . date('YmdHis') . '-' . random_int(100, 999); + } + + $civility = trim((string)($data['civility'] ?? '')); + $lastName = mb_strtoupper(trim((string)($data['last_name'] ?? '')), 'UTF-8'); + $firstName = trim((string)($data['first_name'] ?? '')); + $phonePersonal = trim((string)($data['phone_personal'] ?? '')); + $phonePro = trim((string)($data['phone_pro'] ?? '')); + $address = trim((string)($data['address'] ?? '')); + $postalCode = trim((string)($data['postal_code'] ?? '')); + $city = mb_strtoupper(trim((string)($data['city'] ?? '')), 'UTF-8'); + $email = trim((string)($data['email'] ?? '')); + $baseCode = trim((string)($data['base_code'] ?? '90045110520251341')); + $baseName = trim((string)($data['base_name'] ?? 'INDIVIDUELS UL FOIX HAUTE-ARIEGE')); + $defaultAmount = round(max(0.0, (float)($data['default_amount'] ?? 0.0)), 2); + $defaultMethod = $this->sanitizeMethod((string)($data['default_method'] ?? 'Virement')); + $isArchived = !empty($data['is_archived']) ? 1 : 0; + + $qb = $this->db->getQueryBuilder(); + $qb->insert('ul_cgt_members') + ->values([ + 'cogitiel_id' => $qb->createNamedParameter($cogitielId, IQueryBuilder::PARAM_STR), + 'civility' => $qb->createNamedParameter($civility, IQueryBuilder::PARAM_STR), + 'last_name' => $qb->createNamedParameter($lastName, IQueryBuilder::PARAM_STR), + 'first_name' => $qb->createNamedParameter($firstName, IQueryBuilder::PARAM_STR), + 'phone_personal' => $qb->createNamedParameter($phonePersonal, IQueryBuilder::PARAM_STR), + 'phone_pro' => $qb->createNamedParameter($phonePro, IQueryBuilder::PARAM_STR), + 'address' => $qb->createNamedParameter($address, IQueryBuilder::PARAM_STR), + 'postal_code' => $qb->createNamedParameter($postalCode, IQueryBuilder::PARAM_STR), + 'city' => $qb->createNamedParameter($city, IQueryBuilder::PARAM_STR), + 'email' => $qb->createNamedParameter($email, IQueryBuilder::PARAM_STR), + 'base_code' => $qb->createNamedParameter($baseCode, IQueryBuilder::PARAM_STR), + 'base_name' => $qb->createNamedParameter($baseName, IQueryBuilder::PARAM_STR), + 'default_amount' => $qb->createNamedParameter(number_format($defaultAmount, 2, '.', ''), IQueryBuilder::PARAM_STR), + 'default_method' => $qb->createNamedParameter($defaultMethod, IQueryBuilder::PARAM_STR), + 'is_archived' => $qb->createNamedParameter($isArchived, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + + $newId = (int)$this->db->lastInsertId('*PREFIX*ul_cgt_members'); + return $this->getMemberById($newId); + } + + /** + * Met à jour la fiche d'un syndiqué existant. + * + * @param array $data + * @return array + */ + public function updateMember(int $id, array $data): array { + $cogitielId = trim((string)($data['cogitiel_id'] ?? '')); + $civility = trim((string)($data['civility'] ?? '')); + $lastName = mb_strtoupper(trim((string)($data['last_name'] ?? '')), 'UTF-8'); + $firstName = trim((string)($data['first_name'] ?? '')); + $phonePersonal = trim((string)($data['phone_personal'] ?? '')); + $phonePro = trim((string)($data['phone_pro'] ?? '')); + $address = trim((string)($data['address'] ?? '')); + $postalCode = trim((string)($data['postal_code'] ?? '')); + $city = mb_strtoupper(trim((string)($data['city'] ?? '')), 'UTF-8'); + $email = trim((string)($data['email'] ?? '')); + $baseCode = trim((string)($data['base_code'] ?? '')); + $baseName = trim((string)($data['base_name'] ?? 'INDIVIDUELS UL FOIX HAUTE-ARIEGE')); + $defaultAmount = round(max(0.0, (float)($data['default_amount'] ?? 0.0)), 2); + $defaultMethod = $this->sanitizeMethod((string)($data['default_method'] ?? 'Virement')); + $isArchived = !empty($data['is_archived']) ? 1 : 0; + + $qb = $this->db->getQueryBuilder(); + $qb->update('ul_cgt_members') + ->set('civility', $qb->createNamedParameter($civility, IQueryBuilder::PARAM_STR)) + ->set('last_name', $qb->createNamedParameter($lastName, IQueryBuilder::PARAM_STR)) + ->set('first_name', $qb->createNamedParameter($firstName, IQueryBuilder::PARAM_STR)) + ->set('phone_personal', $qb->createNamedParameter($phonePersonal, IQueryBuilder::PARAM_STR)) + ->set('phone_pro', $qb->createNamedParameter($phonePro, IQueryBuilder::PARAM_STR)) + ->set('address', $qb->createNamedParameter($address, IQueryBuilder::PARAM_STR)) + ->set('postal_code', $qb->createNamedParameter($postalCode, IQueryBuilder::PARAM_STR)) + ->set('city', $qb->createNamedParameter($city, IQueryBuilder::PARAM_STR)) + ->set('email', $qb->createNamedParameter($email, IQueryBuilder::PARAM_STR)) + ->set('base_code', $qb->createNamedParameter($baseCode, IQueryBuilder::PARAM_STR)) + ->set('base_name', $qb->createNamedParameter($baseName, IQueryBuilder::PARAM_STR)) + ->set('default_amount', $qb->createNamedParameter(number_format($defaultAmount, 2, '.', ''), IQueryBuilder::PARAM_STR)) + ->set('default_method', $qb->createNamedParameter($defaultMethod, IQueryBuilder::PARAM_STR)) + ->set('is_archived', $qb->createNamedParameter($isArchived, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + if ($cogitielId !== '') { + $qb->set('cogitiel_id', $qb->createNamedParameter($cogitielId, IQueryBuilder::PARAM_STR)); + } + + $qb->executeStatement(); + + return $this->getMemberById($id); + } + + /** + * Supprime un syndiqué et toutes ses cotisations rattachées. + */ + public function deleteMember(int $id): void { + $this->db->beginTransaction(); + try { + $qbDues = $this->db->getQueryBuilder(); + $qbDues->delete('ul_cgt_dues') + ->where($qbDues->expr()->eq('member_id', $qbDues->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + + $qbMember = $this->db->getQueryBuilder(); + $qbMember->delete('ul_cgt_members') + ->where($qbMember->expr()->eq('id', $qbMember->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + /** + * Importe le contenu d'un fichier .asp CoGétise avec Upsert sur cogitiel_id. + * Ne remplace jamais default_amount, default_method ni les cotisations existantes. + * + * @return array{inserted: int, updated: int, total: int} + */ + public function importAspContent(string $rawContent): array { + $parsedRows = $this->importParser->parse($rawContent); + $inserted = 0; + $updated = 0; + + $this->db->beginTransaction(); + try { + foreach ($parsedRows as $row) { + $qbFind = $this->db->getQueryBuilder(); + $qbFind->select('id') + ->from('ul_cgt_members') + ->where($qbFind->expr()->eq('cogitiel_id', $qbFind->createNamedParameter($row['cogitiel_id'], IQueryBuilder::PARAM_STR))); + $res = $qbFind->executeQuery(); + $existing = $res->fetchAssociative(); + $res->closeCursor(); + + if ($existing !== false) { + // Mise à jour administrative sans écraser default_amount ni default_method + $qbUpd = $this->db->getQueryBuilder(); + $qbUpd->update('ul_cgt_members') + ->set('civility', $qbUpd->createNamedParameter($row['civility'], IQueryBuilder::PARAM_STR)) + ->set('last_name', $qbUpd->createNamedParameter($row['last_name'], IQueryBuilder::PARAM_STR)) + ->set('first_name', $qbUpd->createNamedParameter($row['first_name'], IQueryBuilder::PARAM_STR)) + ->set('phone_personal', $qbUpd->createNamedParameter($row['phone_personal'], IQueryBuilder::PARAM_STR)) + ->set('phone_pro', $qbUpd->createNamedParameter($row['phone_pro'], IQueryBuilder::PARAM_STR)) + ->set('address', $qbUpd->createNamedParameter($row['address'], IQueryBuilder::PARAM_STR)) + ->set('postal_code', $qbUpd->createNamedParameter($row['postal_code'], IQueryBuilder::PARAM_STR)) + ->set('city', $qbUpd->createNamedParameter($row['city'], IQueryBuilder::PARAM_STR)) + ->set('email', $qbUpd->createNamedParameter($row['email'], IQueryBuilder::PARAM_STR)) + ->set('base_code', $qbUpd->createNamedParameter($row['base_code'], IQueryBuilder::PARAM_STR)) + ->set('base_name', $qbUpd->createNamedParameter($row['base_name'], IQueryBuilder::PARAM_STR)) + ->set('is_archived', $qbUpd->createNamedParameter($row['is_archived'], IQueryBuilder::PARAM_INT)) + ->where($qbUpd->expr()->eq('id', $qbUpd->createNamedParameter((int)$existing['id'], IQueryBuilder::PARAM_INT))) + ->executeStatement(); + $updated++; + } else { + $qbIns = $this->db->getQueryBuilder(); + $qbIns->insert('ul_cgt_members') + ->values([ + 'cogitiel_id' => $qbIns->createNamedParameter($row['cogitiel_id'], IQueryBuilder::PARAM_STR), + 'civility' => $qbIns->createNamedParameter($row['civility'], IQueryBuilder::PARAM_STR), + 'last_name' => $qbIns->createNamedParameter($row['last_name'], IQueryBuilder::PARAM_STR), + 'first_name' => $qbIns->createNamedParameter($row['first_name'], IQueryBuilder::PARAM_STR), + 'phone_personal' => $qbIns->createNamedParameter($row['phone_personal'], IQueryBuilder::PARAM_STR), + 'phone_pro' => $qbIns->createNamedParameter($row['phone_pro'], IQueryBuilder::PARAM_STR), + 'address' => $qbIns->createNamedParameter($row['address'], IQueryBuilder::PARAM_STR), + 'postal_code' => $qbIns->createNamedParameter($row['postal_code'], IQueryBuilder::PARAM_STR), + 'city' => $qbIns->createNamedParameter($row['city'], IQueryBuilder::PARAM_STR), + 'email' => $qbIns->createNamedParameter($row['email'], IQueryBuilder::PARAM_STR), + 'base_code' => $qbIns->createNamedParameter($row['base_code'], IQueryBuilder::PARAM_STR), + 'base_name' => $qbIns->createNamedParameter($row['base_name'], IQueryBuilder::PARAM_STR), + 'default_amount' => $qbIns->createNamedParameter('0.00', IQueryBuilder::PARAM_STR), + 'default_method' => $qbIns->createNamedParameter('Virement', IQueryBuilder::PARAM_STR), + 'is_archived' => $qbIns->createNamedParameter($row['is_archived'], IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + $inserted++; + } + } + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + } + + return [ + 'inserted' => $inserted, + 'updated' => $updated, + 'total' => $inserted + $updated, + ]; + } + + /** + * Enregistre (Upsert) une cotisation sur un ou plusieurs mois consécutifs. + * + * @return array> + */ + public function saveDues( + int $memberId, + int $year, + int $monthStart, + int $monthEnd, + float $amount, + string $paymentMethod, + ?string $note = null, + ): array { + $monthStart = max(1, min(12, $monthStart)); + $monthEnd = max($monthStart, min(12, $monthEnd)); + $amount = round(max(0.0, $amount), 2); + $method = $this->sanitizeMethod($paymentMethod); + $cleanNote = $note !== null ? trim($note) : ''; + $savedDues = []; + + $this->db->beginTransaction(); + try { + for ($month = $monthStart; $month <= $monthEnd; $month++) { + $qbFind = $this->db->getQueryBuilder(); + $qbFind->select('id') + ->from('ul_cgt_dues') + ->where($qbFind->expr()->eq('member_id', $qbFind->createNamedParameter($memberId, IQueryBuilder::PARAM_INT))) + ->andWhere($qbFind->expr()->eq('year', $qbFind->createNamedParameter($year, IQueryBuilder::PARAM_INT))) + ->andWhere($qbFind->expr()->eq('month', $qbFind->createNamedParameter($month, IQueryBuilder::PARAM_INT))); + $res = $qbFind->executeQuery(); + $existing = $res->fetchAssociative(); + $res->closeCursor(); + + if ($existing !== false) { + $dueId = (int)$existing['id']; + $qbUpd = $this->db->getQueryBuilder(); + $qbUpd->update('ul_cgt_dues') + ->set('amount', $qbUpd->createNamedParameter(number_format($amount, 2, '.', ''), IQueryBuilder::PARAM_STR)) + ->set('payment_method', $qbUpd->createNamedParameter($method, IQueryBuilder::PARAM_STR)) + ->set('note', $qbUpd->createNamedParameter($cleanNote, IQueryBuilder::PARAM_STR)) + ->where($qbUpd->expr()->eq('id', $qbUpd->createNamedParameter($dueId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } else { + $qbIns = $this->db->getQueryBuilder(); + $qbIns->insert('ul_cgt_dues') + ->values([ + 'member_id' => $qbIns->createNamedParameter($memberId, IQueryBuilder::PARAM_INT), + 'year' => $qbIns->createNamedParameter($year, IQueryBuilder::PARAM_INT), + 'month' => $qbIns->createNamedParameter($month, IQueryBuilder::PARAM_INT), + 'amount' => $qbIns->createNamedParameter(number_format($amount, 2, '.', ''), IQueryBuilder::PARAM_STR), + 'payment_method' => $qbIns->createNamedParameter($method, IQueryBuilder::PARAM_STR), + 'note' => $qbIns->createNamedParameter($cleanNote, IQueryBuilder::PARAM_STR), + ]) + ->executeStatement(); + $dueId = (int)$this->db->lastInsertId('*PREFIX*ul_cgt_dues'); + } + + $savedDues[$month] = [ + 'id' => $dueId, + 'member_id' => $memberId, + 'year' => $year, + 'month' => $month, + 'amount' => $amount, + 'payment_method' => $method, + 'note' => $cleanNote, + ]; + } + + // Met à jour le montant et le mode de référence du syndiqué si un montant > 0 est saisi + if ($amount > 0) { + $qbMbr = $this->db->getQueryBuilder(); + $qbMbr->update('ul_cgt_members') + ->set('default_amount', $qbMbr->createNamedParameter(number_format($amount, 2, '.', ''), IQueryBuilder::PARAM_STR)) + ->set('default_method', $qbMbr->createNamedParameter($method, IQueryBuilder::PARAM_STR)) + ->where($qbMbr->expr()->eq('id', $qbMbr->createNamedParameter($memberId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + } + + return $savedDues; + } + + /** + * Supprime la cotisation d'un mois donné pour un syndiqué. + */ + public function deleteDue(int $memberId, int $year, int $month): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('ul_cgt_dues') + ->where($qb->expr()->eq('member_id', $qb->createNamedParameter($memberId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('month', $qb->createNamedParameter($month, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + /** + * Sauvegarde les modifications d'un modèle de publipostage. + * + * @return array + */ + public function saveTemplate(string $slug, string $title, string $subject, string $body): array { + $qb = $this->db->getQueryBuilder(); + $qb->update('ul_cgt_templates') + ->set('title', $qb->createNamedParameter(trim($title), IQueryBuilder::PARAM_STR)) + ->set('subject', $qb->createNamedParameter(trim($subject), IQueryBuilder::PARAM_STR)) + ->set('body', $qb->createNamedParameter($body, IQueryBuilder::PARAM_STR)) + ->where($qb->expr()->eq('slug', $qb->createNamedParameter($slug, IQueryBuilder::PARAM_STR))) + ->executeStatement(); + + $qbGet = $this->db->getQueryBuilder(); + $qbGet->select('*') + ->from('ul_cgt_templates') + ->where($qbGet->expr()->eq('slug', $qbGet->createNamedParameter($slug, IQueryBuilder::PARAM_STR))); + $res = $qbGet->executeQuery(); + $row = $res->fetchAssociative(); + $res->closeCursor(); + + if ($row === false) { + return []; + } + + return [ + 'id' => (int)$row['id'], + 'slug' => (string)$row['slug'], + 'title' => (string)$row['title'], + 'subject' => (string)$row['subject'], + 'body' => (string)$row['body'], + ]; + } + + /** + * Réinitialise les 3 modèles de publipostage avec leur contenu d'origine. + * + * @return array> + */ + public function resetTemplates(): array { + foreach (DefaultTemplates::getAll() as $tpl) { + $this->saveTemplate($tpl['slug'], $tpl['title'], $tpl['subject'], $tpl['body']); + } + return $this->getTemplates(); + } + + /** + * Envoie par e-mail via OCP\Mail\IMailer les courriers personnalisés aux syndiqués sélectionnés. + * + * @param array $memberIds + * @return array{sent: int, skipped_no_email: int, errors: array} + */ + public function sendMailingEmails( + int $year, + array $memberIds, + string $subjectTemplate, + string $bodyTemplate, + ): array { + $state = $this->getFullState($year); + $membersById = []; + foreach ($state['members'] as $m) { + $membersById[(int)$m['id']] = $m; + } + $duesMap = (array)$state['dues']; + + $sent = 0; + $skippedNoEmail = 0; + $errors = []; + + foreach ($memberIds as $rawId) { + $memberId = (int)$rawId; + if (!isset($membersById[$memberId])) { + continue; + } + + $member = $membersById[$memberId]; + $email = trim((string)($member['email'] ?? '')); + if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + $skippedNoEmail++; + continue; + } + + $memberDues = $duesMap[$memberId] ?? []; + $renderedSubject = $this->renderTemplateVariables($subjectTemplate, $member, $memberDues, $year); + $renderedBody = $this->renderTemplateVariables($bodyTemplate, $member, $memberDues, $year); + $htmlBody = $this->buildEmailHtml($renderedSubject, $renderedBody); + + try { + $recipientName = trim($member['first_name'] . ' ' . $member['last_name']); + $message = $this->mailer->createMessage(); + $message->setSubject($renderedSubject); + $message->setPlainBody($renderedBody); + $message->setHtmlBody($htmlBody); + $message->setTo([$email => $recipientName]); + $failedRecipients = $this->mailer->send($message); + if (!empty($failedRecipients)) { + $errors[] = sprintf('%s (%s) : échec SMTP', $recipientName, $email); + } else { + $sent++; + } + } catch (Throwable $e) { + $errors[] = sprintf('%s (%s) : %s', $member['last_name'], $email, $e->getMessage()); + } + } + + return [ + 'sent' => $sent, + 'skipped_no_email' => $skippedNoEmail, + 'errors' => $errors, + ]; + } + + /** + * Remplace toutes les variables dynamiques {{...}} pour un syndiqué et une année donnés. + * + * @param array $member + * @param array> $memberDues + */ + public function renderTemplateVariables( + string $text, + array $member, + array $memberDues, + int $year, + ): string { + $currentYear = (int)date('Y'); + $currentMonth = (int)date('n'); + $currentDay = (int)date('j'); + if ($year < $currentYear) { + $maxCheckMonth = 12; + } elseif ($year > $currentYear) { + $maxCheckMonth = 0; + } else { + $maxCheckMonth = ($currentDay > 10) ? $currentMonth : max(0, $currentMonth - 1); + } + + $totalPaye = 0.0; + $nbMoisPayes = 0; + $lastPaidAmount = 0.0; + + for ($m = 1; $m <= 12; $m++) { + $amt = isset($memberDues[$m]) ? (float)$memberDues[$m]['amount'] : 0.0; + if ($amt > 0.0) { + $totalPaye += $amt; + $nbMoisPayes++; + $lastPaidAmount = $amt; + } + } + + $missingMonthsNames = []; + for ($m = 1; $m <= $maxCheckMonth; $m++) { + $amt = isset($memberDues[$m]) ? (float)$memberDues[$m]['amount'] : 0.0; + if ($amt <= 0.0) { + $missingMonthsNames[] = self::MONTH_NAMES_FR[$m]; + } + } + + $nbMoisManquants = count($missingMonthsNames); + $moisManquantsListe = $nbMoisManquants > 0 ? implode(', ', $missingMonthsNames) : 'Aucun'; + + $cotisationHabituelle = (float)($member['default_amount'] ?? 0.0); + if ($cotisationHabituelle <= 0.0 && $lastPaidAmount > 0.0) { + $cotisationHabituelle = $lastPaidAmount; + } + + $creditImpot = round($totalPaye * 0.66, 2); + $montantEstimeRetard = round($nbMoisManquants * $cotisationHabituelle, 2); + + $addressParts = []; + if (trim((string)$member['address']) !== '') { + $addressParts[] = trim((string)$member['address']); + } + $cpCity = trim(trim((string)$member['postal_code']) . ' ' . trim((string)$member['city'])); + if ($cpCity !== '') { + $addressParts[] = $cpCity; + } + $adresseComplete = $addressParts !== [] ? implode(' — ', $addressParts) : 'Adresse non renseignée'; + + $replacements = [ + '{{civilite}}' => (string)($member['civility'] ?? ''), + '{{nom}}' => (string)($member['last_name'] ?? ''), + '{{prenom}}' => (string)($member['first_name'] ?? ''), + '{{adresse_complete}}' => $adresseComplete, + '{{code_responsable}}' => (string)($member['cogitiel_id'] ?? ''), + '{{annee}}' => (string)$year, + '{{date_du_jour}}' => date('d/m/Y'), + '{{total_paye}}' => $this->formatEuros($totalPaye), + '{{credit_impot}}' => $this->formatEuros($creditImpot), + '{{nb_mois_payes}}' => (string)$nbMoisPayes, + '{{mois_manquants_liste}}' => $moisManquantsListe, + '{{nb_mois_manquants}}' => (string)$nbMoisManquants, + '{{cotisation_habituelle}}' => $this->formatEuros($cotisationHabituelle), + '{{montant_estime_retard}}' => $this->formatEuros($montantEstimeRetard), + ]; + + return strtr($text, $replacements); + } + + private function formatEuros(float $amount): string { + return number_format($amount, 2, ',', ' ') . ' €'; + } + + private function buildEmailHtml(string $subject, string $plainBody): string { + $escapedSubject = htmlspecialchars($subject, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $escapedBody = nl2br(htmlspecialchars($plainBody, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + + return << + + + +{$escapedSubject} + + +

+
+ UNION LOCALE CGT DE FOIX ET DE LA HAUTE-ARIÈGE
+ Suivi des Cotisations Syndicales — Isolés / Multipros +
+
+ {$escapedBody} +
+
+ + +HTML; + } + + /** + * @return array + */ + private function getMemberById(int $id): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('ul_cgt_members') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $res = $qb->executeQuery(); + $row = $res->fetchAssociative(); + $res->closeCursor(); + + if ($row === false) { + return []; + } + + return $this->normalizeMemberRow($row); + } + + /** + * @param array $row + * @return array + */ + private function normalizeMemberRow(array $row): array { + return [ + 'id' => (int)$row['id'], + 'cogitiel_id' => (string)$row['cogitiel_id'], + 'civility' => (string)($row['civility'] ?? ''), + 'last_name' => (string)$row['last_name'], + 'first_name' => (string)$row['first_name'], + 'phone_personal' => (string)($row['phone_personal'] ?? ''), + 'phone_pro' => (string)($row['phone_pro'] ?? ''), + 'address' => (string)($row['address'] ?? ''), + 'postal_code' => (string)($row['postal_code'] ?? ''), + 'city' => (string)($row['city'] ?? ''), + 'email' => (string)($row['email'] ?? ''), + 'base_code' => (string)($row['base_code'] ?? ''), + 'base_name' => (string)($row['base_name'] ?? ''), + 'default_amount' => round((float)($row['default_amount'] ?? 0.0), 2), + 'default_method' => (string)($row['default_method'] ?? 'Virement'), + 'is_archived' => (int)($row['is_archived'] ?? 0), + ]; + } + + private function sanitizeMethod(string $method): string { + $trimmed = trim($method); + if (in_array($trimmed, self::ALLOWED_METHODS, true)) { + return $trimmed; + } + return 'Virement'; + } +} diff --git a/ul_tresorerie/templates/main.php b/ul_tresorerie/templates/main.php new file mode 100644 index 0000000..59228fd --- /dev/null +++ b/ul_tresorerie/templates/main.php @@ -0,0 +1,847 @@ + +
+ +
+
+ Union Locale CGT Foix Haute-Ariège +
🔒 Accès restreint — Trésorerie UL CGT
+

Vous n'avez pas encore accès à l'application UL Trésorerie

+

+ Cette application de suivi des cotisations syndicales est réservée aux trésoriers et membres habilités de + l'Union Locale CGT de Foix et de la Haute-Ariège. +

+
+ Compte Nextcloud connecté : + () +
+

+ Demandez à un administrateur Nextcloud d'autoriser votre compte ou votre groupe depuis l'onglet + « Accès & Permissions » de l'application. +

+
+
+ + +
+
+
+ Union Locale CGT Foix Haute-Ariège + +
+
+ Suivi Cotisations — Isolés / Multipros +
+
+ +
Vues principales
+ + +
Filtres & Alertes ()
+ + +
+
+ Année active : + +
+
+ Syndiqués actifs : + 0 +
+
+ Total perçu : + 0,00 € +
+
+ +
+ + +
+
+ + +
+ + + + +
+
+
+ + +
+ + + +
+ + + + +
+ +
+ + + +
+
+ +
+ + Case verte : cotisation réglée (clic = modifier) + + + Case grise : non cotisé (clic = saisie pré-remplie + Entrée) + + + V Virement · P Prélèvement · C Chèque · E Espèces + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Syndiqué (Nom, Prénom & Statut)Janv.Févr.MarsAvr.MaiJuinJuil.AoûtSept.Oct.Nov.Déc.Total Annuel
Chargement des données de trésorerie…
Total Mensuel (0 affichés)0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €0,00 €
+
+
+ + + + + + + + + + + +
+ + + + + + + + + + +