diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fecc454 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_style = space +indent_size = 4 + +[*.{css,js,json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be803d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +data/** +resources/documents/** +!resources/documents/README.md +public/media/** +!public/media/.gitkeep +storage/** +tmp/** +!tmp/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..33467ab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Journal des évolutions de Globinours + +Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). + +## [1.0.0-rc.3] — 2026-09-03 + +### Stabilisation + +- gel fonctionnel avant la publication de la version 1.0 ; +- séparation des styles, scripts de navigation, sous-vues communes et routes web ; +- recherche universelle accessible au clavier et filtrée par permissions ; +- correction de l’installation vierge et réparation automatique des schémas de médias incomplets ; +- retrait des scripts ponctuels, paramètres partenaires et récits contenant des données propres à l’instance pilote ; +- durcissement des exclusions Git pour les bases, médias, documents, sauvegardes et journaux ; +- jeu fictif recentré sur une fiche Twix complète : photographie optimisée, quarantaine, médical, courbe de poids, vaccination, vermifugation, antécédent, prise de sang et rendez-vous d’agenda ; +- normalisation de la présentation des sources PHP selon PSR-12, ajout des conventions d’éditeur et retrait de l’ancien seed de développement devenu redondant ; +- validation de 25 parcours automatisés, de la syntaxe PHP et des traductions françaises et anglaises. + +## [1.0.0-rc.2] — 2026-09-02 + +### Recherche et découpage + +- recherche globale des animaux, contacts, événements et factures, sans indexation du contenu médical sensible ; +- découpage supplémentaire du contrôleur animal ; +- alignement de la page Évolutions et de la documentation sur la version candidate. + +## [1.0.0-rc.1] — 2026-09-02 + +### Première stabilisation pré-1.0 + +- nettoyage conservateur du projet et retrait des sources historiques du paquet applicatif ; +- découpage de la fiche et du contrôleur animal par domaines métier ; +- sauvegardes SQLite compatibles avec les volumes synchronisés ; +- validation des parcours essentiels, des traductions et de la syntaxe PHP. + +## [0.1.0] — 2026-08-24 + +### Socle initial + +- gestion des animaux, liens familiaux, placements, adoptions et décès ; +- suivi médical, vaccinations, traitements, mesures et documents privés ; +- annuaire, tournées, statistiques, registre administratif et exports ; +- comptes, rôles, permissions, audit et sauvegardes ; +- import ASM3 en simulation puis transaction protégée ; +- interface responsive, PWA, mini-site public facultatif et internationalisation FR/EN. + +[1.0.0]: https://nnsprod.com/git/oversu/Globinours +[0.1.0]: https://nnsprod.com/git/oversu/Globinours diff --git a/HISTORIQUE.md b/HISTORIQUE.md new file mode 100644 index 0000000..230fc0d --- /dev/null +++ b/HISTORIQUE.md @@ -0,0 +1,23 @@ +# Histoire de Globinours + +Globinours est né d’un besoin de terrain : réunir dans une application légère les informations qu’un refuge utilise chaque jour, sans imposer une infrastructure complexe. Le projet s’est construit autour de PHP et SQLite afin de rester facile à installer, sauvegarder et déplacer. + +Les premières versions ont posé la fiche animale, le suivi médical, les placements, les adoptions, les documents et les tournées. L’application s’est ensuite enrichie d’un annuaire, de statistiques, d’outils administratifs, d’une pharmacie, d’un agenda partagé et d’un mini-site public séparant strictement les contenus publiés des informations internes. + +La sécurité et la continuité ont progressivement rejoint le cœur du projet : authentification, permissions, journal d’audit, médias privés hors de la racine web, sauvegardes vérifiées, restauration contrôlée, protection CSRF, limitation des connexions et paramètres adaptés aux reverse proxies de confiance. + +L’import depuis Animal Shelter Manager a été conçu comme un parcours prudent. Les analyses commencent en simulation, les rapprochements ambigus restent à vérifier et les imports réels sont précédés d’une sauvegarde. Aucun fichier source ni aucune donnée d’un refuge pilote n’est livré avec Globinours. + +Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation. + +## `1.0.0-rc.1` — Première stabilisation + +Nettoyage du projet, séparation des principales vues animales, découpage du contrôleur et validation initiale des parcours essentiels. + +## `1.0.0-rc.2` — Recherche universelle + +Recherche transversale respectant les permissions et poursuite du découpage du cœur applicatif. + +## `1.0.0-rc.3` — Gel fonctionnel + +Séparation des ressources visuelles et des routes, correction de l’installation vierge, anonymisation du dépôt public, création d’une fiche Twix fictive complète et harmonisation de la présentation du code avant le gel fonctionnel de la version 1.0. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b80ed7f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +# Licence de Globinours + +Copyright © 2026 OverSu + +Le code source de Globinours est distribué selon les termes de la **GNU Affero General Public License, version 3 ou toute version ultérieure** (`AGPL-3.0-or-later`). + +Vous pouvez utiliser, étudier, modifier et redistribuer ce code selon les conditions de cette licence. Si vous modifiez Globinours et permettez à des utilisateurs d’interagir avec cette version à travers un réseau, vous devez leur proposer l’accès au code source correspondant dans les conditions prévues par l’AGPL. + +Le texte officiel complet de la licence est disponible auprès du projet GNU : + + + +## Éléments distincts + +Sauf mention explicite contraire, la licence du code ne confère aucun droit sur : + +- les données, photographies, documents médicaux ou informations personnelles d’une installation ; +- le logo et l’identité d’une association utilisatrice ; +- les modèles administratifs fournis par une association ; +- les marques, noms et contenus appartenant à des tiers. + +Le nom et l’identité graphique **Globinours** restent associés au projet d’origine. Une version modifiée ne doit pas laisser croire qu’elle est publiée ou hébergée officiellement par Globinours sans autorisation. diff --git a/readme.md b/README.md similarity index 68% rename from readme.md rename to README.md index 8bbb0dc..9206c33 100644 --- a/readme.md +++ b/README.md @@ -1,5 +1,9 @@

Logo Globinours

Globinours

+

Nouveau : suivi structuré des chirurgies et comptabilité vétérinaire légère (factures, avoirs, règlements et justificatifs privés).

+

Les factures sont corrigeables depuis leur ligne et leur suppression passe par une corbeille qui les exclut des totaux sans effacer la preuve.

+

Le pilotage comptable analyse les paiements par structure et par année, les échéances, les règlements et le coût moyen rapproché des animaux, avec exports CSV et impression PDF.

+

Les factures se filtrent par recherche, année, structure, statut, type de pièce et moyen de règlement ; les indicateurs et l’export suivent le même périmètre.

La gestion quotidienne d’un refuge, pensée pour rester humaine, rapide et lisible.
Né de l’histoire de Twix, conçu pour protéger les suivants.

PHP 8.1+ @@ -13,7 +17,7 @@ ## 🐾 Le projet -Globinours est une application web de gestion de refuge animalier, développée au plus près du fonctionnement réel de l’association **Les Coussinets**. +Globinours est une application web de gestion de refuge animalier, développée à partir de besoins concrets observés sur le terrain. Elle centralise les animaux, leur santé, leurs documents, leurs liens familiaux, les familles d’accueil, les contacts et les actions quotidiennes. Son objectif est simple : permettre aux bénévoles et responsables de trouver ou saisir une information en quelques secondes, sans traverser une succession d’écrans complexes. @@ -25,6 +29,8 @@ Globinours privilégie : - la traçabilité des actions importantes ; - la maîtrise locale des données du refuge. +Une recherche globale accessible par `Ctrl+K` ou `⌘K` retrouve instantanément animaux, contacts, événements et factures. Chaque utilisateur ne voit que les catégories autorisées par son rôle ; aucune note ni pièce médicale sensible n’est indexée. + L’identité de l’association est personnalisable, logo compris. Ce logo est conservé dans l’espace privé de l’installation, sauvegardé avec les données et prévu pour alimenter les documents officiels. Le pied de page identifie clairement le projet, son année de développement, sa version et son développeur, [OverSu](https://oversu.fr), avec un accès direct au [code source](https://nnsprod.com/git/oversu/Globinours). @@ -33,7 +39,7 @@ Le récit complet est conservé dans l’[Histoire de TWIX et Globinours](HISTOR ## 🐈 De TWIX à Globinours -Le projet est né au sein de l’association **Les Coussinets**, face à un constat très concret : les logiciels de refuge existants répondaient mal au travail quotidien. Les informations restaient dispersées, certaines opérations exigeaient trop de manipulations et des besoins vétérinaires ou administratifs essentiels manquaient. +Le projet est né au sein d’un refuge pilote, face à un constat très concret : les logiciels existants répondaient mal au travail quotidien. Les informations restaient dispersées, certaines opérations exigeaient trop de manipulations et des besoins vétérinaires ou administratifs essentiels manquaient. L’ambition initiale n’était pas de construire un produit commercial, mais un compagnon de travail **pensé par un refuge, pour un refuge**. Le premier nom du logiciel fut **TWIX**, en hommage au premier chat sauvé par l’association. @@ -86,8 +92,8 @@ L’architecture a continué à s’alléger : DomPDF et les dépendances Compos ### Suivi médical et tournées sur tablette - tournée du matin ou du soir ; -- salles collectives Silver et Twix ; -- salles de soins et de quarantaine avec box modulables ; +- salles collectives ou compartimentées entièrement configurables (nom, couleur, ordre et statut associé), avec deux salles collectives, une infirmerie et une quarantaine proposées par défaut ; +- nombre et libellé des box adaptables à chaque refuge, sans effacer l’historique des espaces désactivés ; - plusieurs animaux par box ; - traitements attendus affichés pendant la tournée ; - référentiel de médicaments trié alphabétiquement et création à la volée lors du démarrage d’un traitement ; @@ -115,7 +121,7 @@ L’architecture a continué à s’alléger : DomPDF et les dépendances Compos - registre annuel des entrées et sorties ; - bilans statistiques filtrables et imprimables par commune d’origine pour accompagner les demandes de subvention ; -- modèles ODT accessibles depuis l’espace administratif ; +- prise en charge de modèles ODT propres à chaque association depuis `resources/documents/` ; - contrats d’adoption et documents d’abandon préremplis ; - certificat de visite pré-adoption disponible en ODT et en PDF imprimable depuis l’administration ; - documents pour bénévoles et familles d’accueil ; @@ -131,6 +137,16 @@ L’architecture a continué à s’alléger : DomPDF et les dépendances Compos - sauvegarde ZIP complète, téléchargeable et restaurable ; - sauvegarde d’urgence automatique avant restauration. +## 📚 Documentation utilisateur + +Le [manuel d’utilisation complet](docs/README.md) est écrit en Markdown et se consulte directement dans Forgejo. Son sommaire accompagne les bénévoles et responsables depuis la première connexion jusqu’aux animaux, soins, tournées, statistiques, documents et fonctions administratives. Des chapitres séparés couvrent les paramètres, permissions, sauvegardes, migrations ASM3, confidentialité, PWA et application Android. + +La documentation suit les écrans réellement disponibles et reste versionnée avec le code : une installation donnée conserve ainsi le manuel correspondant à sa version. + +Le projet possède également un **site promotionnel autonome** dans le dépôt voisin `Website/`. Cette vitrine statique, sans CDN ni compilation, présente clairement Globinours comme un « Logiciel de gestion de refuge » puis explique qu’il s’agit d’une application web utilisable dans un navigateur, sans installation sur les postes de l’équipe. Elle met en avant son origine bretonne avec le Gwen ha Du, illustre ses principaux parcours et prépare l’intégration d’une vidéo de démonstration sous-titrée. + +Les repères français et bretons du site utilisent des drapeaux SVG locaux. Le bloc historique repose sur une silhouette cartographique réelle de la Bretagne, créditée dans le projet `Website`, et la grille fonctionnelle présente également la continuité par sauvegarde ainsi que le référentiel multi-espèces. + ## 🖥️ Philosophie produit Globinours ne cherche pas à devenir une suite universelle. Il couvre précisément le quotidien d’un refuge, avec des parcours courts et compréhensibles. @@ -156,7 +172,7 @@ refuge/ ├── public/ # Racine web, assets et médias ├── resources/ │ ├── branding/ # Identité visuelle livrée avec Globinours -│ └── documents/ # Modèles ODT du logiciel +│ └── documents/ # Modèles ODT locaux de l’association, non versionnés ├── storage/ │ ├── backups/ # Sauvegardes ZIP │ └── logs/ # Journal PHP local et privé @@ -179,7 +195,7 @@ refuge/ | Images | ImageMagick, WebP et conservation de l’original en mode de repli | | Sauvegardes | ZipArchive, SHA-256 et contrôle d’intégrité SQLite | -Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. Aucun `composer install` ni `npm install` n’est nécessaire. +Les bibliothèques d’interface (Tabler, Chart.js, Flatpickr et Leaflet) sont livrées dans `public/assets/vendor/`. Aucun CDN, `composer install` ni `npm install` n’est nécessaire pour utiliser l’interface sur le réseau local. ## 🔐 Sécurité @@ -188,7 +204,9 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A - mots de passe avec `password_hash()` et `password_verify()` ; - minimum de 10 caractères dans l’interface ; - régénération de l’identifiant de session après connexion ; +- expiration après 30 minutes d’inactivité et durée absolue de 12 heures, configurables par environnement ; - verrouillage de 15 minutes après 5 échecs ; +- limitation complémentaire des échecs par adresse IP et comparaison factice pour les comptes inconnus ; - comptes désactivables ; - protection empêchant un administrateur de retirer ses propres droits. @@ -196,12 +214,15 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A - cookies `HttpOnly` et `SameSite=Strict` ; - attribut `Secure` activé sous HTTPS ; +- mode de session PHP strict et cookies uniquement, avec prise en charge explicite des reverse proxies de confiance ; - jeton CSRF aléatoire vérifié sur les requêtes POST ; - écritures autorisées selon le rôle ; -- redirections après connexion limitées aux chemins internes. -- en-têtes `nosniff`, anti-iframe, politique de référent limitée à l’origine et permissions navigateur restrictives ; +- redirections après connexion limitées aux chemins internes ; +- en-têtes `nosniff`, anti-iframe, HSTS sous HTTPS, politique CSP, politique de référent limitée à l’origine et permissions navigateur restrictives ; - routes de maintenance limitées au mode développement, à la machine locale et aux administrateurs. +Les délais peuvent être adaptés avec `GLOBINOURS_SESSION_IDLE_SECONDS` et `GLOBINOURS_SESSION_ABSOLUTE_SECONDS`. Derrière YunoHost ou un autre reverse proxy non local, `GLOBINOURS_TRUSTED_PROXIES` doit contenir exclusivement ses adresses IP séparées par des virgules ; les en-têtes transférés provenant d’autres clients sont ignorés. + ### Base et affichage - requêtes paramétrées PDO et émulation désactivée ; @@ -213,7 +234,8 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A ### Téléversements et audit - inspection MIME des photos côté serveur ; -- distinction applicative des médias privés ; +- médias privés stockés hors de `public/`, servis uniquement par une route authentifiée et autorisée ; +- documents médicaux réservés aux rôles médicaux et chaque consultation privée journalisée ; - audit nominatif des actions ; - mots de passe et jetons CSRF exclus des métadonnées d’audit. @@ -221,7 +243,7 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A - snapshot cohérent par `VACUUM INTO` ; - manifeste versionné et empreinte SHA-256 de chaque fichier ; -- rejet des chemins ZIP dangereux ; +- extraction fichier par fichier et rejet des chemins dangereux, liens symboliques et entrées absentes du manifeste ; - `PRAGMA integrity_check` avant restauration ; - sauvegarde automatique avant remplacement ; - reconnexion obligatoire après restauration. @@ -243,7 +265,7 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A - LibreOffice, facultatif mais nécessaire pour convertir les ODT en PDF ; - ImageMagick, recommandé pour optimiser automatiquement les images téléversées ; - écriture autorisée dans `data`, `public/media` et `storage` ; -- accès Internet pour les CDN, la cartographie et les intégrations externes. +- accès Internet uniquement pour les fonds cartographiques et les intégrations externes (géocodage, I-CAD, notifications distantes) ; le cœur de l’interface reste utilisable sans Internet. Exemple Debian/Ubuntu : @@ -282,7 +304,7 @@ Pour une démonstration temporaire à distance, le routeur `public/router.php` p La racine du serveur web doit pointer vers `public/`, jamais vers la racine complète du projet. `data/` et `storage/` ne doivent pas être servis par HTTP. -Ainsi, si le projet se trouve dans `/var/www/globinours`, la racine documentaire du domaine doit être `/var/www/globinours/public`. L’adresse publique reste alors `https://lescoussinets.org/` — il ne faut pas ajouter `/public/` dans l’URL. Le fichier `public/.htaccess` fourni redirige les routes applicatives vers `index.php` sur Apache ou LiteSpeed, sous réserve que les règles `.htaccess` soient autorisées. +Ainsi, si le projet se trouve dans `/var/www/globinours`, la racine documentaire du domaine doit être `/var/www/globinours/public`. L’adresse publique reste alors `https://refuge.example.org/` — il ne faut pas ajouter `/public/` dans l’URL. Le fichier `public/.htaccess` fourni redirige les routes applicatives vers `index.php` sur Apache ou LiteSpeed, sous réserve que les règles `.htaccess` soient autorisées. Configuration Nginx équivalente dans le bloc du domaine : @@ -316,6 +338,8 @@ Les 10 sauvegardes les plus récentes sont conservées localement. Elles peuvent ## 🧪 Vérifications +L’organisation physique du dépôt et les éléments temporaires sont détaillés dans [l’audit des fichiers](docs/15-audit-structure-fichiers.md). Les anciennes sources de récupération résident dans le dossier voisin `ARCHIVES/Globinours-recuperation-2026/` et les scripts concernés demandent leur chemin explicitement. Les migrations SQL font partie intégrante de l’application et ne doivent jamais être supprimées d’un paquet ou d’un déploiement. + ```bash find app public -name '*.php' -print0 | xargs -0 -n1 php -l php scripts/check_translations.php @@ -340,13 +364,13 @@ La commande respecte la fréquence et la date du dernier envoi. Elle utilise la | Étape | État | Contenu | |---|:---:|---| -| Origines de TWIX | ✅ Historique | Besoin terrain des Coussinets, socle PHP/SQLite, premières fiches animales, médical et documents PDF | +| Origines du projet | ✅ Historique | Besoin terrain d’un refuge pilote, socle PHP/SQLite, premières fiches animales, médical et documents PDF | | Passage à Globinours | ✅ Historique | Ouverture à d’autres associations, réorganisation du code, nouvelle identité et hommage durable à Twix | | Fondations | ✅ Livré | Animaux, portées, médical, annuaire, documents, adoptions et tournées tablette | | Continuité | ✅ Livré | Utilisateurs, audit, sauvegarde/restauration, archives recherchables et triables, consultation des fiches archivées, corbeille et décès | | Dashboard opérationnel | ✅ Livré | Synthèse des urgences, soins, échéances, quarantaines, poids, tournées du jour et qualité des fiches | | Statistiques du refuge | ✅ Livré | Comparaison N−1, activité, médical, profils, couleurs, races, âges, origines, séjours et photographie actuelle | -| Bilans communaux | ✅ Livré | Filtre annuel ou global par commune d’origine, villes cliquables, indicateurs recalculés et impression pour les subventions | +| Bilans communaux | ✅ Livré | Filtre annuel ou global, villes cliquables, indicateurs recalculés, impression et couverture distinguant communes documentées, déduites par fratrie et inconnues | | Rôles et permissions | ✅ Livré | Accès par groupe et par module, séparation voir/modifier, navigation adaptée et contrôle serveur | | Lieux de vie | ✅ Livré | Chronologie avant/après des statuts, salles, box, familles et adresses, attribution utilisateur et export CSV | | Médias plus légers | ✅ Livré | Compression, orientation, redimensionnement et nettoyage des métadonnées à l’import | @@ -375,7 +399,7 @@ La commande respecte la fréquence et la date du dernier envoi. Elle utilise la | Décès connus à l’année | ✅ Livré | Précision annuelle sans fausse date, âge au décès, décès après adoption et médailles mémorielles conservés dans les imports historiques | | Statistiques historiques réconciliées | ✅ Livré | Entrées, sorties, adoptions, naissances, vaccinations et médical consolidés depuis les fiches métier, avec événements non datés séparés de la chronologie | | Vermifuges | ✅ Livré | Catalogue distinct des médicaments (9 produits initiaux), historique structuré, échéances selon l’âge, rythme trimestriel adulte, administration groupée des portées et aperçu médical | -| Ordonnances et analyses | ✅ Livré | Documents privés, photo sur tablette, liaison aux traitements, résultats structurés, courbes par paramètre et références configurables par analyseur et espèce | +| Ordonnances et analyses | ✅ Livré | Documents privés, photo sur tablette, saisie verticale au clavier, résultats structurés, courbes et quatre bilans configurables par espèce : complet, rénal, hépatique et thyroïdien | | Mini-site public | ✅ Livré | Accueil, navigation directe par espèce, fiches d’adoption, page libre, dons, contact cartographié, identité légale en pied de page, coordonnées protégées des robots simples et trois thèmes CSS légers | | Installation guidée | ✅ Livré | Contrôle des prérequis et écritures, identité associative, langue, mode public, premier administrateur et verrouillage après installation | | Santé et maintenance | ✅ Livré | Intégrité SQLite, migrations, stockage, sauvegardes, médias, configuration PHP et erreurs récentes depuis une page en lecture seule | @@ -388,13 +412,50 @@ La commande respecte la fréquence et la date du dernier envoi. Elle utilise la | Fiches de l’annuaire | ✅ Livré | Lignes entièrement cliquables, animaux adoptés/accueillis/déposés — y compris archivés ou décédés —, personnes rattachées, compteur d’adoptions et dépenses cumulées des structures | | Réconciliation I‑CAD | ✅ Livré | Consolidation transactionnelle des adoptants et dates d’adoption, création des fiches historiques manquantes et fusion contrôlée des doublons de contacts | | Cohérence du parcours | ✅ Livré | Statut et emplacement recalculés depuis le dernier placement, historique et sortie synchronisés, corrections d’archives sans restauration | -| Qualité de production | 🚧 En progression | Étendre les tests aux parcours HTTP complets, rendre le déploiement reproductible et documenter la comparaison avec Animal Shelter Manager | +| Confidentialité et conservation | ✅ Livré | Export d’un contact, anonymisation contrôlée précédée d’une sauvegarde, révision selon une durée configurable, registre des fichiers privés et pages légales du mini-site | +| Qualité de production | 🚧 En progression | Étendre les tests aux parcours HTTP complets et documenter la comparaison avec Animal Shelter Manager | +| Déploiement reproductible | ✅ Livré | Nginx/PHP-FPM/systemd, maintenance, sauvegarde avant mise à niveau, migrations contrôlées et retour arrière du code | +| Publication YunoHost | 🧪 Prêt à tester | Paquet v2 audité et bundle local signé disponibles ; valider installation, mise à niveau, sauvegarde et restauration sur une instance YunoHost avant de figer l’archive publique | +| Paquets ZimaOS et CasaOS | 💡 À étudier | Préparer des applications légères fondées sur Docker, avec volumes persistants, installation guidée et mises à niveau sauvegardées ; vérifier d’abord les contraintes propres à chaque catalogue | +| Interface mobile et PWA | ✅ Socle livré | Navigation tactile, formulaires et fenêtres adaptatifs, installation PWA sans mise en cache des données métier | +| Appareils de confiance | ✅ Livré | Session persistante 90 jours sans stockage du mot de passe, registre personnel et révocation unitaire ou globale | +| Wrapper Android | 🧪 APK debug livré | Dépôt Java natif autonome sans bibliothèque applicative, instance HTTPS configurable, WebView cloisonnée, cookies persistants, verrouillage par la sécurité Android et APK signé de test ; validation sur appareils à réaliser | La roadmap illustrée et l’historique des grandes évolutions sont également consultables directement dans l’application depuis le lien **Évolutions** du pied de page. Le détail exhaustif destiné au suivi Git se trouve dans [CHANGELOG.md](CHANGELOG.md). +### Confidentialité et conservation + +La rubrique **Paramètres → Confidentialité / RGPD** offre un export portable par contact et des durées distinctes pour adoptants, familles d’accueil, bénévoles et autres contacts. La liste de révision bloque les dossiers encore actifs ; l’anonymisation exige un motif, la confirmation d’une vérification humaine et une sauvegarde préalable. Les relations non nominatives nécessaires aux statistiques sont conservées. Le registre des accès aux fichiers privés possède sa propre rétention et peut être purgé par la maintenance planifiée, sans jamais supprimer automatiquement un contact. + +## 💊 Pharmacie et stocks + +La pharmacie sépare médicaments, vermifuges et vaccins. Chaque produit possède une unité et un seuil minimal ; chaque lot conserve quantité, fournisseur, coût unitaire, numéro et péremption. Réceptions, administrations, pertes et corrections forment un journal nominatif. Les vaccins et vermifuges peuvent consommer directement un lot depuis la fiche animale. Une consommation tarifée rejoint automatiquement le coût estimé de l’animal et les statistiques financières. Les seuils bas et péremptions sous trente jours alimentent les notifications. + +## ⏱ Sauvegarde automatique vérifiée + +La fréquence quotidienne ou hebdomadaire se règle dans **Paramètres → Sauvegardes**. La commande `php scripts/maintenance.php run`, appelée régulièrement par cron ou systemd, crée uniquement les archives arrivées à échéance. Chaque ZIP est immédiatement rouvert : manifeste, empreintes SHA-256 et intégrité SQLite doivent tous être valides avant que l’exécution soit considérée réussie. La même page permet de revérifier manuellement n’importe quelle archive. + +## 📅 Agenda partagé + +L’agenda réunit rendez-vous généraux ou vétérinaires, transports, visites pré-adoption et tâches. Chaque élément peut être relié à un animal, un contact, un utilisateur responsable et plusieurs bénévoles issus de l’annuaire, avec priorité, lieu, description, horaire ou journée entière et rappel. Les filtres isolent son propre agenda, un responsable, un bénévole, une catégorie ou une recherche ; les rappels médicaux peuvent être masqués temporairement. Globinours avertit lorsqu’une même personne reçoit deux missions qui se chevauchent. L’équipe choisit une vue mensuelle ou hebdomadaire ; ce choix est conservé sur son compte. Un clic dans l’espace libre d’une journée ouvre la création avec la date déjà renseignée, y compris au clavier. La période affichée peut être téléchargée au format iCalendar (`.ics`). Chaque utilisateur peut également créer un lien d’abonnement privé compatible avec les principaux agendas, puis le révoquer ou le régénérer ; seul son hash SHA-256 est conservé en base. Les répétitions quotidiennes, hebdomadaires et mensuelles sont développées dans le calendrier sans dupliquer les enregistrements. Les rappels vaccinaux, prochains vermifuges ainsi que les dates de début et de fin des traitements déjà enregistrés dans les fiches animales apparaissent automatiquement dans le calendrier et ouvrent directement la bonne section du suivi médical, sans double saisie. Les échéances sous vingt-quatre heures rejoignent le dashboard et les notifications. Les droits de consultation et de modification sont configurables par groupe comme les autres modules. + +### Déploiement et YunoHost + +Les exemples pour Nginx, PHP-FPM et le service de démonstration sont dans [`deploy/`](deploy/README.md). `scripts/update.sh` vérifie une archive de version, sauvegarde les données, conserve le code précédent, active le mode maintenance, applique les migrations et contrôle SQLite ; `scripts/rollback.sh` restaure explicitement un snapshot de code sans tenter de rétrograder silencieusement la base. + +Après la finalisation du paquet YunoHost, la feuille de route prévoit l’étude de paquets **ZimaOS** et **CasaOS**. Globinours devra disposer d’une image reproductible, de volumes persistants clairement séparés et d’un parcours de sauvegarde et de mise à niveau sûr avant toute publication dans un catalogue. + +Le paquet **YunoHost packaging v2** vit désormais dans un dépôt voisin léger, `yunohost/`, et ne duplique plus le code de Globinours. Après le premier tag stable, il téléchargera l’archive officielle avec contrôle SHA-256. Les données réelles, médias et documents privés vivront dans le répertoire persistant YunoHost et ne seront pas exposés par la racine web. + +### Mobile, PWA et Android + +L’interface dispose d’un socle responsive commun aux ordinateurs, tablettes et smartphones : barre supérieure défilable, navigation tactile, onglets horizontaux, formulaires lisibles sans zoom iOS, modales adaptées et zones d’action suffisamment grandes. Le manifeste PWA permet d’installer Globinours depuis un navigateur compatible. Son service worker ne met en cache que l’enveloppe graphique publique et jamais les pages authentifiées ni les données du refuge. + +La connexion peut créer un appareil de confiance pendant 90 jours sans conserver le mot de passe. Chaque utilisateur retrouve ses appareils depuis le menu du compte et peut les révoquer individuellement ou tous ensemble. Le dépôt voisin `Android/` contient un wrapper Java fondé uniquement sur les API natives : choix d’une instance HTTPS, WebView limitée à ce domaine, ouverture des liens externes dans le navigateur, cookies persistants et déverrouillage par la sécurité biométrique ou le code Android. Un APK debug signé est fourni pour les essais, sans vocation à être publié sur un store. + ### Remise à zéro et base de démonstration -La page **Paramètres → Données et démo** est réservée aux administrateurs. Elle propose deux opérations protégées par une phrase de confirmation : vider toutes les données métier, ou les remplacer par un jeu entièrement fictif couvrant animaux, annuaire, portée, groupe inséparable, famille d’accueil, médical, poids, vaccins et décès. Une sauvegarde ZIP complète est créée avant chaque opération. +La page **Paramètres → Données et démo** est réservée aux administrateurs. Elle propose deux opérations protégées par une phrase de confirmation : vider toutes les données métier, ou les remplacer par un jeu entièrement fictif. Sa fiche vitrine Twix réunit photographie, identité, quarantaine, consultation, traitement, poids, vaccination, vermifugation, antécédent résolu, prise de sang et rendez-vous d’agenda ; les autres animaux restent volontairement plus légers pour illustrer l’annuaire, une portée, un groupe inséparable, une famille d’accueil et un décès. Une sauvegarde ZIP complète est créée avant chaque opération. Les utilisateurs, rôles et permissions, paramètres de l’association, identité visuelle, modèles documentaires et sauvegardes existantes sont conservés. Les médias animaliers et justificatifs de subvention liés aux anciennes données sont retirés après validation de la transaction. Le jeu fictif est destiné aux démonstrations et ne doit jamais être confondu avec des données réelles. @@ -423,7 +484,31 @@ php scripts/recover-asm3-intake.php chemin/vers/dump.sql --apply Le premier rapport inventorie les tables et les domaines migrables. Le plan JSON convertit ensuite chaque fiche animale vers les champs Globinours et la classe en `create`, `match` ou `review`. Une identification identique ou le couple nom + naissance permettent un rapprochement sûr. Le code refuge n’est jamais utilisé seul entre deux sauvegardes historiques : ASM3 peut l’avoir réattribué après une remise à zéro et Globinours suffixe alors le code importé sans confondre deux animaux. -La reprise des entrées lit le champ ASM3 `REASONFORENTRY`, conserve son texte dans les circonstances et ne structure une commune que lorsqu’elle a été relue comme lieu réel de prise en charge. Une sauvegarde précède l’écriture et un CSV distingue les communes certaines des dossiers ambigus. +La reprise des entrées lit le champ ASM3 `REASONFORENTRY`, conserve son texte dans les circonstances et ne structure une commune que lorsqu’elle a été relue comme lieu réel de prise en charge. Une sauvegarde précède l’écriture et un CSV distingue les communes certaines des dossiers ambigus. Une seconde moulinette peut compléter les membres d’une portée lorsque la mère, les frères et sœurs, les dates et les codes refuge rendent le rapprochement solide : ces valeurs restent marquées `inferred_family`, accompagnées de leur justification, et ne sont jamais confondues avec une saisie documentée. + +Les animaux nés sous la responsabilité du refuge restent volontairement sans commune de récupération : leur entrée structurée `born_in_care` / `birth` les distingue des origines réellement inconnues dans les statistiques. Une naissance au refuge n’est donc jamais transformée artificiellement en récupération sur la commune où se trouvent les locaux. + +Les statistiques historiques réunissent une date d’entrée portée par la fiche et un éventuel mouvement d’entrée, avec dédoublonnage par animal. Lorsqu’un décès sous prise en charge possède une année certaine mais aucune date d’entrée, l’animal contribue au nombre de prises en charge de cette année sans être présenté comme une entrée datée. Les événements sans année restent dans « Toutes les années » et ne sont jamais répartis artificiellement. + +```bash +php scripts/reconstruct-asm3-rescue-locations.php /chemin/dump_asm3.sql +php scripts/reconstruct-asm3-rescue-locations.php /chemin/dump_asm3.sql --apply +``` + +La première commande produit uniquement un rapport de simulation. L’application refuse tout conflit, crée une sauvegarde, synchronise le premier mouvement d’entrée et inscrit la justification dans l’historique. Les adresses de vétérinaires, adoptants ou tiers ne sont pas utilisées comme communes d’origine sans preuve explicite. + +Les classeurs historiques 2022-2023 peuvent ensuite compléter les communes grâce au code, au nom et à l’espèce. Un identifiant réutilisé ne suffit jamais à lui seul et une même fiche retrouvée dans plusieurs communes reste dans le rapport des conflits. + +```bash +php scripts/recover-2022-2023-xlsx.php /chemin/listing.xlsx +php scripts/recover-2022-2023-xlsx.php /chemin/listing.xlsx --apply +``` + +La même commande reconstitue, de façon explicitement estimative, la stérilisation des chats dont la première adoption connue intervient après six mois. Elle ne fabrique pas de date médicale : la date d’adoption sert uniquement de date comptable, le raisonnement et la source tarifaire restant inscrits sur la dépense et dans l’historique. + +Lorsqu’un animal est attesté dans un registre mais absent de la base, il doit être recréé comme fiche historique distincte plutôt que fusionné avec le porteur actuel d’un ancien code réutilisé. Les informations certaines sont conservées ; dates, parentés ou actes non prouvés restent vides. + +Les tarifs des partenaires doivent être saisis ou importés par chaque installation depuis ses propres sources. Aucune grille tarifaire réelle n’est livrée dans le dépôt. Sans `--apply`, les commandes restent en simulation. En mode réel, elles exigent une confirmation textuelle, créent une sauvegarde, utilisent des transactions et mémorisent les correspondances ASM3 afin qu’une reprise ne duplique rien. Les rapprochements et cas ambigus ne sont jamais fusionnés automatiquement. @@ -470,6 +555,8 @@ Le dashboard signale également les fiches actives auxquelles il manque une date Les dépenses vétérinaires peuvent être analysées par commune d’origine, cabinet et catégorie d’acte. Globinours affiche le coût total, le nombre d’animaux du périmètre, ceux ayant réellement généré une dépense, la moyenne sur l’ensemble des animaux, la moyenne sur les seuls animaux avec frais et leur médiane. Cette lecture produit des chiffres directement exploitables pour argumenter une demande de subvention communale sans laisser un cas médical exceptionnel fausser toute l’analyse. +Dans le bilan par structure, chaque cabinet, crématorium ou fourrière identifié est cliquable. Sa fiche reprend exactement la période — et, le cas échéant, la commune — sélectionnée dans les statistiques, puis détaille le total payé ou facturé et chaque dépense par animal. Le refuge peut ainsi chiffrer objectivement son activité apportée à un partenaire pour une année donnée. + Pour une année, le périmètre financier comprend les animaux pris en charge pendant cette année ou ayant généré une dépense pendant celle-ci. Le bilan global utilise toutes les fiches non supprimées. La commune est déduite du lieu de récupération, jamais du cabinet vétérinaire ni de la famille d’accueil. Les formulaires officiels des communes ne sont pas générés par Globinours : ils évoluent trop souvent pour garantir un résultat fiable. Le logiciel réunit plutôt les chiffres, le bilan et les justificatifs nécessaires à leur saisie. @@ -500,6 +587,10 @@ Le contrôle `php scripts/check_translations.php` vérifie les clés utilisées, Les confirmations destructives sont elles aussi localisées et contrôlées côté serveur. Par exemple, la remise à zéro attend `VIDER LA BASE` en français ou `CLEAR DATABASE` en anglais ; le chargement de démonstration et la migration ASM3 suivent la même règle. La phrase attendue provient toujours du catalogue actif, jamais d’une constante cachée dans le contrôleur. +## 📣 Présentation publique du projet + +Le site promotionnel autonome documente désormais précisément les publics visés, les différences avec une organisation par tableurs ou une solution généraliste, le parcours sécurisé de migration ASM3 et les trois modes de déploiement. La démonstration publique, les captures définitives et la vidéo finale sont volontairement réservées à la version 1.0. Le scénario, les sous-titres et la liste de vues à produire sont déjà préparés ; toutes les images devront provenir du jeu de données fictif intégré. + ## ⚠️ Préparer le dépôt Git Ne jamais publier les données réelles du refuge. Au minimum : @@ -519,8 +610,22 @@ Les modèles ODT et les éléments graphiques propres à Globinours placés dans ## 📄 Licence -Aucune licence de redistribution n’est encore fournie. Avant de rendre le dépôt public, choisissez une licence et vérifiez que les documents, logos et modèles peuvent être diffusés. +Le code source de Globinours est distribué sous licence **GNU AGPLv3 ou ultérieure**. Elle autorise l’utilisation, l’étude, la modification et la redistribution, tout en demandant qu’une version modifiée proposée à travers un réseau rende également son code source correspondant accessible à ses utilisateurs. Consultez [LICENSE.md](LICENSE.md). + +Les données d’une installation, les photographies, les documents privés, l’identité d’une association et les modèles fournis par des tiers ne deviennent jamais libres du seul fait qu’ils sont utilisés avec Globinours. Le nom et l’identité graphique Globinours ne doivent pas être employés pour faire passer une version modifiée pour une offre officielle. ---

Globinours
Un outil léger, construit autour des animaux et des personnes qui prennent soin d’eux.

+ +### Candidates de publication 1.0 + +| Candidate | Date | État | Jalons principaux | +|---|---|---|---| +| `1.0.0-rc.1` | 2 septembre 2026 | Remplacée | Nettoyage du projet, première découpe de la fiche et du contrôleur animal, sauvegardes compatibles avec les volumes synchronisés. | +| `1.0.0-rc.2` | 2 septembre 2026 | Remplacée | Recherche universelle, second découpage du contrôleur animal et 23e test automatisé. | +| `1.0.0-rc.3` | 3 septembre 2026 | **Candidate gelée** | Squelette visuel et routes séparés, installation vierge corrigée, démonstration Twix complète, sources PHP harmonisées, 25 contrôles validés et gel fonctionnel. | + +**Version candidate actuelle : `1.0.0-rc.3`.** Aucun nouveau développement fonctionnel n’entre désormais dans la branche 1.0 : seuls les défauts bloquants constatés pendant les dernières validations terrain peuvent être corrigés avant l’attribution de la version `1.0.0`. + +Le résultat du contrôle de stabilisation est consultable dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3c029dd --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0-rc.3 diff --git a/app/Controllers/AccountingController.php b/app/Controllers/AccountingController.php new file mode 100644 index 0000000..b804e04 --- /dev/null +++ b/app/Controllers/AccountingController.php @@ -0,0 +1,513 @@ +query( + "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND r.role IN ('cabinet','crematorium','fourriere','structure_veterinaire') ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + public static function index(): void + { + $db = DB::pdo(); + $year = (int) ($_GET['year'] ?? 0); + $where = ['ai.deleted_at IS NULL']; + $params = []; + if ($year) { + $where[] = "strftime('%Y',ai.invoice_date)=:year"; + $params[':year'] = (string) $year; + } + $s = $db->prepare( + 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' . + implode(' AND ', $where) . + ' ORDER BY date(ai.invoice_date) DESC,ai.id DESC', + ); + $s->execute($params); + $rows = $s->fetchAll(PDO::FETCH_ASSOC); + $years = $db + ->query( + "SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC", + ) + ->fetchAll(PDO::FETCH_COLUMN); + render('accounting.php', [ + 'title' => 'Comptabilité', + 'invoices' => $rows, + 'vendors' => self::vendors($db), + 'years' => $years, + 'year' => $year, + ]); + } + public static function edit(): void + { + $id = (int) ($_GET['id'] ?? 0); + $db = DB::pdo(); + $s = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL'); + $s->execute([$id]); + $invoice = $s->fetch(PDO::FETCH_ASSOC); + if (!$invoice) { + http_response_code(404); + return; + } + render('accounting_edit.php', [ + 'title' => 'Modifier la facture ' . $invoice['reference'], + 'invoice' => $invoice, + 'vendors' => self::vendors($db), + ]); + } + public static function save(): void + { + $values = self::values(); + $file = self::upload(); + $db = DB::pdo(); + $s = $db->prepare( + 'INSERT INTO accounting_invoices(vendor_contact_id,reference,invoice_date,due_date,document_type,status,payment_method,paid_on,total_ht_cents,total_ttc_cents,amount_due_cents,notes,source,original_filename,stored_filename,mime_type,file_size,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', + ); + $s->execute([ + ...$values, + 'Saisie manuelle', + $file['original'] ?? null, + $file['stored'] ?? null, + $file['mime'] ?? null, + $file['size'] ?? null, + Auth::id(), + ]); + AuditService::log( + 'accounting_invoice_created', + '/accounting/save', + 'Facture ajoutée', + 'accounting_invoice', + (int) $db->lastInsertId(), + ['reference' => $values[1]], + ); + header('Location: /accounting?year=0'); + } + public static function update(): void + { + $id = (int) ($_POST['id'] ?? 0); + $db = DB::pdo(); + $q = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL'); + $q->execute([$id]); + $old = $q->fetch(PDO::FETCH_ASSOC); + if (!$old) { + http_response_code(404); + return; + } + $values = self::values(); + $file = self::upload(); + $sql = + "UPDATE accounting_invoices SET vendor_contact_id=?,reference=?,invoice_date=?,due_date=?,document_type=?,status=?,payment_method=?,paid_on=?,total_ht_cents=?,total_ttc_cents=?,amount_due_cents=?,notes=?,updated_at=datetime('now')"; + if ($file) { + $sql .= ',original_filename=?,stored_filename=?,mime_type=?,file_size=?'; + array_push($values, $file['original'], $file['stored'], $file['mime'], $file['size']); + } + $sql .= ' WHERE id=? AND deleted_at IS NULL'; + $values[] = $id; + $db->prepare($sql)->execute($values); + if ($file && !empty($old['stored_filename'])) { + @unlink(self::dir() . '/' . basename((string) $old['stored_filename'])); + } + AuditService::log( + 'accounting_invoice_updated', + '/accounting/update', + 'Facture modifiée', + 'accounting_invoice', + $id, + ); + header('Location: /accounting?year=0'); + } + public static function delete(): void + { + $id = (int) ($_POST['id'] ?? 0); + $db = DB::pdo(); + $s = $db->prepare( + "UPDATE accounting_invoices SET deleted_at=datetime('now'),deleted_by=?,updated_at=datetime('now') WHERE id=? AND deleted_at IS NULL", + ); + $s->execute([Auth::id(), $id]); + if ($s->rowCount()) { + AuditService::log( + 'accounting_invoice_deleted', + '/accounting/delete', + 'Facture mise à la corbeille', + 'accounting_invoice', + $id, + ); + } + header('Location: /accounting?year=0'); + } + public static function document(): void + { + $id = (int) ($_GET['id'] ?? 0); + $s = DB::pdo()->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL'); + $s->execute([$id]); + $r = $s->fetch(PDO::FETCH_ASSOC); + if (!$r || !$r['stored_filename']) { + http_response_code(404); + return; + } + $p = self::dir() . '/' . basename((string) $r['stored_filename']); + if (!is_file($p)) { + http_response_code(404); + return; + } + header('Content-Type: ' . $r['mime_type']); + header( + 'Content-Disposition: inline; filename="' . str_replace('"', '', (string) $r['original_filename']) . '"', + ); + header('Content-Length: ' . filesize($p)); + readfile($p); + } + private static function values(): array + { + $ref = trim((string) ($_POST['reference'] ?? '')); + $date = (string) ($_POST['invoice_date'] ?? ''); + $ttc = self::money($_POST['total_ttc'] ?? ''); + if ($ref === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $ttc === null) { + http_response_code(400); + exit(t('accounting.required_fields')); + } + $status = (string) ($_POST['status'] ?? 'to_pay'); + $due = self::money($_POST['amount_due'] ?? ''); + if ($due === null) { + $due = $status === 'paid' ? 0 : $ttc; + } + return [ + (int) ($_POST['vendor_contact_id'] ?? 0) ?: null, + $ref, + $date, + $_POST['due_date'] ?? '' ?: null, + (string) ($_POST['document_type'] ?? 'invoice'), + $status, + $_POST['payment_method'] ?? '' ?: null, + $_POST['paid_on'] ?? '' ?: null, + self::money($_POST['total_ht'] ?? ''), + $ttc, + $due, + trim((string) ($_POST['notes'] ?? '')) ?: null, + ]; + } + private static function money(mixed $v): ?int + { + $v = trim(str_replace([' ', '€', "\xc2\xa0"], ['', '', ''], (string) $v)); + if ($v === '') { + return null; + } + $v = str_replace(',', '.', $v); + return is_numeric($v) ? (int) round((float) $v * 100) : null; + } + private static function upload(): array + { + if (empty($_FILES['document']['tmp_name'])) { + return []; + } + $f = $_FILES['document']; + if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20 * 1024 * 1024) { + throw new RuntimeException(t('accounting.document_invalid')); + } + $mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']); + $ext = + ['application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ?? + null; + if (!$ext) { + throw new RuntimeException(t('accounting.document_format')); + } + $stored = bin2hex(random_bytes(20)) . '.' . $ext; + if (!move_uploaded_file($f['tmp_name'], self::dir() . '/' . $stored)) { + throw new RuntimeException(t('accounting.document_save_failed')); + } + return [ + 'original' => basename((string) $f['name']), + 'stored' => $stored, + 'mime' => $mime, + 'size' => (int) $f['size'], + ]; + } + public static function dashboard(): void + { + $db = DB::pdo(); + $year = (int) ($_GET['year'] ?? 0); + $where = ['ai.deleted_at IS NULL']; + $params = []; + if ($year) { + $where[] = "strftime('%Y',ai.invoice_date)=:year"; + $params[':year'] = (string) $year; + } + $sql = + 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' . + implode(' AND ', $where) . + ' ORDER BY date(ai.invoice_date) DESC,ai.id DESC'; + $s = $db->prepare($sql); + $s->execute($params); + $rows = $s->fetchAll(PDO::FETCH_ASSOC); + [$rows, $filters] = self::filterRows($rows); + $vendors = []; + $annual = []; + $statuses = []; + $methods = []; + $totals = ['billed' => 0, 'due' => 0, 'documents' => count($rows), 'overdue' => 0, 'to_pay' => 0]; + foreach ($rows as $r) { + $ttc = (int) $r['total_ttc_cents']; + $due = (int) ($r['amount_due_cents'] ?? 0); + $totals['billed'] += $ttc; + $totals['due'] += $due; + if ($due > 0) { + $totals['to_pay']++; + } + if ($due > 0 && !empty($r['due_date']) && $r['due_date'] < date('Y-m-d')) { + $totals['overdue']++; + } + $vendorId = (int) ($r['vendor_contact_id'] ?? 0); + $vendorLabel = $r['vendor_name'] ?: 'Structure non renseignée'; + $vendors[$vendorId] ??= [ + 'id' => $vendorId, + 'label' => $vendorLabel, + 'billed' => 0, + 'due' => 0, + 'documents' => 0, + 'paid_documents' => 0, + 'animals' => 0, + 'animal_cost' => 0, + ]; + $vendors[$vendorId]['billed'] += $ttc; + $vendors[$vendorId]['due'] += $due; + $vendors[$vendorId]['documents']++; + if ($r['status'] === 'paid') { + $vendors[$vendorId]['paid_documents']++; + } + $y = substr((string) $r['invoice_date'], 0, 4); + $annual[$y] ??= ['year' => $y, 'billed' => 0, 'due' => 0, 'documents' => 0]; + $annual[$y]['billed'] += $ttc; + $annual[$y]['due'] += $due; + $annual[$y]['documents']++; + $statuses[$r['status']] = ($statuses[$r['status']] ?? 0) + 1; + $method = $r['payment_method'] ?: 'unknown'; + $methods[$method] = ($methods[$method] ?? 0) + 1; + } + $expenseWhere = $year ? " AND strftime('%Y',ae.occurred_on)=:expense_year" : ''; + $expense = $db->prepare( + "SELECT ae.clinic_contact_id,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total FROM animal_expenses ae WHERE ae.clinic_contact_id IS NOT NULL $expenseWhere GROUP BY ae.clinic_contact_id", + ); + $expense->execute($year ? [':expense_year' => (string) $year] : []); + foreach ($expense->fetchAll(PDO::FETCH_ASSOC) as $e) { + if (isset($vendors[(int) $e['clinic_contact_id']])) { + $vendors[(int) $e['clinic_contact_id']]['animals'] = (int) $e['animals']; + $vendors[(int) $e['clinic_contact_id']]['animal_cost'] = (int) $e['total']; + } + } + usort($vendors, fn($a, $b) => $b['billed'] <=> $a['billed']); + ksort($annual); + $years = $db + ->query( + "SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC", + ) + ->fetchAll(PDO::FETCH_COLUMN); + render('accounting.php', [ + 'title' => 'Comptabilité', + 'invoices' => $rows, + 'vendors' => self::vendors($db), + 'years' => $years, + 'year' => $year, + 'accountingFilters' => $filters, + 'accountingTotals' => $totals, + 'vendorStats' => $vendors, + 'annualStats' => array_values($annual), + 'statusStats' => $statuses, + 'methodStats' => $methods, + ]); + } + public static function exportCsv(): void + { + self::export(false); + } + private static function export(bool $unused): void + { + $db = DB::pdo(); + $year = (int) ($_GET['year'] ?? 0); + $where = ['ai.deleted_at IS NULL']; + $params = []; + if ($year) { + $where[] = "strftime('%Y',ai.invoice_date)=:year"; + $params[':year'] = (string) $year; + } + $s = $db->prepare( + 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' . + implode(' AND ', $where) . + ' ORDER BY date(ai.invoice_date),ai.id', + ); + $s->execute($params); + header('Content-Type: text/csv; charset=UTF-8'); + header( + 'Content-Disposition: attachment; filename="globinours-comptabilite-' . + ($year ?: 'toutes-annees') . + '.csv"', + ); + echo "\xEF\xBB\xBF"; + $out = fopen('php://output', 'wb'); + fputcsv( + $out, + [ + 'Date', + 'Référence', + 'Type', + 'Structure', + 'HT €', + 'TTC €', + 'Statut', + 'Reste dû €', + 'Règlement', + 'Échéance', + 'Notes', + ], + ';', + ); + $safe = static function ($v) { + $v = (string) $v; + return preg_match('/^[=+\-@]/', $v) ? "'" . $v : $v; + }; + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) { + fputcsv( + $out, + [ + $r['invoice_date'], + $safe($r['reference']), + $r['document_type'], + $safe($r['vendor_name'] ?? ''), + $r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''), + number_format($r['total_ttc_cents'] / 100, 2, '.', ''), + $r['status'], + number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''), + $r['payment_method'], + $r['due_date'], + $safe($r['notes'] ?? ''), + ], + ';', + ); + } + fclose($out); + } + private static function filterRows(array $rows): array + { + $filters = [ + 'q' => trim((string) ($_GET['q'] ?? '')), + 'vendor_id' => (int) ($_GET['vendor_id'] ?? 0), + 'status' => (string) ($_GET['status'] ?? ''), + 'type' => (string) ($_GET['type'] ?? ''), + 'payment_method' => (string) ($_GET['payment_method'] ?? ''), + ]; + $rows = array_values( + array_filter($rows, static function ($r) use ($filters) { + if ( + $filters['q'] !== '' && + !str_contains( + mb_strtolower( + implode(' ', [$r['reference'] ?? '', $r['vendor_name'] ?? '', $r['notes'] ?? '']), + ), + mb_strtolower($filters['q']), + ) + ) { + return false; + } + if ($filters['vendor_id'] > 0 && (int) $r['vendor_contact_id'] !== $filters['vendor_id']) { + return false; + } + if ($filters['status'] !== '' && $r['status'] !== $filters['status']) { + return false; + } + if ($filters['type'] !== '' && $r['document_type'] !== $filters['type']) { + return false; + } + if ($filters['payment_method'] === 'unknown' && !empty($r['payment_method'])) { + return false; + } + if ( + $filters['payment_method'] !== '' && + $filters['payment_method'] !== 'unknown' && + $r['payment_method'] !== $filters['payment_method'] + ) { + return false; + } + return true; + }), + ); + return [$rows, $filters]; + } + public static function exportFilteredCsv(): void + { + $db = DB::pdo(); + $year = (int) ($_GET['year'] ?? 0); + $s = $db->prepare( + 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL' . + ($year ? " AND strftime('%Y',ai.invoice_date)=:year" : '') . + ' ORDER BY date(ai.invoice_date),ai.id', + ); + $s->execute($year ? [':year' => (string) $year] : []); + [$rows] = self::filterRows($s->fetchAll(PDO::FETCH_ASSOC)); + header('Content-Type: text/csv; charset=UTF-8'); + header( + 'Content-Disposition: attachment; filename="globinours-comptabilite-' . + ($year ?: 'toutes-annees') . + '.csv"', + ); + echo "\xEF\xBB\xBF"; + $out = fopen('php://output', 'wb'); + fputcsv( + $out, + [ + 'Date', + 'Référence', + 'Type', + 'Structure', + 'HT €', + 'TTC €', + 'Statut', + 'Reste dû €', + 'Règlement', + 'Échéance', + 'Notes', + ], + ';', + ); + $safe = static fn($v) => preg_match('/^[=+\-@]/', (string) $v) ? "'" . $v : (string) $v; + foreach ($rows as $r) { + fputcsv( + $out, + [ + $r['invoice_date'], + $safe($r['reference']), + $r['document_type'], + $safe($r['vendor_name'] ?? ''), + $r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''), + number_format($r['total_ttc_cents'] / 100, 2, '.', ''), + $r['status'], + number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''), + $r['payment_method'], + $r['due_date'], + $safe($r['notes'] ?? ''), + ], + ';', + ); + } + fclose($out); + } + public static function editByReference(): void + { + $ref = trim((string) ($_GET['reference'] ?? '')); + $s = DB::pdo()->prepare( + 'SELECT id FROM accounting_invoices WHERE reference=? AND deleted_at IS NULL ORDER BY id DESC LIMIT 1', + ); + $s->execute([$ref]); + $_GET['id'] = (int) $s->fetchColumn(); + self::edit(); + } +} diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..b60fbac --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,101 @@ + t('admin.exports_title'), 'datasets' => ExportService::catalog()]); + } + public static function downloadExport(): void + { + self::adminOnly(); + $type = (string) ($_GET['type'] ?? ''); + try { + AuditService::log( + 'data_export', + '/admin/exports/download', + $type === 'all' ? 'Export complet des données' : 'Export CSV : ' . $type, + ); + if ($type === 'all') { + ExportService::downloadZip(); + } else { + ExportService::downloadCsv($type); + } + } catch (InvalidArgumentException) { + http_response_code(404); + echo h(t('admin.unknown_export')); + } catch (Throwable $e) { + error_log('Échec export ' . $type . ' : ' . $e->getMessage()); + http_response_code(500); + echo h(t('admin.export_failed')); + } + } + public static function register(): void + { + [$rows, $year, $q, $species, $sort, $dir] = self::registerData(); + render( + 'admin_register.php', + compact('rows', 'year', 'q', 'species', 'sort', 'dir') + ['title' => t('admin.title')], + ); + } + + public static function registerPdf(): void + { + [$rows, $year, $q, $species, $sort, $dir] = self::registerData(); + $generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')); + header('Content-Type: text/html; charset=UTF-8'); + require __DIR__ . '/../Views/admin_register_print.php'; + } + + private static function registerData(): array + { + $pdo = DB::pdo(); + $year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y'); + $q = trim((string) ($_GET['q'] ?? '')); + $species = trim((string) ($_GET['species'] ?? '')); + $allowedSort = [ + 'date' => 'm.created_at', + 'name' => 'a.name', + 'species' => 'a.species', + 'sex' => 'a.sex', + 'type' => 'm.kind', + ]; + $sort = (string) ($_GET['sort'] ?? 'date'); + if (!isset($allowedSort[$sort])) { + $sort = 'date'; + } + $dir = strtolower((string) ($_GET['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc'; + + $sql = "SELECT m.*, a.id AS animal_id, a.internal_code, a.name, a.sex, a.species, a.birth_date, a.chip_id, + d.cause_code death_cause_code,d.cause_details death_cause_details,d.euthanized death_euthanized,d.body_disposition death_disposition,d.cremation_date, + vet.name death_veterinarian,crem.name death_crematorium + FROM animal_movements m JOIN animals a ON a.id=m.animal_id + LEFT JOIN animal_deaths d ON d.animal_id=m.animal_id AND m.kind='exit' AND lower(m.lieu) IN ('décès','deces','décès de l''animal','deces de l''animal') + LEFT JOIN directory_contacts vet ON vet.id=d.veterinarian_contact_id + LEFT JOIN directory_contacts crem ON crem.id=d.crematorium_contact_id + WHERE a.deleted_at IS NULL AND a.archived_at IS NULL + AND m.created_at BETWEEN :ys AND :ye"; + $params = [':ys' => sprintf('%04d-01-01 00:00:00', $year), ':ye' => sprintf('%04d-12-31 23:59:59', $year)]; + if ($q !== '') { + $sql .= ' AND (a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q)'; + $params[':q'] = "%$q%"; + } + if ($species !== '') { + $sql .= ' AND lower(a.species)=:species'; + $params[':species'] = strtolower($species); + } + $sql .= ' ORDER BY ' . $allowedSort[$sort] . ' ' . strtoupper($dir); + $statement = $pdo->prepare($sql); + $statement->execute($params); + return [$statement->fetchAll(PDO::FETCH_ASSOC), $year, $q, $species, $sort, $dir]; + } + private static function adminOnly(): void + { + if (!PermissionService::can('administrative', 'view')) { + http_response_code(403); + echo h(t('admin.forbidden')); + exit(); + } + } +} diff --git a/app/Controllers/AdoptionsController.php b/app/Controllers/AdoptionsController.php new file mode 100644 index 0000000..6e4b05c --- /dev/null +++ b/app/Controllers/AdoptionsController.php @@ -0,0 +1,70 @@ + $animalId, + 'adopter_name' => $name, + 'adopter_phone' => trim($_POST['adopter_phone'] ?? ''), + 'adopter_email' => trim($_POST['adopter_email'] ?? ''), + 'adopter_address' => trim($_POST['adopter_address'] ?? ''), + 'adopter_postal_code' => trim($_POST['adopter_postal_code'] ?? ''), + 'adopter_city' => trim($_POST['adopter_city'] ?? ''), + 'adoption_date' => $_POST['adoption_date'] ?? date('Y-m-d'), + 'notes' => trim($_POST['notes'] ?? ''), + ]; + + $db = DB::pdo(); + $contactId = (int) ($_POST['adopter_contact_id'] ?? 0); + $contactData = [ + ':name' => $data['adopter_name'], + ':phone' => $data['adopter_phone'] ?: null, + ':email' => $data['adopter_email'] ?: null, + ':address' => $data['adopter_address'] ?: null, + ':postal' => $data['adopter_postal_code'] ?: null, + ':city' => $data['adopter_city'] ?: null, + ]; + if ($contactId > 0) { + $contactData[':id'] = $contactId; + $db->prepare( + "UPDATE directory_contacts SET name=:name,phone=:phone,email=:email,address=:address,postal_code=:postal,city=:city,updated_at=datetime('now') WHERE id=:id AND deleted_at IS NULL", + )->execute($contactData); + } else { + $db->prepare( + "INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city) VALUES('person',:name,:phone,:email,:address,:postal,:city)", + )->execute($contactData); + $contactId = (int) $db->lastInsertId(); + } + $db->prepare("INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:id,'adoptant')")->execute([ + ':id' => $contactId, + ]); + $data['adopter_contact_id'] = $contactId; + + try { + AdoptionService::adopt($data); + header('Location: /animal?id=' . $animalId); + exit(); + } catch (Throwable $e) { + error_log('Échec adoption animal #' . $animalId . ' : ' . $e->getMessage()); + http_response_code(500); + echo h(t('error.adoption_failed')); + } + } +} diff --git a/app/Controllers/AgendaController.php b/app/Controllers/AgendaController.php new file mode 100644 index 0000000..0c3d3fd --- /dev/null +++ b/app/Controllers/AgendaController.php @@ -0,0 +1,267 @@ +prepare('SELECT agenda_feed_token_hash,agenda_feed_created_at FROM users WHERE id=?'); + $feed->execute([(int) $user['id']]); + $feedState = $feed->fetch(PDO::FETCH_ASSOC) ?: []; + $token = (string) ($_SESSION['agenda_feed_token_once'] ?? ''); + unset($_SESSION['agenda_feed_token_once']); + $volunteers = $db + ->query( + "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $edit = (int) ($_GET['edit'] ?? 0); + render('agenda.php', [ + 'title' => t('agenda.title'), + 'cursor' => $cursor, + 'weekCursor' => $weekCursor, + 'viewMode' => $viewMode, + 'items' => $items, + 'upcoming' => $upcoming, + 'filters' => $filters, + 'filterSuffix' => self::filterSuffix($filters), + 'feedEnabled' => !empty($feedState['agenda_feed_token_hash']), + 'feedCreatedAt' => $feedState['agenda_feed_created_at'] ?? null, + 'feedUrl' => $token !== '' ? CalendarFeedService::absoluteUrl('/agenda/feed.ics?token=' . $token) : '', + 'editItem' => $edit ? AgendaService::find($edit) : null, + 'animals' => $db + ->query( + 'SELECT id,name FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL ORDER BY name COLLATE NOCASE', + ) + ->fetchAll(), + 'contacts' => $db + ->query('SELECT id,name FROM directory_contacts WHERE deleted_at IS NULL ORDER BY name COLLATE NOCASE') + ->fetchAll(), + 'volunteers' => $volunteers, + 'users' => $db + ->query( + "SELECT id,COALESCE(NULLIF(display_name,''),username) name FROM users WHERE active=1 ORDER BY name COLLATE NOCASE", + ) + ->fetchAll(), + 'status' => (string) ($_GET['status'] ?? ''), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function save(): void + { + try { + $id = (int) ($_POST['id'] ?? 0); + $saved = AgendaService::save($_POST, $id ?: null); + $conflicts = AgendaService::conflicts($saved); + AuditService::log( + $id ? 'agenda_updated' : 'agenda_created', + '/agenda/save', + $id ? 'Événement modifié' : 'Événement créé', + 'agenda', + $saved, + ['conflicts' => count($conflicts)], + ); + header('Location: /agenda?status=' . ($conflicts ? 'conflict' : 'saved')); + } catch (Throwable $e) { + header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function status(): void + { + try { + $id = (int) ($_POST['id'] ?? 0); + AgendaService::setStatus($id, (string) ($_POST['status'] ?? '')); + AuditService::log('agenda_status', '/agenda/status', 'État de l’agenda modifié', 'agenda', $id, [ + 'status' => $_POST['status'] ?? '', + ]); + header('Location: /agenda?status=updated'); + } catch (Throwable $e) { + header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function export(): void + { + $view = (string) ($_GET['view'] ?? 'month'); + if ($view === 'week') { + $date = self::date((string) ($_GET['date'] ?? '')) ?: new DateTimeImmutable('today'); + $items = AgendaService::week($date); + $name = 'agenda-semaine-' . $date->modify('monday this week')->format('Y-m-d') . '.ics'; + } else { + $month = (string) ($_GET['month'] ?? date('Y-m')); + $date = preg_match('/^\d{4}-\d{2}$/', $month) ? DateTimeImmutable::createFromFormat('!Y-m', $month) : false; + $date = $date ?: new DateTimeImmutable('first day of this month'); + $items = AgendaService::month($date); + $name = 'agenda-' . $date->format('Y-m') . '.ics'; + } + $items = self::filtered($items, self::filters(), (int) Auth::id()); + self::output( + CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')), + $name, + ); + } + public static function feed(): void + { + $user = CalendarFeedService::userForToken((string) ($_GET['token'] ?? '')); + if (!$user) { + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + echo t('agenda.feed_invalid'); + return; + } + $items = AgendaService::range(new DateTimeImmutable('-90 days'), new DateTimeImmutable('+365 days 23:59:59')); + self::output( + CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')), + 'globinours-agenda.ics', + false, + ); + } + public static function regenerateFeed(): void + { + self::feedPost(); + $_SESSION['agenda_feed_token_once'] = CalendarFeedService::generateToken((int) Auth::id()); + AuditService::log('agenda_feed_regenerated', '/agenda/feed/regenerate', 'Lien privé d’agenda régénéré'); + header('Location: /agenda?status=feed'); + exit(); + } + public static function revokeFeed(): void + { + self::feedPost(); + CalendarFeedService::revoke((int) Auth::id()); + AuditService::log('agenda_feed_revoked', '/agenda/feed/revoke', 'Lien privé d’agenda révoqué'); + header('Location: /agenda?status=feed-revoked'); + exit(); + } + private static function filters(): array + { + $types = [...AgendaService::TYPES, 'vaccine', 'deworming', 'treatment_start', 'treatment_end']; + $scope = ($_GET['scope'] ?? 'all') === 'mine' ? 'mine' : 'all'; + $type = in_array($t = (string) ($_GET['category'] ?? ''), $types, true) ? $t : ''; + return [ + 'scope' => $scope, + 'assigned_user_id' => max(0, (int) ($_GET['assigned_user_id'] ?? 0)), + 'volunteer_contact_id' => max(0, (int) ($_GET['volunteer_contact_id'] ?? 0)), + 'category' => $type, + 'show_medical' => (string) ($_GET['show_medical'] ?? '1') !== '0', + 'q' => mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100), + ]; + } + private static function filtered(array $items, array $f, int $userId): array + { + return array_values( + array_filter($items, static function ($row) use ($f, $userId): bool { + $source = (string) ($row['source_type'] ?? 'agenda'); + if ($f['scope'] === 'mine' && (int) ($row['assigned_user_id'] ?? 0) !== $userId) { + return false; + } + if ($f['assigned_user_id'] && (int) ($row['assigned_user_id'] ?? 0) !== $f['assigned_user_id']) { + return false; + } + if ( + $f['volunteer_contact_id'] && + !in_array( + $f['volunteer_contact_id'], + array_map('intval', array_filter(explode(',', (string) ($row['volunteer_ids'] ?? '')))), + true, + ) + ) { + return false; + } + if ($f['category'] !== '' && (string) $row['item_type'] !== $f['category']) { + return false; + } + if (!$f['show_medical'] && $source !== 'agenda') { + return false; + } + if ( + $f['q'] !== '' && + !str_contains( + mb_strtolower( + implode(' ', [ + (string) ($row['title'] ?? ''), + (string) ($row['animal_name'] ?? ''), + (string) ($row['location'] ?? ''), + (string) ($row['assigned_name'] ?? ''), + (string) ($row['volunteer_names'] ?? ''), + ]), + ), + mb_strtolower($f['q']), + ) + ) { + return false; + } + return true; + }), + ); + } + private static function filterSuffix(array $f): string + { + $query = http_build_query( + [ + 'scope' => $f['scope'], + 'assigned_user_id' => $f['assigned_user_id'] ?: null, + 'volunteer_contact_id' => $f['volunteer_contact_id'] ?: null, + 'category' => $f['category'] ?: null, + 'show_medical' => $f['show_medical'] ? '1' : '0', + 'q' => $f['q'] ?: null, + ], + '', + '&', + PHP_QUERY_RFC3986, + ); + return $query !== '' ? '&' . str_replace('&', '&', $query) : ''; + } + private static function feedPost(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !Auth::validCsrf($_POST['csrf'] ?? null)) { + http_response_code(419); + exit(); + } + if (!Auth::user() || !PermissionService::can('agenda')) { + http_response_code(403); + exit(); + } + } + private static function output(string $content, string $filename, bool $attachment = true): void + { + header('Content-Type: text/calendar; charset=utf-8'); + header('Content-Disposition: ' . ($attachment ? 'attachment' : 'inline') . '; filename="' . $filename . '"'); + header('Cache-Control: private, no-store'); + echo $content; + exit(); + } + private static function date(string $value): ?DateTimeImmutable + { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date && $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/app/Controllers/AnimalBrowseActions.php b/app/Controllers/AnimalBrowseActions.php new file mode 100644 index 0000000..251a7a4 --- /dev/null +++ b/app/Controllers/AnimalBrowseActions.php @@ -0,0 +1,1017 @@ +prepare('UPDATE users SET animal_list_view=:view,updated_at=datetime(\'now\') WHERE id=:id')->execute([ + ':view' => $viewMode, + ':id' => $user['id'], + ]); + } + + $allowed = [ + 'name' => 'a.name', + 'sex' => 'a.sex', + 'species' => 'a.species', + 'age' => 'a.birth_date', + 'status' => 'a.status', + 'compatibility_dogs' => 'a.compatibility_dogs', + 'compatibility_cats' => 'a.compatibility_cats', + 'compatibility_children' => 'a.compatibility_children', + 'house_trained' => 'a.house_trained', + 'updated_at' => 'a.updated_at', + 'weight' => 'wt.last_weight', + ]; + $orderBy = $allowed[$sort] ?? 'a.updated_at'; + + $where = ['a.deleted_at IS NULL', 'a.archived_at IS NULL']; + $params = []; + if (in_array($availability, ['available', 'not_available', 'unknown'], true)) { + $where[] = 'a.adoption_availability=:availability'; + $params[':availability'] = $availability; + } else { + $availability = ''; + } + if ($species !== '' && SpeciesService::exists($species, false)) { + $where[] = 'lower(trim(a.species))=:species'; + $params[':species'] = $species; + } else { + $species = ''; + } + + if ($q !== '') { + $where[] = '(a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q)'; + $params[':q'] = '%' . $q . '%'; + } + if ($status === 'current') { + $where[] = "lower(a.status) NOT IN ('adopte','adopté','decede','décédé','enfui','fugue')"; + } elseif ($status !== '') { + $where[] = 'a.status = :status'; + $params[':status'] = $status; + } + $qualityFilters = [ + 'birth_date' => [t('quality.filter.birth'), "a.birth_date IS NULL OR trim(a.birth_date)=''"], + 'sex' => [t('quality.filter.sex'), "a.sex IS NULL OR trim(a.sex)='' OR a.sex='U'"], + 'color' => [t('quality.filter.color'), "a.color IS NULL OR trim(a.color)=''"], + 'breed' => [t('quality.filter.breed'), "a.breed IS NULL OR trim(a.breed)=''"], + 'origin' => [ + t('quality.filter.origin'), + "(a.rescue_location_name IS NULL OR trim(a.rescue_location_name)='') AND (a.rescue_address IS NULL OR trim(a.rescue_address)='')", + ], + 'chip' => [ + t('quality.filter.chip'), + "(a.chip_id IS NULL OR trim(a.chip_id)='') AND (a.chip IS NULL OR trim(a.chip)='')", + ], + 'identification_registration' => [ + t('quality.filter.registration'), + "COALESCE(NULLIF(a.identification_registration_status,''),'unknown')='unknown'", + ], + 'adoption_availability' => [ + t('quality.filter.adoption'), + "COALESCE(NULLIF(a.adoption_availability,''),'unknown')='unknown'", + ], + 'sterilized' => [t('quality.filter.unsterilized'), "a.sterilization_status='no'"], + 'sterilization_unknown' => [ + t('quality.filter.sterilization'), + "COALESCE(NULLIF(a.sterilization_status,''),'unknown')='unknown'", + ], + ]; + if (isset($qualityFilters[$quality])) { + $where[] = '(' . $qualityFilters[$quality][1] . ')'; + } else { + $quality = ''; + } + + $sql = " + SELECT + a.*, + ms.last_med_at, + ms.ongoing_treatments, + ms.next_vaccine_due, + wt.last_weight, + wt.prev_weight, + p.filename AS primary_photo, + ( + SELECT dc.name + FROM animal_placements ap + JOIN directory_contacts dc ON dc.id=ap.contact_id AND dc.deleted_at IS NULL + WHERE ap.animal_id=a.id AND ap.event_type='foster' + ORDER BY date(ap.event_date) DESC,ap.id DESC + LIMIT 1 + ) AS foster_contact_name, + ( + SELECT rv.name + FROM vaccinations v + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + WHERE v.animal_id = a.id + AND v.due_date IS NOT NULL + AND date(v.due_date) >= date('now') + ORDER BY date(v.due_date) ASC, v.id DESC + LIMIT 1 + ) AS next_vaccine_name, + ( + SELECT group_concat(m.name, ', ') + FROM treatments t + JOIN ref_medications m ON m.id = t.medication_id + WHERE t.animal_id = a.id AND t.ongoing = 1 + ) AS ongoing_treatment_names, + ( + SELECT COALESCE( + NULLIF(trim(mn.diagnosis), ''), + NULLIF(trim(mn.reason), ''), + NULLIF(trim(mn.symptoms), '') + ) + FROM medical_notes mn + WHERE mn.animal_id = a.id + ORDER BY datetime(mn.noted_at) DESC, mn.id DESC + LIMIT 1 + ) AS latest_medical_point, + (SELECT d.administered_on FROM dewormings d WHERE d.animal_id=a.id ORDER BY date(d.administered_on) DESC,d.id DESC LIMIT 1) AS last_deworming_date, + (SELECT d.next_due_date FROM dewormings d WHERE d.animal_id=a.id ORDER BY date(d.administered_on) DESC,d.id DESC LIMIT 1) AS next_deworming_due + FROM animals a + LEFT JOIN v_animals_med_summary ms ON ms.animal_id = a.id + LEFT JOIN v_animals_weight_trend wt ON wt.animal_id = a.id + LEFT JOIN animal_photos p + ON p.animal_id = a.id + AND p.is_primary = 1 + "; + + $sql .= ' WHERE ' . implode(' AND ', $where); + + if ($sort === 'age') { + $birthDirection = $dir === 'ASC' ? 'DESC' : 'ASC'; + $sql .= " ORDER BY (a.birth_date IS NULL OR trim(a.birth_date) = '') ASC, date(a.birth_date) $birthDirection, a.id DESC LIMIT 500"; + } elseif ( + in_array( + $sort, + ['compatibility_dogs', 'compatibility_cats', 'compatibility_children', 'house_trained'], + true, + ) + ) { + $profileColumn = $allowed[$sort]; + $profileDirection = $dir === 'ASC' ? 'ASC' : 'DESC'; + $sql .= " ORDER BY CASE WHEN COALESCE($profileColumn,'unknown')='unknown' THEN 1 ELSE 0 END ASC, CASE WHEN $profileColumn='yes' THEN 0 ELSE 1 END $profileDirection, a.name COLLATE NOCASE ASC, a.id DESC LIMIT 500"; + } else { + $sql .= " ORDER BY $orderBy $dir, a.id DESC LIMIT 500"; + } + + $stmt = $db->prepare($sql); + $stmt->execute($params); + $animals = $stmt->fetchAll(); + + // Regroupe visuellement les chatons sous un parent présent dans le résultat. + // La mère est prioritaire quand les deux parents sont affichés afin de ne jamais dupliquer une fiche. + if ($animals) { + $rowsById = []; + foreach ($animals as $row) { + $rowsById[(int) $row['id']] = $row; + } + $relations = $db + ->query( + ' + SELECT lk.litter_id, lk.animal_id, lk.position, l.mother_id, l.father_id, l.birth_date + FROM litter_kittens lk + JOIN litters l ON l.id = lk.litter_id + ORDER BY l.id, lk.position + ', + ) + ->fetchAll(PDO::FETCH_ASSOC); + $childrenByParent = []; + $groupedChildren = []; + $orphanGroups = []; + $orphanChildren = []; + $orphanGroupByChild = []; + foreach ($relations as $relation) { + $childId = (int) $relation['animal_id']; + if (!isset($rowsById[$childId])) { + continue; + } + $motherId = (int) ($relation['mother_id'] ?? 0); + $fatherId = (int) ($relation['father_id'] ?? 0); + $parentId = isset($rowsById[$motherId]) ? $motherId : (isset($rowsById[$fatherId]) ? $fatherId : 0); + if (!$parentId) { + $litterId = (int) $relation['litter_id']; + $orphanGroups[$litterId]['birth_date'] = $relation['birth_date']; + $orphanGroups[$litterId]['label'] = + !$motherId && !$fatherId ? 'Parents inconnus' : 'Parents indisponibles'; + $orphanGroups[$litterId]['children'][] = $childId; + $orphanChildren[$childId] = true; + $orphanGroupByChild[$childId] = $litterId; + continue; + } + if (!$parentId || $parentId === $childId) { + continue; + } + $childrenByParent[$parentId][] = $childId; + $groupedChildren[$childId] = true; + } + $ordered = []; + $emitted = []; + $emit = function (int $id, string $branch = '') use ( + &$emit, + &$ordered, + &$emitted, + &$rowsById, + &$childrenByParent, + ): void { + if (isset($emitted[$id]) || !isset($rowsById[$id])) { + return; + } + $emitted[$id] = true; + $row = $rowsById[$id]; + if ($branch !== '') { + $row['_litter_branch'] = $branch; + } + $ordered[] = $row; + $children = array_values(array_unique($childrenByParent[$id] ?? [])); + foreach ($children as $index => $childId) { + $emit($childId, $index === count($children) - 1 ? '└' : '├'); + } + }; + $emittedOrphanGroups = []; + $emitOrphanGroup = function (int $litterId) use ( + &$emittedOrphanGroups, + &$orphanGroups, + &$rowsById, + &$emitted, + &$emit, + ): void { + if (isset($emittedOrphanGroups[$litterId]) || !isset($orphanGroups[$litterId])) { + return; + } + $emittedOrphanGroups[$litterId] = true; + $group = $orphanGroups[$litterId]; + $children = array_values(array_unique($group['children'] ?? [])); + $visibleChildren = array_values( + array_filter( + $children, + fn(int $childId): bool => isset($rowsById[$childId]) && !isset($emitted[$childId]), + ), + ); + $dateLabel = t('common.unknown_date'); + if (!empty($group['birth_date'])) { + $date = DateTimeImmutable::createFromFormat('Y-m-d', (string) $group['birth_date']); + if ($date) { + $dateLabel = $date->format('d/m/Y'); + } + } + foreach ($visibleChildren as $index => $childId) { + if (!isset($rowsById[$childId]) || isset($emitted[$childId])) { + continue; + } + $rowsById[$childId]['_litter_branch'] = $index === count($visibleChildren) - 1 ? '└' : '├'; + if ($index === 0) { + $rowsById[$childId]['_litter_group'] = [ + 'label' => (string) ($group['label'] ?? t('litter.unknown_parent')), + 'details' => t('litter.group_details', [ + 'date' => $dateLabel, + 'count' => count($visibleChildren), + ]), + ]; + } + $emit($childId, $rowsById[$childId]['_litter_branch']); + } + }; + foreach ($animals as $row) { + $rowId = (int) $row['id']; + if (isset($orphanGroupByChild[$rowId])) { + $emitOrphanGroup((int) $orphanGroupByChild[$rowId]); + } elseif (!isset($groupedChildren[$rowId])) { + $emit($rowId); + } + } + foreach ($animals as $row) { + $emit((int) $row['id']); + } + $animals = $ordered; + + // Les groupes inséparables sont prioritaires sur la présentation par portée. + $bondedRows = $db + ->query( + ' + SELECT bg.id AS group_id, bg.name AS group_name, bg.notes AS group_notes, + bgm.animal_id, bgm.position + FROM bonded_groups bg + JOIN bonded_group_members bgm ON bgm.group_id = bg.id + WHERE bg.active = 1 + ORDER BY bg.id, bgm.position + ', + ) + ->fetchAll(PDO::FETCH_ASSOC); + $bondedByAnimal = []; + $bondedMeta = []; + foreach ($bondedRows as $bondedRow) { + $animalId = (int) $bondedRow['animal_id']; + if (!isset($rowsById[$animalId])) { + continue; + } + $groupId = (int) $bondedRow['group_id']; + $bondedByAnimal[$animalId] = $groupId; + $bondedMeta[$groupId] = ['name' => $bondedRow['group_name'], 'notes' => $bondedRow['group_notes']]; + } + if ($bondedByAnimal) { + $orderedById = []; + foreach ($animals as $row) { + $orderedById[(int) $row['id']] = $row; + } + $bondedOutput = []; + $emittedGroups = []; + foreach ($animals as $row) { + $animalId = (int) $row['id']; + $groupId = $bondedByAnimal[$animalId] ?? 0; + if (!$groupId) { + $bondedOutput[] = $row; + continue; + } + if (isset($emittedGroups[$groupId])) { + continue; + } + $emittedGroups[$groupId] = true; + $memberIds = []; + foreach ($animals as $candidate) { + $candidateId = (int) $candidate['id']; + if (($bondedByAnimal[$candidateId] ?? 0) === $groupId) { + $memberIds[] = $candidateId; + } + } + foreach ($memberIds as $index => $memberId) { + $member = $orderedById[$memberId]; + unset($member['_litter_branch'], $member['_litter_group']); + $member['_bonded_branch'] = $index === count($memberIds) - 1 ? '└' : '├'; + if ($index === 0) { + $name = trim((string) ($bondedMeta[$groupId]['name'] ?? '')); + $member['_bonded_group'] = [ + 'label' => $name !== '' ? $name : t('bonded.default_name'), + 'details' => t('bonded.group_details', [ + 'count' => count($memberIds), + 'state' => + count($memberIds) > 1 ? t('bonded.adopt_together') : t('bonded.incomplete'), + ]), + 'notes' => trim((string) ($bondedMeta[$groupId]['notes'] ?? '')), + ]; + } + $bondedOutput[] = $member; + } + } + $animals = $bondedOutput; + } + } + + $statuses = [ + 'current' => t('animals.current_care'), + '' => t('animals.all_records'), + 'refuge' => t('status.refuge'), + 'fa' => t('status.fa'), + 'fa_permanente' => t('status.fa_permanente'), + 'chat_libre' => t('status.chat_libre'), + 'quarantaine' => t('status.quarantaine'), + 'soin' => t('status.soin'), + 'isolation' => t('status.isolation'), + 'hospitalise' => t('status.hospitalise'), + 'adopte' => t('status.adopte'), + 'decede' => t('status.decede'), + 'enfui' => t('status.enfui'), + ]; + + render('animals_list.php', [ + 'title' => t('animals.title'), + 'animals' => $animals, + 'q' => $q, + 'status' => $status, + 'statuses' => $statuses, + 'sort' => $sort, + 'dir' => $dir, + 'quality' => $quality, + 'qualityLabel' => $quality !== '' ? $qualityFilters[$quality][0] : '', + 'availability' => $availability, + 'viewMode' => $viewMode, + 'species' => $species, + 'speciesOptions' => SpeciesService::all(false), + ]); + } + + public static function show(): void + { + $db = DB::pdo(); + $id = (int) ($_GET['id'] ?? 0); + + if ($id <= 0) { + http_response_code(400); + echo h(t('error.missing_id')); + return; + } + + // Une fiche archivée reste consultable : seule la corbeille masque la fiche normale. + $stmt = $db->prepare('SELECT * FROM animals WHERE id = :id AND deleted_at IS NULL LIMIT 1'); + $stmt->execute([':id' => $id]); + $animal = $stmt->fetch(); + + if (!$animal) { + http_response_code(404); + echo h(t('error.animal_not_found')); + return; + } + if (!empty($animal['intake_owner_care_home'])) { + $animal['intake_reason'] = 'owner_care_home'; + } + $depositor = null; + if (!empty($animal['depositor_contact_id'])) { + $s = $db->prepare( + 'SELECT id,name,phone,email,city FROM directory_contacts WHERE id=:id AND deleted_at IS NULL', + ); + $s->execute([':id' => $animal['depositor_contact_id']]); + $depositor = $s->fetch(PDO::FETCH_ASSOC) ?: null; + } + $freeCatCaretaker = null; + if (!empty($animal['free_cat_caretaker_contact_id'])) { + $s = $db->prepare( + 'SELECT id,name,phone,email,city FROM directory_contacts WHERE id=:id AND deleted_at IS NULL', + ); + $s->execute([':id' => $animal['free_cat_caretaker_contact_id']]); + $freeCatCaretaker = $s->fetch(PDO::FETCH_ASSOC) ?: null; + } + $sterilizationContacts = []; + foreach ( + [ + 'vet' => $animal['sterilization_vet_contact_id'] ?? null, + 'clinic' => $animal['sterilization_clinic_contact_id'] ?? null, + ] + as $key => $contactId + ) { + if ($contactId) { + $s = $db->prepare('SELECT id,name FROM directory_contacts WHERE id=:id AND deleted_at IS NULL'); + $s->execute([':id' => $contactId]); + $sterilizationContacts[$key] = $s->fetch(PDO::FETCH_ASSOC) ?: null; + } + } + + // Adoptions + $stmt = $db->prepare(' + SELECT adopter_name, adopter_address, adopter_postal_code, adopter_city, adopter_country, adopter_phone, adopter_email, adoption_date + FROM adoptions + WHERE animal_id = :id + ORDER BY id DESC + LIMIT 1 + '); + + $stmt->execute([':id' => $id]); + $adoption = $stmt->fetch(PDO::FETCH_ASSOC); + $placementStmt = $db->prepare( + 'SELECT ap.*,dc.name contact_name,dc.city contact_city,COALESCE(u.display_name,u.username) actor_name FROM animal_placements ap LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id LEFT JOIN users u ON u.id=ap.created_by WHERE ap.animal_id=:id ORDER BY date(ap.event_date) DESC,ap.id DESC', + ); + $placementStmt->execute([':id' => $id]); + $placements = $placementStmt->fetchAll(PDO::FETCH_ASSOC); + $placementContacts = $db + ->query( + "SELECT DISTINCT dc.id,dc.name,dc.city FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND r.role IN ('fa','adoptant') ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + + // Notes médicales (timeline) + $stmt = $db->prepare(' + SELECT mn.*, COALESCE(dc.name, c.name) AS clinic_name + FROM medical_notes mn + LEFT JOIN clinics c ON c.id = mn.clinic_id + LEFT JOIN directory_contacts dc ON dc.id = mn.clinic_contact_id + WHERE mn.animal_id = :id + ORDER BY mn.noted_at DESC + LIMIT 50 + '); + $stmt->execute([':id' => $id]); + $medical_notes = $stmt->fetchAll(); + + $prescriptionStmt = $db->prepare( + 'SELECT p.*,vet.name veterinarian_name,clinic.name clinic_name,COALESCE(u.display_name,u.username) actor_name FROM medical_prescriptions p LEFT JOIN directory_contacts vet ON vet.id=p.veterinarian_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=p.clinic_contact_id LEFT JOIN users u ON u.id=p.created_by WHERE p.animal_id=:id ORDER BY date(p.prescribed_on) DESC,p.id DESC', + ); + $prescriptionStmt->execute([':id' => $id]); + $prescriptions = $prescriptionStmt->fetchAll(PDO::FETCH_ASSOC); + $prescriptionTreatmentStmt = $db->prepare( + 'SELECT pt.prescription_id,t.id,m.name medication_name FROM prescription_treatments pt JOIN treatments t ON t.id=pt.treatment_id LEFT JOIN ref_medications m ON m.id=t.medication_id WHERE t.animal_id=:id ORDER BY m.name COLLATE NOCASE', + ); + $prescriptionTreatmentStmt->execute([':id' => $id]); + $prescriptionTreatments = []; + foreach ($prescriptionTreatmentStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $prescriptionTreatments[(int) $row['prescription_id']][] = $row; + } + + $labStmt = $db->prepare( + 'SELECT r.*,vet.name veterinarian_name,clinic.name clinic_name,COALESCE(u.display_name,u.username) actor_name FROM lab_reports r LEFT JOIN directory_contacts vet ON vet.id=r.veterinarian_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=r.clinic_contact_id LEFT JOIN users u ON u.id=r.created_by WHERE r.animal_id=:id ORDER BY date(r.sampled_on) DESC,r.id DESC', + ); + $labStmt->execute([':id' => $id]); + $labReports = $labStmt->fetchAll(PDO::FETCH_ASSOC); + $labResultStmt = $db->prepare( + 'SELECT lr.*,r.sampled_on FROM lab_results lr JOIN lab_reports r ON r.id=lr.report_id WHERE r.animal_id=:id ORDER BY date(r.sampled_on),lr.position,lr.id', + ); + $labResultStmt->execute([':id' => $id]); + $labResultsByReport = []; + $labSeries = []; + foreach ($labResultStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $labResultsByReport[(int) $row['report_id']][] = $row; + $seriesKey = + mb_strtolower(trim((string) $row['parameter_name'])) . + '|' . + mb_strtolower(trim((string) ($row['unit'] ?? ''))); + if (!isset($labSeries[$seriesKey])) { + $labSeries[$seriesKey] = [ + 'name' => $row['parameter_name'], + 'unit' => $row['unit'] ?? '', + 'points' => [], + ]; + } + $labSeries[$seriesKey]['points'][] = [ + 'date' => $row['sampled_on'], + 'value' => (float) $row['value'], + 'min' => $row['reference_min'] !== null ? (float) $row['reference_min'] : null, + 'max' => $row['reference_max'] !== null ? (float) $row['reference_max'] : null, + ]; + } + + // Traitements en cours + $stmt = $db->prepare(' + SELECT t.*, m.name AS medication_name, m.molecule, m.form, COALESCE(dc.name, c.name) AS clinic_name + FROM treatments t + JOIN ref_medications m ON m.id = t.medication_id + LEFT JOIN clinics c ON c.id = t.clinic_id + LEFT JOIN directory_contacts dc ON dc.id = t.clinic_contact_id + WHERE t.animal_id = :id AND t.ongoing = 1 + ORDER BY t.start_date DESC, t.id DESC + '); + $stmt->execute([':id' => $id]); + $treatments = $stmt->fetchAll(); + + // Vaccins + $stmt = $db->prepare(' + SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name,COALESCE(v.administered_by_name,au.display_name,au.username) AS administrator_name + FROM vaccinations v + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + LEFT JOIN clinics c ON c.id = v.clinic_id + LEFT JOIN directory_contacts dc ON dc.id = v.clinic_contact_id + LEFT JOIN users au ON au.id=v.administered_by_user_id + WHERE v.animal_id = :id + ORDER BY v.done_date DESC, v.id DESC + LIMIT 50 + '); + $stmt->execute([':id' => $id]); + $vaccinations = $stmt->fetchAll(); + + $stmt = $db->prepare( + 'SELECT d.*,m.name medication_name,COALESCE(u.display_name,u.username) actor_name FROM dewormings d LEFT JOIN ref_dewormers m ON m.id=d.dewormer_id LEFT JOIN users u ON u.id=d.created_by WHERE d.animal_id=:id ORDER BY date(d.administered_on) DESC,d.id DESC', + ); + $stmt->execute([':id' => $id]); + $dewormings = $stmt->fetchAll(PDO::FETCH_ASSOC); + $lastDeworming = $dewormings[0] ?? null; + + $medications = $db->query('SELECT id, name FROM ref_medications ORDER BY name COLLATE NOCASE, id')->fetchAll(); + $dewormers = $db + ->query('SELECT id,name FROM ref_dewormers WHERE active=1 ORDER BY name COLLATE NOCASE,id') + ->fetchAll(PDO::FETCH_ASSOC); + $expenseTariffs = PricingService::tariffs(true); + $expenseStmt = $db->prepare( + 'SELECT ae.*,dc.name clinic_name,COALESCE(u.display_name,u.username) actor_name FROM animal_expenses ae LEFT JOIN directory_contacts dc ON dc.id=ae.clinic_contact_id LEFT JOIN users u ON u.id=ae.created_by WHERE ae.animal_id=:id ORDER BY date(ae.occurred_on) DESC,ae.id DESC', + ); + $expenseStmt->execute([':id' => $id]); + $expenses = $expenseStmt->fetchAll(PDO::FETCH_ASSOC); + $expenseTotal = array_sum(array_column($expenses, 'total_cents')); + $surgeryStmt = $db->prepare( + 'SELECT s.*,c.name clinic_name,v.name veterinarian_name FROM animal_surgeries s LEFT JOIN directory_contacts c ON c.id=s.clinic_contact_id LEFT JOIN directory_contacts v ON v.id=s.veterinarian_contact_id WHERE s.animal_id=:id ORDER BY date(s.surgery_date) DESC,s.id DESC', + ); + $surgeryStmt->execute([':id' => $id]); + $surgeries = $surgeryStmt->fetchAll(PDO::FETCH_ASSOC); + $ref_med_administration = $db + ->query('SELECT id, name, accro FROM ref_med_administration ORDER BY id') + ->fetchAll(); + $vaccines = $db->query('SELECT id, name, code FROM ref_vaccines ORDER BY id')->fetchAll(); + $inventoryBatches = $db + ->query( + 'SELECT b.id,b.batch_number,b.expires_on,b.quantity,p.name,p.unit,p.category,p.reference_id FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.quantity>0 AND p.active=1 ORDER BY p.category,p.name COLLATE NOCASE,date(b.expires_on)', + ) + ->fetchAll(PDO::FETCH_ASSOC); + $activeUsers = $db + ->query( + "SELECT id,COALESCE(NULLIF(display_name,''),username) name FROM users WHERE active=1 ORDER BY name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $clinics = $db->query('SELECT id, name FROM clinics ORDER BY name')->fetchAll(); + $directoryClinics = $db + ->query( + " + SELECT dc.id, dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='cabinet' + WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE + ", + ) + ->fetchAll(); + $veterinarians = $db + ->query( + " + SELECT dc.id, dc.name, dc.organization_id FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='veterinaire' + WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE + ", + ) + ->fetchAll(); + $adopters = $db + ->query( + " + SELECT dc.* FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='adoptant' + WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE + ", + ) + ->fetchAll(); + + $deathStmt = $db->prepare( + 'SELECT ad.*,vet.name veterinarian_name,crem.name crematorium_name FROM animal_deaths ad LEFT JOIN directory_contacts vet ON vet.id=ad.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=ad.crematorium_contact_id WHERE ad.animal_id=:id', + ); + $deathStmt->execute([':id' => $id]); + $death = $deathStmt->fetch(PDO::FETCH_ASSOC) ?: null; + + // Poids + $weightsStmt = $db->prepare(" + SELECT measured_at, value + FROM measurements + WHERE animal_id = :id AND type = 'weight' + ORDER BY datetime(measured_at) ASC + "); + $weightsStmt->execute([':id' => $id]); + $weights = $weightsStmt->fetchAll(); + + $stmt = $db->prepare(" + SELECT value + FROM measurements + WHERE animal_id = :id AND type='weight' + ORDER BY id DESC + LIMIT 1 + "); + $stmt->execute([':id' => $id]); + $lastWeight = $stmt->fetchColumn(); + + $stmt = $db->prepare(" + SELECT value + FROM measurements + WHERE animal_id = :id AND type='weight' + AND value != ( + SELECT value FROM measurements + WHERE animal_id = :id AND type='weight' + ORDER BY id DESC LIMIT 1 + ) + ORDER BY id DESC + LIMIT 1 + "); + $stmt->execute([':id' => $id]); + $prevWeight = $stmt->fetchColumn(); + + // PHOTOS : on récupère TOUT (public + privé) + $stmt = $db->prepare(' + SELECT id, filename, is_primary, is_public, caption, care_round_id, created_at + FROM animal_photos + WHERE animal_id = :id + ORDER BY is_public DESC, is_primary DESC, id DESC + '); + $stmt->execute([':id' => $id]); + $photos = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // PHOTO PRINCIPALE : seulement une publique + $stmt = $db->prepare(' + SELECT filename + FROM animal_photos + WHERE animal_id = :id AND is_primary = 1 AND is_public = 1 + ORDER BY id DESC + LIMIT 1 + '); + $stmt->execute([':id' => $id]); + $primary_photo = $stmt->fetchColumn() ?: null; + + // Historique + $h = $db->prepare(' + SELECT ah.type,ah.label,ah.details,ah.created_at,COALESCE(u.display_name,u.username) actor_name + FROM animal_history ah LEFT JOIN users u ON u.id=ah.user_id + WHERE ah.animal_id = :id + ORDER BY ah.created_at DESC + '); + $h->execute([':id' => $id]); + $history = $h->fetchAll(PDO::FETCH_ASSOC); + + $locationStmt = $db->prepare('SELECT alh.*,COALESCE(u.display_name,u.username) actor_name + FROM animal_location_history alh LEFT JOIN users u ON u.id=alh.user_id + WHERE alh.animal_id=:id ORDER BY datetime(alh.moved_at) DESC,alh.id DESC'); + $locationStmt->execute([':id' => $id]); + $locationHistory = $locationStmt->fetchAll(PDO::FETCH_ASSOC); + + $conditionsStmt = $db->prepare(" + SELECT id, name, status, diagnosed_at, resolved_at, notes + FROM animal_health_conditions + WHERE animal_id = :id + ORDER BY status = 'active' DESC, datetime(COALESCE(diagnosed_at, created_at)) DESC, id DESC + "); + $conditionsStmt->execute([':id' => $id]); + $healthConditions = $conditionsStmt->fetchAll(PDO::FETCH_ASSOC); + + // --- ACTIVITY (timeline unifiée) --- + $activityStmt = $db->prepare(" + SELECT kind, label, details, at FROM ( + -- Historique interne + SELECT + CASE WHEN type='care_round' THEN 'care_round' WHEN type='death' THEN 'death' ELSE 'history' END AS kind, + COALESCE(label,'') AS label, + COALESCE(details,'') || CASE WHEN user_id IS NOT NULL THEN '\n\nPar : ' || COALESCE((SELECT display_name FROM users WHERE id=animal_history.user_id),(SELECT username FROM users WHERE id=animal_history.user_id),'Compte inconnu') ELSE '' END AS details, + datetime(created_at) AS at + FROM animal_history + WHERE animal_id = :id + + UNION ALL + + -- Notes médicales + SELECT + 'medical' AS kind, + CASE + WHEN COALESCE(reason,'') != '' THEN 'Note médicale : ' || reason + ELSE 'Note médicale' + END AS label, + TRIM( + COALESCE(symptoms,'') || + CASE WHEN COALESCE(symptoms,'')!='' AND COALESCE(diagnosis,'')!='' THEN '\n\n' ELSE '' END || + CASE WHEN COALESCE(diagnosis,'')!='' THEN 'Diagnostic : ' || diagnosis ELSE '' END || + CASE WHEN COALESCE(plan,'')!='' THEN '\n\nPlan : ' || plan ELSE '' END + ) AS details, + datetime(noted_at) AS at + FROM medical_notes + WHERE animal_id = :id + + UNION ALL + + -- Vaccins + SELECT + 'vaccine' AS kind, + 'Vaccin : ' || COALESCE(rv.name,'(inconnu)') AS label, + TRIM( + 'Fait le : ' || COALESCE(v.done_date,'?') || + CASE WHEN v.due_date IS NOT NULL THEN '\nRappel : ' || v.due_date ELSE '' END || + CASE WHEN COALESCE(v.lot,'')!='' THEN '\nLot : ' || v.lot ELSE '' END || + CASE WHEN COALESCE(v.manufacturer,'')!='' THEN '\nFabricant : ' || v.manufacturer ELSE '' END || + CASE WHEN COALESCE(v.batch_expires_on,'')!='' THEN '\nExpiration du lot : ' || v.batch_expires_on ELSE '' END || + CASE WHEN COALESCE(v.administered_by_name,'')!='' THEN '\nAdministré par : ' || v.administered_by_name WHEN v.administered_by_user_id IS NOT NULL THEN '\nAdministré par : ' || COALESCE((SELECT display_name FROM users WHERE id=v.administered_by_user_id),(SELECT username FROM users WHERE id=v.administered_by_user_id),'Compte inconnu') ELSE '' END || + CASE WHEN COALESCE(v.notes,'')!='' THEN '\nNotes : ' || v.notes ELSE '' END + ) AS details, + datetime(v.done_date || ' 12:00:00') AS at + FROM vaccinations v + LEFT JOIN ref_vaccines rv ON rv.id = v.vaccine_id + WHERE v.animal_id = :id + + UNION ALL + + -- Traitements (création/MAJ) + SELECT + 'treatment' AS kind, + 'Traitement : ' || COALESCE(m.name,'(médicament)') AS label, + TRIM( + 'Dose : ' || COALESCE(t.dose_text,'?') || + CASE WHEN COALESCE(t.route,'')!='' THEN '\nVoie : ' || t.route ELSE '' END || + CASE WHEN COALESCE(t.start_date,'')!='' THEN '\nDébut : ' || t.start_date ELSE '' END || + CASE WHEN COALESCE(t.end_date,'')!='' THEN '\nFin : ' || t.end_date ELSE '' END || + CASE WHEN t.ongoing=1 THEN '\nÉtat : en cours' ELSE '\nÉtat : terminé' END || + CASE WHEN COALESCE(t.notes,'')!='' THEN '\nNotes : ' || t.notes ELSE '' END + ) AS details, + datetime(COALESCE(t.start_date, datetime('now')) || ' 12:00:00') AS at + FROM treatments t + LEFT JOIN ref_medications m ON m.id = t.medication_id + WHERE t.animal_id = :id + + UNION ALL + + -- Poids (measurements) + SELECT + 'weight' AS kind, + 'Poids : ' || printf('%.2f', value) || ' ' || COALESCE(unit,'kg') AS label, + COALESCE(notes,'') AS details, + datetime(measured_at) AS at + FROM measurements + WHERE animal_id = :id AND type='weight' + ) + ORDER BY at DESC + LIMIT 200 + "); + $activityStmt->execute([':id' => $id]); + $activity = $activityStmt->fetchAll(PDO::FETCH_ASSOC); + + // --- i-CAD (cache local) --- + require_once __DIR__ . '/../Services/IcadService.php'; // adapte si ton chemin diffère + $icadSvc = new IcadService($db); + + $icadCached = $icadSvc->getCachedByAnimal($id); + $icad = [ + 'cached' => $icadCached, + 'stale' => $icadSvc->isStale($icadCached, 86400), + ]; + + $littersStmt = $db->prepare(' + SELECT l.*, mother.name AS mother_name, father.name AS father_name + FROM litters l + LEFT JOIN animals mother ON mother.id = l.mother_id + LEFT JOIN animals father ON father.id = l.father_id + WHERE l.mother_id = :id OR l.father_id = :id + OR EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.litter_id = l.id AND lk.animal_id = :id) + ORDER BY COALESCE(l.birth_date, l.created_at) DESC, l.id DESC + '); + $littersStmt->execute([':id' => $id]); + $litters = $littersStmt->fetchAll(PDO::FETCH_ASSOC); + $kittenStmt = $db->prepare(' + SELECT a.id, a.name, a.sex, a.color, a.status, a.archived_at + FROM litter_kittens lk JOIN animals a ON a.id = lk.animal_id + WHERE lk.litter_id = :litter_id AND a.deleted_at IS NULL + ORDER BY lk.position, a.id + '); + foreach ($litters as &$litter) { + $kittenStmt->execute([':litter_id' => $litter['id']]); + $litter['kittens'] = $kittenStmt->fetchAll(PDO::FETCH_ASSOC); + } + unset($litter); + + $bondedStmt = $db->prepare(' + SELECT bg.id, bg.name, bg.notes + FROM bonded_groups bg + JOIN bonded_group_members bgm ON bgm.group_id = bg.id + WHERE bg.active = 1 AND bgm.animal_id = :id + LIMIT 1 + '); + $bondedStmt->execute([':id' => $id]); + $bondedGroup = $bondedStmt->fetch(PDO::FETCH_ASSOC) ?: null; + $bondedMembers = []; + if ($bondedGroup) { + $stmt = $db->prepare(' + SELECT a.id, a.name, a.color, a.sex, a.status + FROM bonded_group_members bgm JOIN animals a ON a.id = bgm.animal_id + WHERE bgm.group_id = :group_id AND a.deleted_at IS NULL AND a.archived_at IS NULL + ORDER BY bgm.position, a.name COLLATE NOCASE + '); + $stmt->execute([':group_id' => $bondedGroup['id']]); + $bondedMembers = $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + render('animal_show.php', [ + 'title' => 'Fiche de ' . $animal['name'], + 'pageDescription' => 'Dossier et suivi de ' . $animal['name'] . ' dans Globinours.', + 'animal' => $animal, + 'depositor' => $depositor, + 'intakeTypes' => self::intakeTypes(), + 'intakeReasons' => self::intakeReasons(), + 'freeCatCaretaker' => $freeCatCaretaker, + 'sterilizationContacts' => $sterilizationContacts, + 'medical_notes' => $medical_notes, + 'prescriptions' => $prescriptions, + 'prescriptionTreatments' => $prescriptionTreatments, + 'labReports' => $labReports, + 'labResultsByReport' => $labResultsByReport, + 'labSeries' => $labSeries, + 'labReferenceSets' => LabReferenceService::forAnimal((string) ($animal['species'] ?? '')), + 'treatments' => $treatments, + 'vaccinations' => $vaccinations, + 'dewormings' => $dewormings, + 'lastDeworming' => $lastDeworming, + 'medications' => $medications, + 'dewormers' => $dewormers, + 'expenseTariffs' => $expenseTariffs, + 'expenses' => $expenses, + 'expenseTotal' => $expenseTotal, + 'surgeries' => $surgeries, + 'ref_med_administration' => $ref_med_administration, + 'vaccines' => $vaccines, + 'inventoryBatches' => $inventoryBatches, + 'activeUsers' => $activeUsers, + 'clinics' => $clinics, + 'directoryClinics' => $directoryClinics, + 'veterinarians' => $veterinarians, + 'adopters' => $adopters, + 'death' => $death, + 'weights' => $weights, + 'lastWeight' => $lastWeight, + 'prevWeight' => $prevWeight, + 'photos' => $photos, + 'primary_photo' => $primary_photo, + 'history' => $history, + 'locationHistory' => $locationHistory, + 'healthConditions' => $healthConditions, + 'activity' => $activity, + 'adoption' => $adoption, + 'placements' => $placements, + 'placementContacts' => $placementContacts, + 'litters' => $litters, + 'bondedGroup' => $bondedGroup, + 'bondedMembers' => $bondedMembers, + + 'icad' => $icad, + ]); + } + + public static function inactiveList(string $mode): void + { + if (!in_array($mode, ['archived', 'trash'], true)) { + http_response_code(404); + return; + } + $db = DB::pdo(); + $column = $mode === 'archived' ? 'archived_at' : 'deleted_at'; + $q = trim((string) ($_GET['q'] ?? '')); + $status = trim((string) ($_GET['status'] ?? '')); + $sort = (string) ($_GET['sort'] ?? 'removed_at'); + $dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC'; + $allowed = [ + 'name' => 'a.name COLLATE NOCASE', + 'sex' => 'a.sex', + 'age' => 'a.birth_date', + 'status' => 'a.status', + 'compatibility_dogs' => 'a.compatibility_dogs', + 'compatibility_cats' => 'a.compatibility_cats', + 'house_trained' => 'a.house_trained', + 'weight' => 'wt.last_weight', + 'rescue_location_name' => + "COALESCE(NULLIF(TRIM(a.rescue_location_name),''),a.rescue_address) COLLATE NOCASE", + 'removed_at' => 'a.' . $column, + ]; + $where = ["a.$column IS NOT NULL"]; + $params = []; + if ($q !== '') { + $where[] = + '(a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q OR a.rescue_location_name LIKE :q)'; + $params[':q'] = '%' . $q . '%'; + } + if ($status !== '') { + $where[] = 'a.status=:status'; + $params[':status'] = $status; + } + $orderBy = $allowed[$sort] ?? $allowed['removed_at']; + $ageDirection = $dir === 'ASC' ? 'DESC' : 'ASC'; + $orderSql = + $sort === 'age' + ? "(a.birth_date IS NULL OR trim(a.birth_date)='') ASC, date(a.birth_date) $ageDirection, a.id DESC" + : "$orderBy $dir, a.id DESC"; + $stmt = $db->prepare( + ' + SELECT a.*, p.filename AS primary_photo, wt.last_weight, wt.prev_weight + FROM animals a + LEFT JOIN animal_photos p ON p.animal_id = a.id AND p.is_primary = 1 + LEFT JOIN v_animals_weight_trend wt ON wt.animal_id=a.id + WHERE ' . + implode(' AND ', $where) . + " + ORDER BY $orderSql + LIMIT 500 + ", + ); + $stmt->execute($params); + $statuses = ['' => t('animals.all_statuses')]; + foreach ( + [ + 'refuge', + 'fa', + 'fa_permanente', + 'chat_libre', + 'quarantaine', + 'soin', + 'isolation', + 'hospitalise', + 'adopte', + 'decede', + 'enfui', + ] + as $statusCode + ) { + $statuses[$statusCode] = t('status.' . $statusCode); + } + render('animals_inactive.php', [ + 'title' => $mode === 'archived' ? t('animals.archived') : t('animals.trash'), + 'pageDescription' => $mode === 'archived' ? t('inactive.archive_help') : t('inactive.trash_help'), + 'animals' => $stmt->fetchAll(PDO::FETCH_ASSOC), + 'mode' => $mode, + 'q' => $q, + 'status' => $status, + 'statuses' => $statuses, + 'sort' => $sort, + 'dir' => $dir, + ]); + } + + private static function redirectToAnimal(int $id): void + { + header('Location: /animal?id=' . $id); + exit(); + } +} diff --git a/app/Controllers/AnimalLifecycleActions.php b/app/Controllers/AnimalLifecycleActions.php new file mode 100644 index 0000000..e7c86c4 --- /dev/null +++ b/app/Controllers/AnimalLifecycleActions.php @@ -0,0 +1,87 @@ +prepare( + " + UPDATE animals + SET deleted_at = datetime('now'), + archived_at = NULL, + deletion_reason = :reason, + updated_at = datetime('now') + WHERE id = :id + ", + )->execute([':id' => $id, ':reason' => $reason !== '' ? $reason : null]); + + self::logHistory($id, 'delete', 'Animal placé dans la corbeille', $reason); + + header('Location: /animals'); + exit(); + } + + public static function archive(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $id = (int) ($_POST['id'] ?? 0); + if ($id <= 0) { + http_response_code(400); + return; + } + $db = DB::pdo(); + $db->prepare( + " + UPDATE animals SET archived_at = datetime('now'), deleted_at = NULL, + deletion_reason = NULL, updated_at = datetime('now') + WHERE id = :id + ", + )->execute([':id' => $id]); + self::logHistory($id, 'update', 'Animal archivé'); + header('Location: /animals'); + exit(); + } + + public static function restore(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $id = (int) ($_POST['id'] ?? 0); + if ($id <= 0) { + http_response_code(400); + return; + } + $db = DB::pdo(); + $db->prepare( + " + UPDATE animals SET archived_at = NULL, deleted_at = NULL, + deletion_reason = NULL, updated_at = datetime('now') + WHERE id = :id + ", + )->execute([':id' => $id]); + self::logHistory($id, 'update', 'Animal restauré'); + header('Location: /animals'); + exit(); + } +} diff --git a/app/Controllers/AnimalMedicalActions.php b/app/Controllers/AnimalMedicalActions.php new file mode 100644 index 0000000..e7304a3 --- /dev/null +++ b/app/Controllers/AnimalMedicalActions.php @@ -0,0 +1,706 @@ +prepare('SELECT fiv, felv, fiv_status, felv_status FROM animals WHERE id = :id'); + $animalStmt->execute([':id' => $animalId]); + $old = $animalStmt->fetch(PDO::FETCH_ASSOC); + if (!$old) { + http_response_code(404); + return; + } + + $oldFivStatus = (string) ($old['fiv_status'] ?: ((int) $old['fiv'] === 1 ? 'pos' : 'neg')); + $oldFelvStatus = (string) ($old['felv_status'] ?: ((int) $old['felv'] === 1 ? 'pos' : 'neg')); + + $db->beginTransaction(); + try { + $db->prepare( + " + UPDATE animals + SET fiv_status = :fiv_status, + felv_status = :felv_status, + fiv = :fiv, + felv = :felv, + updated_at = datetime('now') + WHERE id = :id + ", + )->execute([ + ':fiv_status' => $fivStatus, + ':felv_status' => $felvStatus, + ':fiv' => $fivStatus === 'pos' ? 1 : 0, + ':felv' => $felvStatus === 'pos' ? 1 : 0, + ':id' => $animalId, + ]); + + $noteStmt = $db->prepare(" + INSERT INTO medical_notes + (animal_id, noted_at, kind, reason, diagnosis, plan, created_at) + VALUES + (:animal_id, :noted_at, 'diagnostic', :reason, :diagnosis, :plan, datetime('now')) + "); + + foreach ( + [ + ['label' => 'FIV', 'old' => $oldFivStatus, 'new' => $fivStatus], + ['label' => 'FeLV', 'old' => $oldFelvStatus, 'new' => $felvStatus], + ] + as $test + ) { + if ($test['new'] === 'pos' && $test['old'] !== 'pos') { + $diagnosis = $test['label'] . ' positif'; + $noteStmt->execute([ + ':animal_id' => $animalId, + ':noted_at' => $testDate . ' 12:00:00', + ':reason' => 'Résultat de test ' . $test['label'], + ':diagnosis' => $diagnosis, + ':plan' => $testNotes !== '' ? $testNotes : null, + ]); + self::logHistory( + $animalId, + 'medical', + $diagnosis, + 'Test positif le ' . $testDate . ($testNotes !== '' ? "\n" . $testNotes : ''), + ); + } + } + + $conditionSelect = $db->prepare( + 'SELECT status FROM animal_health_conditions WHERE id = :id AND animal_id = :animal_id', + ); + $conditionUpdate = $db->prepare(" + UPDATE animal_health_conditions + SET name = :name, status = :status, diagnosed_at = :diagnosed_at, + resolved_at = :resolved_at, notes = :notes, updated_at = datetime('now') + WHERE id = :id AND animal_id = :animal_id + "); + $conditionInsert = $db->prepare(' + INSERT INTO animal_health_conditions + (animal_id, name, status, diagnosed_at, resolved_at, notes) + VALUES + (:animal_id, :name, :status, :diagnosed_at, :resolved_at, :notes) + '); + + foreach ($conditions as $key => $condition) { + if (!is_array($condition)) { + continue; + } + $name = trim((string) ($condition['name'] ?? '')); + if ($name === '') { + continue; + } + $status = ($condition['status'] ?? 'active') === 'resolved' ? 'resolved' : 'active'; + $diagnosedAt = trim((string) ($condition['diagnosed_at'] ?? '')) ?: null; + $notes = trim((string) ($condition['notes'] ?? '')) ?: null; + $conditionId = ctype_digit((string) $key) ? (int) $key : 0; + $previousStatus = null; + + if ($conditionId > 0) { + $conditionSelect->execute([':id' => $conditionId, ':animal_id' => $animalId]); + $previousStatus = $conditionSelect->fetchColumn(); + if ($previousStatus === false) { + continue; + } + $conditionUpdate->execute([ + ':name' => $name, + ':status' => $status, + ':diagnosed_at' => $diagnosedAt, + ':resolved_at' => $status === 'resolved' ? date('Y-m-d') : null, + ':notes' => $notes, + ':id' => $conditionId, + ':animal_id' => $animalId, + ]); + } else { + $conditionInsert->execute([ + ':animal_id' => $animalId, + ':name' => $name, + ':status' => $status, + ':diagnosed_at' => $diagnosedAt, + ':resolved_at' => $status === 'resolved' ? date('Y-m-d') : null, + ':notes' => $notes, + ]); + } + + if ($status === 'active' && $previousStatus !== 'active') { + $noteStmt->execute([ + ':animal_id' => $animalId, + ':noted_at' => ($diagnosedAt ?: date('Y-m-d')) . ' 12:00:00', + ':reason' => 'Statut médical global', + ':diagnosis' => $name, + ':plan' => $notes, + ]); + self::logHistory( + $animalId, + 'medical', + 'Pathologie / vigilance ajoutée', + $name . ($notes ? "\n" . $notes : ''), + ); + } + } + + $changes = []; + if ($oldFivStatus !== $fivStatus) { + $changes[] = 'FIV : ' . $oldFivStatus . ' → ' . $fivStatus; + } + if ($oldFelvStatus !== $felvStatus) { + $changes[] = 'FeLV : ' . $oldFelvStatus . ' → ' . $felvStatus; + } + if ($changes) { + self::logHistory($animalId, 'medical', 'Statut médical mis à jour', implode("\n", $changes)); + } + + $db->commit(); + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } + + header('Location: /animal?id=' . $animalId . '#tab-med'); + exit(); + } + + public static function addMedical(): void + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + return; + } + + $animalId = (int) ($_POST['animal_id'] ?? 0); + if ($animalId <= 0) { + http_response_code(400); + return; + } + + $kind = trim($_POST['kind'] ?? 'consult'); + $reason = trim($_POST['reason'] ?? ''); + $symptoms = trim($_POST['symptoms'] ?? ''); + $exam = trim($_POST['exam'] ?? ''); + $diagnosis = trim($_POST['diagnosis'] ?? ''); + $plan = trim($_POST['plan'] ?? ''); + $markSterilized = !empty($_POST['mark_sterilized']); + if ($markSterilized) { + $kind = 'acte'; + if ($reason === '') { + $reason = 'Stérilisation'; + } + } + + $temperature = $_POST['temperature_c'] ?? null; + $weight = $_POST['weight_kg'] ?? null; + + $clinicId = (int) ($_POST['clinic_id'] ?? 0); + $clinicId = $clinicId > 0 ? $clinicId : null; + + $db = DB::pdo(); + $clinicContact = self::resolveDirectoryContact( + $db, + (int) ($_POST['clinic_contact_id'] ?? 0), + (string) ($_POST['new_clinic_name'] ?? ''), + 'organization', + 'cabinet', + ); + $vetContact = self::resolveDirectoryContact( + $db, + (int) ($_POST['vet_contact_id'] ?? 0), + (string) ($_POST['new_vet_name'] ?? ''), + 'person', + 'veterinaire', + ); + self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact); + if (!$vetContact || !$clinicContact) { + http_response_code(400); + echo h(t('error.vet_clinic_required')); + return; + } + $vetName = $vetContact['name'] ?? null; + + // 1️⃣ Insertion note médicale + $stmt = $db->prepare(' + INSERT INTO medical_notes + (animal_id, kind, reason, symptoms, exam, diagnosis, plan, temperature_c, weight_kg, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by, created_at) + VALUES + (:aid, :kind, :reason, :symptoms, :exam, :diagnosis, :plan, :temp, :weight, :clinic, :vet, :clinic_contact, :vet_contact, :created_by, :created) + '); + + $stmt->execute([ + ':aid' => $animalId, + ':kind' => $kind, + ':reason' => $reason, + ':symptoms' => $symptoms, + ':exam' => $exam, + ':diagnosis' => $diagnosis, + ':plan' => $plan, + ':temp' => $temperature !== '' ? (float) $temperature : null, + ':weight' => $weight !== '' ? (float) $weight : null, + ':clinic' => $clinicId, + ':vet' => $vetName, + ':clinic_contact' => $clinicContact['id'] ?? null, + ':vet_contact' => $vetContact['id'] ?? null, + ':created_by' => Auth::id(), + ':created' => self::now(), + ]); + + // 👉 RÉCUPÉRATION ID NOTE + $medicalNoteId = $db->lastInsertId(); + if ((int) ($_POST['tariff_id'] ?? 0) > 0) { + PricingService::record( + $db, + $animalId, + (int) $_POST['tariff_id'], + 1, + substr(self::now(), 0, 10), + 'Ajoutée avec la note médicale', + ); + } + + // ========================== + // 2️⃣ Upload photos médicales + // ========================== + + if (!empty($_FILES['medical_photos']['name'][0])) { + $uploadDir = PrivateMediaService::medicalDir($animalId); + + if (!is_dir($uploadDir)) { + PrivateMediaService::ensure($uploadDir); + } + + foreach ($_FILES['medical_photos']['tmp_name'] as $key => $tmpName) { + if ($_FILES['medical_photos']['error'][$key] !== UPLOAD_ERR_OK) { + continue; + } + + if ($_FILES['medical_photos']['size'][$key] > 20 * 1024 * 1024) { + continue; + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $tmpName); + + $allowedTypes = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + ]; + + if (!isset($allowedTypes[$mime])) { + continue; + } + + try { + $stored = ImageService::storeUploaded($tmpName, $uploadDir, 'med', 'medical'); + $safeName = $stored['filename']; + @chmod($stored['path'], 0600); + + $stmtPhoto = $db->prepare(' + INSERT INTO medical_photos (animal_id, medical_note_id, filename) + VALUES (?, ?, ?) + '); + + $stmtPhoto->execute([$animalId, $medicalNoteId, $safeName]); + } catch (Throwable) { + continue; + } + } + } + + // 3️⃣ Poids → measurements (graph) + if ($weight !== '' && is_numeric($weight)) { + $db->prepare( + " + INSERT INTO measurements (animal_id, measured_at, type, value, unit, notes, created_by) + VALUES (:aid, :at, 'weight', :v, 'kg', 'from medical note', :created_by) + ", + )->execute([ + ':aid' => $animalId, + ':at' => self::now(), + ':v' => (float) $weight, + ':created_by' => Auth::id(), + ]); + } + + // 4️⃣ Historique + self::logHistory($animalId, 'medical', 'Note médicale ajoutée', $reason ?: null); + + // 5️⃣ Touch animal + if ($markSterilized) { + $db->prepare( + "UPDATE animals SET sterilized=1,sterilization_status='yes',sterilization_date=COALESCE(sterilization_date,date(:date)),sterilization_vet_contact_id=COALESCE(:vet,sterilization_vet_contact_id),sterilization_clinic_contact_id=COALESCE(:clinic,sterilization_clinic_contact_id) WHERE id=:id", + )->execute([ + ':date' => self::now(), + ':vet' => $vetContact['id'] ?? null, + ':clinic' => $clinicContact['id'] ?? null, + ':id' => $animalId, + ]); + } + $db->prepare( + ' + UPDATE animals SET updated_at = :u WHERE id = :id + ', + )->execute([ + ':u' => self::now(), + ':id' => $animalId, + ]); + + self::redirectToAnimal($animalId); + } + + public static function addTreatment(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + echo h(t('error.method_not_allowed')); + return; + } + + $id = (int) ($_POST['animal_id'] ?? 0); + $medId = (int) ($_POST['medication_id'] ?? 0); + $createMedication = !empty($_POST['create_medication']); + if ($id <= 0 || (!$createMedication && $medId <= 0)) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + + $route = trim((string) ($_POST['route'] ?? '')); + $dose = trim((string) ($_POST['dose_text'] ?? '')); + $start = trim((string) ($_POST['start_date'] ?? date('Y-m-d'))); + $end = trim((string) ($_POST['end_date'] ?? '')); + $notes = trim((string) ($_POST['notes'] ?? '')); + $giveMorning = !empty($_POST['give_morning']) ? 1 : 0; + $giveEvening = !empty($_POST['give_evening']) ? 1 : 0; + $giveAsNeeded = !empty($_POST['give_as_needed']) ? 1 : 0; + $clinic_id = (int) ($_POST['clinic_id'] ?? 0); + $clinic_id = $clinic_id > 0 ? $clinic_id : null; + + $vet_name = trim((string) ($_POST['vet_name'] ?? '')); + $vet_name = $vet_name !== '' ? mb_strtoupper($vet_name, 'UTF-8') : null; + + if ($dose === '') { + http_response_code(400); + echo h(t('error.dose_required')); + return; + } + + $db = DB::pdo(); + if ($createMedication) { + $medicationName = trim((string) ($_POST['new_medication_name'] ?? '')); + $medicationMolecule = trim((string) ($_POST['new_medication_molecule'] ?? '')); + $medicationForm = trim((string) ($_POST['new_medication_form'] ?? '')); + if ($medicationName === '') { + http_response_code(400); + echo h(t('error.medication_name_required')); + return; + } + + $existing = $db->prepare('SELECT id FROM ref_medications WHERE name = :name COLLATE NOCASE LIMIT 1'); + $existing->execute([':name' => $medicationName]); + $medId = (int) ($existing->fetchColumn() ?: 0); + if ($medId <= 0) { + $insertMedication = $db->prepare( + 'INSERT INTO ref_medications(name, molecule, form) VALUES(:name,:molecule,:form)', + ); + $insertMedication->execute([ + ':name' => mb_substr($medicationName, 0, 150), + ':molecule' => $medicationMolecule !== '' ? mb_substr($medicationMolecule, 0, 150) : null, + ':form' => $medicationForm !== '' ? mb_substr($medicationForm, 0, 100) : null, + ]); + $medId = (int) $db->lastInsertId(); + } + } + $clinicContact = self::resolveDirectoryContact( + $db, + (int) ($_POST['clinic_contact_id'] ?? 0), + (string) ($_POST['new_clinic_name'] ?? ''), + 'organization', + 'cabinet', + ); + $vetContact = self::resolveDirectoryContact( + $db, + (int) ($_POST['vet_contact_id'] ?? 0), + (string) ($_POST['new_vet_name'] ?? ''), + 'person', + 'veterinaire', + ); + self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact); + if (!$vetContact || !$clinicContact) { + http_response_code(400); + echo h(t('error.vet_clinic_required')); + return; + } + $vet_name = $vetContact['name'] ?? $vet_name; + $stmt = $db->prepare(' + INSERT INTO treatments(animal_id, medication_id, route, dose_text, start_date, end_date, ongoing, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, give_morning, give_evening, give_as_needed, created_by) + VALUES(:aid,:mid,:route,:dose,:start,:end,1,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:give_morning,:give_evening,:give_as_needed,:created_by) + '); + $stmt->execute([ + ':aid' => $id, + ':mid' => $medId, + ':route' => $route !== '' ? $route : null, + ':dose' => $dose, + ':start' => $start, + ':end' => $end !== '' ? $end : null, + ':notes' => $notes !== '' ? $notes : null, + ':clinic_id' => $clinic_id, + ':vet_name' => $vet_name, + ':clinic_contact_id' => $clinicContact['id'] ?? null, + ':vet_contact_id' => $vetContact['id'] ?? null, + ':give_morning' => $giveMorning, + ':give_evening' => $giveEvening, + ':give_as_needed' => $giveAsNeeded, + ':created_by' => Auth::id(), + ]); + if ((int) ($_POST['tariff_id'] ?? 0) > 0) { + PricingService::record($db, $id, (int) $_POST['tariff_id'], 1, $start, 'Ajoutée avec le traitement'); + } + + self::logHistory( + $id, + 'treatment', + 'Traitement ajouté', + "Médicament #$medId\nDose : $dose\nDébut : $start" . + ($end !== '' ? "\nFin : $end" : '') . + ($notes !== '' ? "\nNotes : $notes" : ''), + ); + + $db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]); + self::redirectToAnimal($id); + } + + public static function addExpense(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + return; + } + $animal = (int) ($_POST['animal_id'] ?? 0); + $tariff = (int) ($_POST['tariff_id'] ?? 0); + $qty = (float) str_replace(',', '.', (string) ($_POST['quantity'] ?? 1)); + $date = (string) ($_POST['occurred_on'] ?? date('Y-m-d')); + if (!$animal || !$tariff || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + http_response_code(400); + echo h(t('error.invalid_expense')); + return; + } + $db = DB::pdo(); + try { + $total = PricingService::record($db, $animal, $tariff, $qty, $date, trim((string) ($_POST['notes'] ?? ''))); + self::logHistory( + $animal, + 'medical', + 'Dépense vétérinaire ajoutée', + number_format($total / 100, 2, ',', ' ') . ' €', + ); + AuditService::log( + 'animal_expense_added', + '/animal/add-expense', + 'Dépense vétérinaire enregistrée', + 'animal', + $animal, + ['total_cents' => $total], + ); + self::redirectToAnimal($animal); + } catch (Throwable $e) { + http_response_code(400); + echo h($e->getMessage()); + } + } + + public static function addVaccine(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + echo h(t('error.method_not_allowed')); + return; + } + $db = DB::pdo(); + + $id = (int) ($_POST['animal_id'] ?? 0); + $vacId = (int) ($_POST['vaccine_id'] ?? 0); + if ($id <= 0 || $vacId <= 0) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + + $done = trim((string) ($_POST['done_date'] ?? date('Y-m-d'))); + $due = trim((string) ($_POST['due_date'] ?? '')); + // Si due_date est fourni, on respecte. + // Sinon auto selon protocole. + if ($due === '') { + // Birthdate (pour l'âge) + $stmt = $db->prepare('SELECT birth_date FROM animals WHERE id = :id'); + $stmt->execute([':id' => $id]); + $birth = (string) ($stmt->fetchColumn() ?: ''); + + $isUnderOne = false; + if ($birth !== '') { + $birthDt = new DateTime($birth); + $doneDt = new DateTime($done); + $ageDays = (int) $birthDt->diff($doneDt)->format('%r%a'); + if ($ageDays >= 0) { + $isUnderOne = $ageDays < 365; + } + } + + // Protocole vaccin (family TC / L) + $stmt = $db->prepare('SELECT family, name FROM ref_vaccines WHERE id = :vid'); + $stmt->execute([':vid' => $vacId]); + $vac = $stmt->fetch() ?: []; + $family = strtoupper((string) ($vac['family'] ?? '')); + + // Détermine si c'est une primo pour la famille L (Leucose) + $isPrimoL = false; + if ($family === 'L') { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM vaccinations v + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + WHERE v.animal_id = :aid AND upper(coalesce(rv.family,'')) = 'L' + "); + $stmt->execute([':aid' => $id]); + $countL = (int) $stmt->fetchColumn(); + $isPrimoL = $countL === 0; + } + + // Règles + if ($family === 'L') { + $due = $isPrimoL + ? date('Y-m-d', strtotime($done . ' +1 month')) + : date('Y-m-d', strtotime($done . ' +1 year')); + } else { + // TC (ou inconnu => TC) + $due = $isUnderOne + ? date('Y-m-d', strtotime($done . ' +1 month')) + : date('Y-m-d', strtotime($done . ' +1 year')); + } + } + + $lot = trim((string) ($_POST['lot'] ?? '')); + $manufacturer = trim((string) ($_POST['manufacturer'] ?? '')); + $batchExpiresOn = trim((string) ($_POST['batch_expires_on'] ?? '')); + if ($batchExpiresOn !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $batchExpiresOn)) { + http_response_code(400); + echo h(t('error.invalid_batch_expiry')); + return; + } + $administratorUserId = (int) ($_POST['administered_by_user_id'] ?? 0) ?: null; + $administratorName = trim((string) ($_POST['administered_by_name'] ?? '')); + if ($administratorUserId) { + $check = $db->prepare('SELECT 1 FROM users WHERE id=:id AND active=1'); + $check->execute([':id' => $administratorUserId]); + if (!$check->fetchColumn()) { + $administratorUserId = null; + } + } + $notes = trim((string) ($_POST['notes'] ?? '')); + $clinic_id = (int) ($_POST['clinic_id'] ?? 0); + $clinic_id = $clinic_id > 0 ? $clinic_id : null; + + $vet_name = trim((string) ($_POST['vet_name'] ?? '')); + $vet_name = $vet_name !== '' ? mb_strtoupper($vet_name, 'UTF-8') : null; + $clinicContact = self::resolveDirectoryContact( + $db, + (int) ($_POST['clinic_contact_id'] ?? 0), + (string) ($_POST['new_clinic_name'] ?? ''), + 'organization', + 'cabinet', + ); + $vetContact = self::resolveDirectoryContact( + $db, + (int) ($_POST['vet_contact_id'] ?? 0), + (string) ($_POST['new_vet_name'] ?? ''), + 'person', + 'veterinaire', + ); + self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact); + if (!$vetContact || !$clinicContact) { + http_response_code(400); + echo h(t('error.vet_clinic_required')); + return; + } + $vet_name = $vetContact['name'] ?? $vet_name; + + $stmt = $db->prepare(' + INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on,administered_by_user_id,administered_by_name, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) + VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:administrator_user,:administrator_name,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) + '); + $stmt->execute([ + ':aid' => $id, + ':vid' => $vacId, + ':done' => $done, + ':due' => $due !== '' ? $due : null, + ':lot' => $lot !== '' ? $lot : null, + ':manufacturer' => $manufacturer ?: null, + ':batch_expiry' => $batchExpiresOn ?: null, + ':administrator_user' => $administratorUserId, + ':administrator_name' => $administratorName ?: null, + ':notes' => $notes !== '' ? $notes : null, + ':clinic_id' => $clinic_id, + ':vet_name' => $vet_name, + ':clinic_contact_id' => $clinicContact['id'] ?? null, + ':vet_contact_id' => $vetContact['id'] ?? null, + ':created_by' => Auth::id(), + ]); + InventoryService::consumeBatch( + (int) ($_POST['inventory_batch_id'] ?? 0), + (float) str_replace(',', '.', (string) ($_POST['inventory_quantity'] ?? 0)), + $id, + 'Vaccination du ' . $done, + ); + if ((int) ($_POST['tariff_id'] ?? 0) > 0) { + PricingService::record($db, $id, (int) $_POST['tariff_id'], 1, $done, 'Ajoutée avec la vaccination'); + } + + self::logHistory( + $id, + 'vaccine', + 'Vaccin ajouté', + "Vaccin #$vacId\nFait le : $done" . + ($due !== '' ? "\nRappel : $due" : '') . + ($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') . + ($lot !== '' ? "\nLot : $lot" : '') . + ($batchExpiresOn !== '' ? "\nExpiration du lot : $batchExpiresOn" : '') . + ($administratorName !== '' || $administratorUserId + ? "\nAdministré par : " . + ($administratorName !== '' ? $administratorName : 'utilisateur #' . $administratorUserId) + : ''), + ); + + $db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]); + self::redirectToAnimal($id); + } +} diff --git a/app/Controllers/AnimalPhotoActions.php b/app/Controllers/AnimalPhotoActions.php new file mode 100644 index 0000000..cd3bfd7 --- /dev/null +++ b/app/Controllers/AnimalPhotoActions.php @@ -0,0 +1,296 @@ +getMessage()); + return; + } + + // si aucune photo primaire, celle-ci devient primaire + $stmt = $db->prepare('SELECT COUNT(*) FROM animal_photos WHERE animal_id=:id AND is_primary=1'); + $stmt->execute([':id' => $id]); + $hasPrimary = (int) $stmt->fetchColumn() > 0; + + $stmt = $db->prepare(' + INSERT INTO animal_photos(animal_id, filename, original_name, mime, size_bytes, is_primary) + VALUES(:aid,:fn,:orig,:mime,:sz,:prim) + '); + $stmt->execute([ + ':aid' => $id, + ':fn' => $filename, + ':orig' => $orig, + ':mime' => $mime, + ':sz' => $size, + ':prim' => $hasPrimary ? 0 : 1, + ]); + + header('Location: /animal?id=' . $id); + exit(); + } + + public static function uploadPhotos(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + + $animalId = (int) ($_POST['animal_id'] ?? 0); + if ($animalId <= 0) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + + if (empty($_FILES['photos'])) { + http_response_code(400); + echo h(t('error.no_files')); + return; + } + + $isPublic = (int) ($_POST['is_public'] ?? 1 ? 1 : 0); + $redirectHash = trim((string) ($_POST['redirect_hash'] ?? '')); + + $db = DB::pdo(); + self::ensureAnimalMediaDir($animalId); + + $files = $_FILES['photos']; + $count = is_array($files['name']) ? count($files['name']) : 1; + + // Y a-t-il déjà une photo principale ? + $hasPrimary = (bool) $db + ->query( + ' + SELECT 1 FROM animal_photos + WHERE animal_id=' . + (int) $animalId . + ' + AND is_primary=1 + LIMIT 1 + ', + ) + ->fetchColumn(); + + for ($i = 0; $i < $count; $i++) { + $name = is_array($files['name']) ? $files['name'][$i] : $files['name']; + $tmp = is_array($files['tmp_name']) ? $files['tmp_name'][$i] : $files['tmp_name']; + $err = is_array($files['error']) ? $files['error'][$i] : $files['error']; + + if ($err !== UPLOAD_ERR_OK) { + continue; + } + if (!is_uploaded_file($tmp)) { + continue; + } + if (!self::isAllowedImage($tmp)) { + continue; + } + + $base = self::safeFilename(pathinfo($name, PATHINFO_FILENAME)); + $targetDir = $isPublic === 1 ? self::animalMediaDir($animalId) : PrivateMediaService::animalDir($animalId); + if ($isPublic === 0) { + PrivateMediaService::ensure($targetDir); + } + try { + $stored = ImageService::storeUploaded($tmp, $targetDir, $base, 'photo'); + $final = $stored['filename']; + if ($isPublic === 0) { + @chmod($stored['path'], 0600); + } + } catch (Throwable) { + continue; + } + + // RÈGLE : jamais une privée en principale + $isPrimary = !$hasPrimary && $isPublic === 1 ? 1 : 0; + if ($isPrimary === 1) { + $hasPrimary = true; + } + + $st = $db->prepare(' + INSERT INTO animal_photos(animal_id, filename, is_primary, is_public) + VALUES(:aid,:fn,:p,:pub) + '); + $st->execute([ + ':aid' => $animalId, + ':fn' => $final, + ':p' => $isPrimary, + ':pub' => $isPublic, + ]); + } + + $db->prepare( + " + UPDATE animals + SET updated_at=datetime('now') + WHERE id=:id + ", + )->execute([':id' => $animalId]); + + // Retour direct sur onglet photos + $hash = $redirectHash !== '' && $redirectHash[0] === '#' ? $redirectHash : ''; + header('Location: /animal?id=' . $animalId . $hash); + exit(); + } + + public static function setPrimaryPhoto(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + + $animalId = (int) ($_POST['animal_id'] ?? 0); + $photoId = (int) ($_POST['photo_id'] ?? 0); + if ($animalId <= 0 || $photoId <= 0) { + http_response_code(400); + return; + } + + $db = DB::pdo(); + $stmt = $db->prepare('SELECT filename,is_public FROM animal_photos WHERE id=:pid AND animal_id=:aid'); + $stmt->execute([':pid' => $photoId, ':aid' => $animalId]); + $photo = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$photo) { + http_response_code(404); + echo h(t('error.photo_not_found')); + return; + } + $moved = false; + try { + if ((int) $photo['is_public'] !== 1) { + PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], true); + $moved = true; + } + $db->beginTransaction(); + $db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([':aid' => $animalId]); + $db->prepare('UPDATE animal_photos SET is_primary=1,is_public=1 WHERE id=:pid AND animal_id=:aid')->execute( + [':pid' => $photoId, ':aid' => $animalId], + ); + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + if ($moved) { + try { + PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], false); + } catch (Throwable) { + } + } + throw $e; + } + + // Historique si tu veux + if (method_exists(__CLASS__, 'logHistory')) { + self::logHistory($animalId, 'photo', 'Photo principale modifiée'); + } + + header('Location: /animal?id=' . $animalId); + exit(); + } + + public static function deletePhoto(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + + $animalId = (int) ($_POST['animal_id'] ?? 0); + $photoId = (int) ($_POST['photo_id'] ?? 0); + if ($animalId <= 0 || $photoId <= 0) { + http_response_code(400); + return; + } + + $db = DB::pdo(); + + $st = $db->prepare( + 'SELECT filename, is_primary, is_public FROM animal_photos WHERE id=:pid AND animal_id=:aid', + ); + $st->execute([':pid' => $photoId, ':aid' => $animalId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if (!$row) { + header('Location: /animal?id=' . $animalId); + exit(); + } + + $filename = (string) $row['filename']; + $wasPrimary = (int) $row['is_primary'] === 1; + + $db->prepare('DELETE FROM animal_photos WHERE id=:pid AND animal_id=:aid')->execute([ + ':pid' => $photoId, + ':aid' => $animalId, + ]); + + $path = + ((int) ($row['is_public'] ?? 1) === 1 + ? self::animalMediaDir($animalId) + : PrivateMediaService::animalDir($animalId)) . + '/' . + $filename; + if (is_file($path)) { + @unlink($path); + } + + // Si on a supprimé la principale, on met la plus récente restante en principale + if ($wasPrimary) { + $pid = $db + ->query('SELECT id FROM animal_photos WHERE animal_id=' . (int) $animalId . ' ORDER BY id DESC LIMIT 1') + ->fetchColumn(); + if ($pid) { + $db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid')->execute([':pid' => (int) $pid]); + } + } + + $db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]); + + if (method_exists(__CLASS__, 'logHistory')) { + self::logHistory($animalId, 'photo', 'Photo supprimée'); + } + + header('Location: /animal?id=' . $animalId); + exit(); + } +} diff --git a/app/Controllers/AnimalRecordActions.php b/app/Controllers/AnimalRecordActions.php new file mode 100644 index 0000000..f06795f --- /dev/null +++ b/app/Controllers/AnimalRecordActions.php @@ -0,0 +1,1041 @@ +prepare('SELECT * FROM animals WHERE id=:id'); + $stmt->execute([':id' => $id]); + $animal = $stmt->fetch(); + if (!$animal) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + + $stmt = $db->prepare( + 'SELECT ad.*,vet.name veterinarian_name,crem.name crematorium_name FROM animal_deaths ad LEFT JOIN directory_contacts vet ON vet.id=ad.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=ad.crematorium_contact_id WHERE ad.animal_id=:id', + ); + $stmt->execute([':id' => $id]); + $death = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + + // Notes médicales + clinique + $stmt = $db->prepare(' + SELECT mn.*, COALESCE(dc.name, c.name) AS clinic_name + FROM medical_notes mn + LEFT JOIN clinics c ON c.id = mn.clinic_id + LEFT JOIN directory_contacts dc ON dc.id = mn.clinic_contact_id + WHERE mn.animal_id=:id + ORDER BY mn.noted_at DESC, mn.id DESC + '); + $stmt->execute([':id' => $id]); + $medical_notes = $stmt->fetchAll(); + + // Photos médicales indexées par note + $stmt = $db->prepare(' + SELECT id, medical_note_id, filename + FROM medical_photos + WHERE medical_note_id IN ( + SELECT id FROM medical_notes WHERE animal_id = :id + ) + ORDER BY medical_note_id ASC, id ASC + '); + $stmt->execute([':id' => $id]); + $allMedicalPhotos = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Regroupe par note + $medicalPhotosByNote = []; + foreach ($allMedicalPhotos as $p) { + $medicalPhotosByNote[$p['medical_note_id']][] = $p; + } + + // Traitements (tous, y compris terminés) + $stmt = $db->prepare(' + SELECT t.*, m.name AS medication_name, m.molecule, m.form, COALESCE(dc.name, c.name) AS clinic_name + FROM treatments t + JOIN ref_medications m ON m.id = t.medication_id + LEFT JOIN clinics c ON c.id = t.clinic_id + LEFT JOIN directory_contacts dc ON dc.id = t.clinic_contact_id + WHERE t.animal_id=:id + ORDER BY t.ongoing DESC, t.start_date DESC, t.id DESC + '); + $stmt->execute([':id' => $id]); + $treatments = $stmt->fetchAll(); + + // Vaccins + $stmt = $db->prepare(' + SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name + FROM vaccinations v + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + LEFT JOIN clinics c ON c.id = v.clinic_id + LEFT JOIN directory_contacts dc ON dc.id = v.clinic_contact_id + WHERE v.animal_id=:id + ORDER BY v.done_date DESC, v.id DESC + '); + $stmt->execute([':id' => $id]); + $vaccinations = $stmt->fetchAll(); + + // Photos + $stmt = $db->prepare('SELECT * FROM animal_photos WHERE animal_id=:id ORDER BY is_primary DESC, id DESC'); + $stmt->execute([':id' => $id]); + $photos = $stmt->fetchAll(); + + // Génère le HTML PDF via une vue dédiée + //$viewFile = __DIR__ . '/../Views/animal_pdf.php'; + $viewFile = __DIR__ . '/../Views/animal_pdf_v2.php'; + if (!file_exists($viewFile)) { + http_response_code(500); + echo h(t('error.missing_pdf_view')); + return; + } + + // variables pour la vue + $title = 'Dossier - ' . ($animal['name'] ?? 'Animal #' . $id); + + // Photo principale + $stmt = $db->prepare(' + SELECT filename + FROM animal_photos + WHERE animal_id=:id AND is_primary=1 + ORDER BY id DESC + LIMIT 1 + '); + $stmt->execute([':id' => $id]); + $primary_photo = $stmt->fetchColumn() ?: null; + + // Dernière note médicale (résumé) + $stmt = $db->prepare(' + SELECT noted_at, kind, reason, weight_kg, temperature_c, diagnosis, plan + FROM medical_notes + WHERE animal_id=:id + ORDER BY datetime(noted_at) DESC, id DESC + LIMIT 1 + '); + $stmt->execute([':id' => $id]); + $last_med = $stmt->fetch() ?: null; + + // Prochain vaccin (due_date la plus proche) + $stmt = $db->prepare(' + SELECT rv.name AS vaccine_name, v.due_date + FROM vaccinations v + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + WHERE v.animal_id=:id AND v.due_date IS NOT NULL + ORDER BY date(v.due_date) ASC, v.id ASC + LIMIT 1 + '); + $stmt->execute([':id' => $id]); + $next_vaccine = $stmt->fetch() ?: null; + + $generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')); + $footerText = + 'Généré par ' . + AppSettings::get('association_name') . + ', le ' . + $generatedAt->format('d/m/Y') . + ' à ' . + $generatedAt->format('H\\hi'); + $internalCode = trim((string) ($animal['internal_code'] ?? '')); + if ($internalCode !== '') { + $footerText .= ' · Dossier ' . $internalCode; + } + header('Content-Type: text/html; charset=UTF-8'); + require $viewFile; + } + + public static function form(): void + { + $db = DB::pdo(); + $id = (int) ($_GET['id'] ?? 0); + + $animal = [ + 'id' => 0, + 'name' => '', + 'internal_code' => '', + 'status' => 'refuge', + 'species' => 'chat', + 'sex' => 'U', + 'sterilized' => 0, + 'sterilization_status' => 'unknown', + 'sterilization_date' => '', + 'sterilization_vet_contact_id' => '', + 'sterilization_clinic_contact_id' => '', + 'refuge_room' => '', + 'chip_id' => '', + 'identification_type' => 'unknown', + 'identification_date' => '', + 'identification_registration_status' => 'unknown', + 'adoption_availability' => 'unknown', + 'adoption_available_from' => '', + 'adoption_unavailability_reason' => '', + 'website_published' => 0, + 'website_description' => '', + 'color' => '', + 'hair_type' => '', + 'birth_date' => '', + 'notes' => '', + 'intake_date' => '', + 'intake_type' => 'unknown', + 'intake_reason' => 'unknown', + 'depositor_contact_id' => '', + 'intake_circumstances' => '', + 'intake_owner_care_home' => 0, + 'quarantine_until' => '', + 'compatibility_dogs' => 'unknown', + 'compatibility_cats' => 'unknown', + 'compatibility_children' => 'unknown', + 'house_trained' => 'unknown', + 'free_cat_site_name' => '', + 'free_cat_city' => '', + 'free_cat_address' => '', + 'free_cat_caretaker_contact_id' => '', + 'free_cat_captured_at' => '', + 'free_cat_released_at' => '', + 'free_cat_tracking_status' => 'present', + // adapte si tu as fiv/felv en colonnes + 'fiv' => '', + 'felv' => '', + ]; + + if ($id > 0) { + $stmt = $db->prepare('SELECT * FROM animals WHERE id=:id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(); + if (!$row) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + $animal = array_merge($animal, $row); + if (!empty($animal['intake_owner_care_home'])) { + $animal['intake_reason'] = 'owner_care_home'; + } + } + + $fosterContacts = $db + ->query( + "SELECT DISTINCT dc.id,dc.name,dc.address,dc.postal_code,dc.city FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='fa' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $currentFosterId = 0; + if ($id > 0) { + $stmt = $db->prepare( + "SELECT contact_id FROM animal_placements WHERE animal_id=:id AND event_type='foster' ORDER BY date(event_date) DESC,id DESC LIMIT 1", + ); + $stmt->execute([':id' => $id]); + $currentFosterId = (int) $stmt->fetchColumn(); + } + + $depositors = $db + ->query( + "SELECT dc.id,dc.name,dc.phone,dc.city FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='deposant' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $sterilizationVets = $db + ->query( + "SELECT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='veterinaire' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $sterilizationClinics = $db + ->query( + "SELECT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='cabinet' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $freeCatCaretakers = $db + ->query( + "SELECT id,name,city FROM directory_contacts WHERE deleted_at IS NULL AND kind='person' ORDER BY name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + render('animal_form.php', [ + 'title' => $id > 0 ? 'Modifier ' . $animal['name'] : 'Ajouter un animal', + 'pageDescription' => + $id > 0 + ? 'Modification de la fiche de ' . $animal['name'] . '.' + : 'Création d’une nouvelle fiche animal dans Globinours.', + 'animal' => $animal, + 'intakeTypes' => self::intakeTypes(), + 'intakeReasons' => self::intakeReasons(), + 'depositors' => $depositors, + 'sterilizationVets' => $sterilizationVets, + 'sterilizationClinics' => $sterilizationClinics, + 'fosterContacts' => $fosterContacts, + 'currentFosterId' => $currentFosterId, + 'freeCatCaretakers' => $freeCatCaretakers, + 'speciesOptions' => SpeciesService::activeWithCurrent((string) ($animal['species'] ?? 'chat')), + 'shelterRooms' => ShelterRoomService::all(true), + ]); + } + + private static function nextInternalCode(string $name): string + { + $year = date('Y'); + + $base = strtoupper(trim($name)); + $base = preg_replace('/[^A-Z0-9]+/u', '_', $base); + $base = trim($base, '_'); + if ($base === '') { + $base = 'ANIMAL'; + } + + $db = DB::pdo(); + $stmt = $db->prepare(' + SELECT COUNT(*) FROM animals + WHERE internal_code LIKE :p + '); + $stmt->execute([':p' => $year . '-%']); + $n = (int) $stmt->fetchColumn(); + + $seq = str_pad((string) ($n + 1), 3, '0', STR_PAD_LEFT); + return $year . '-' . $seq . '-' . $base; + } + + public static function save(): void + { + require_once __DIR__ . '/../Services/GeocodeService.php'; + require_once __DIR__ . '/../Services/GeoService.php'; + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + return; + } + + $db = DB::pdo(); + + $id = (int) ($_POST['id'] ?? 0); + $old = null; + if ($id > 0) { + $st = $db->prepare('SELECT * FROM animals WHERE id=:id'); + $st->execute([':id' => $id]); + $old = $st->fetch(PDO::FETCH_ASSOC) ?: null; + } + $isArchivedCorrection = $old && !empty($old['archived_at']); + + $name = trim((string) ($_POST['name'] ?? '')); + $status = trim((string) ($_POST['status'] ?? 'refuge')); + if ($isArchivedCorrection) { + $status = (string) $old['status']; + } + if (in_array(strtolower($status), ['decede', 'décédé'], true)) { + $deathCheck = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=:id'); + $deathCheck->execute([':id' => $id]); + if (!$deathCheck->fetchColumn()) { + http_response_code(400); + echo h(t('error.use_death_action')); + return; + } + } + $refugeRoom = trim((string) ($_POST['refuge_room'] ?? '')); + $locationChangeReason = trim((string) ($_POST['location_change_reason'] ?? '')); + $availableRooms = ShelterRoomService::byCode(true); + if (!isset($availableRooms[$refugeRoom]) || (string) $availableRooms[$refugeRoom]['status_code'] !== $status) { + $refugeRoom = null; + } + + $species = SpeciesService::normalize((string) ($_POST['species'] ?? 'chat')); + if (!SpeciesService::exists($species, $id <= 0)) { + http_response_code(400); + echo h(t('error.valid_species')); + return; + } + if ($status === 'chat_libre' && !SpeciesService::isCat($species)) { + http_response_code(400); + echo h(t('error.freecat_cats_only')); + return; + } + + $sex = trim((string) ($_POST['sex'] ?? 'U')); + $sterilizationStatus = (string) ($_POST['sterilization_status'] ?? 'unknown'); + if (!in_array($sterilizationStatus, ['unknown', 'no', 'yes'], true)) { + $sterilizationStatus = 'unknown'; + } + $sterilized = $sterilizationStatus === 'yes' ? 1 : 0; + $sterilizationDate = trim((string) ($_POST['sterilization_date'] ?? '')); + if ($sterilizationDate !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $sterilizationDate)) { + http_response_code(400); + echo h(t('error.invalid_sterilization_date')); + return; + } + $sterilizationVetId = (int) ($_POST['sterilization_vet_contact_id'] ?? 0) ?: null; + $sterilizationClinicId = (int) ($_POST['sterilization_clinic_contact_id'] ?? 0) ?: null; + if ($sterilizationStatus !== 'yes') { + $sterilizationDate = ''; + $sterilizationVetId = null; + $sterilizationClinicId = null; + } + $chip_id = trim((string) ($_POST['chip_id'] ?? '')); + $identificationType = (string) ($_POST['identification_type'] ?? 'unknown'); + if (!in_array($identificationType, ['unknown', 'microchip', 'tattoo'], true)) { + $identificationType = 'unknown'; + } + $identificationDate = trim((string) ($_POST['identification_date'] ?? '')); + $identificationRegistration = (string) ($_POST['identification_registration_status'] ?? 'unknown'); + if (!in_array($identificationRegistration, ['unknown', 'pending', 'registered'], true)) { + $identificationRegistration = 'unknown'; + } + if ($chip_id === '') { + $identificationType = 'unknown'; + $identificationDate = ''; + $identificationRegistration = 'unknown'; + } + $adoptionAvailability = (string) ($_POST['adoption_availability'] ?? 'unknown'); + if (!in_array($adoptionAvailability, ['unknown', 'not_available', 'available'], true)) { + $adoptionAvailability = 'unknown'; + } + $adoptionAvailableFrom = trim((string) ($_POST['adoption_available_from'] ?? '')); + $adoptionUnavailabilityReason = trim((string) ($_POST['adoption_unavailability_reason'] ?? '')); + foreach ([$identificationDate, $adoptionAvailableFrom] as $dateValue) { + if ($dateValue !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateValue)) { + http_response_code(400); + echo h(t('error.invalid_date')); + return; + } + } + if ($adoptionAvailability !== 'available') { + $adoptionAvailableFrom = ''; + } + if ($adoptionAvailability !== 'not_available') { + $adoptionUnavailabilityReason = ''; + } + $websitePublished = isset($_POST['website_published']) ? 1 : 0; + $websiteDescription = mb_substr(trim((string) ($_POST['website_description'] ?? '')), 0, 5000); + $breed = trim((string) ($_POST['breed'] ?? '')); + $color = trim((string) ($_POST['color'] ?? '')); + $hairType = trim((string) ($_POST['hair_type'] ?? '')); + $profileValue = static function (string $field): string { + $value = (string) ($_POST[$field] ?? 'unknown'); + return in_array($value, ['unknown', 'yes', 'no'], true) ? $value : 'unknown'; + }; + $compatibilityDogs = $profileValue('compatibility_dogs'); + $compatibilityCats = $profileValue('compatibility_cats'); + $compatibilityChildren = $profileValue('compatibility_children'); + $houseTrained = $profileValue('house_trained'); + $freeCatSiteName = trim((string) ($_POST['free_cat_site_name'] ?? '')); + $freeCatCity = trim((string) ($_POST['free_cat_city'] ?? '')); + $freeCatAddress = trim((string) ($_POST['free_cat_address'] ?? '')); + $freeCatCaretakerId = (int) ($_POST['free_cat_caretaker_contact_id'] ?? 0) ?: null; + $freeCatCapturedAt = trim((string) ($_POST['free_cat_captured_at'] ?? '')); + $freeCatReleasedAt = trim((string) ($_POST['free_cat_released_at'] ?? '')); + $freeCatTrackingStatus = (string) ($_POST['free_cat_tracking_status'] ?? 'present'); + if (!in_array($freeCatTrackingStatus, ['present', 'missing', 'moved', 'deceased'], true)) { + $freeCatTrackingStatus = 'present'; + } + foreach ([$freeCatCapturedAt, $freeCatReleasedAt] as $freeCatDate) { + if ($freeCatDate !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $freeCatDate)) { + http_response_code(400); + echo h(t('error.invalid_freecat_date')); + return; + } + } + if ($status !== 'chat_libre') { + $freeCatSiteName = ''; + $freeCatCity = ''; + $freeCatAddress = ''; + $freeCatCaretakerId = null; + $freeCatCapturedAt = ''; + $freeCatReleasedAt = ''; + $freeCatTrackingStatus = 'present'; + } elseif ($freeCatCaretakerId) { + $check = $db->prepare( + "SELECT 1 FROM directory_contacts WHERE id=:id AND deleted_at IS NULL AND kind='person'", + ); + $check->execute([':id' => $freeCatCaretakerId]); + if (!$check->fetchColumn()) { + $freeCatCaretakerId = null; + } + } + + $notes = trim((string) ($_POST['notes'] ?? '')); + + $intake_date = trim((string) ($_POST['intake_date'] ?? '')); // YYYY-MM-DD + $intakeType = (string) ($_POST['intake_type'] ?? 'unknown'); + if (!isset(self::INTAKE_TYPES[$intakeType])) { + $intakeType = 'unknown'; + } + $submittedIntakeReason = (string) ($_POST['intake_reason'] ?? 'unknown'); + if (!isset(self::INTAKE_REASONS[$submittedIntakeReason])) { + $submittedIntakeReason = 'unknown'; + } + $intakeOwnerCareHome = $submittedIntakeReason === 'owner_care_home' ? 1 : 0; + $intakeReason = $intakeOwnerCareHome ? 'owner_health' : $submittedIntakeReason; + $intakeCircumstances = trim((string) ($_POST['intake_circumstances'] ?? '')); + + // Naissance (date + estimation) + $birth_date = trim((string) ($_POST['birth_date'] ?? '')); + $birth_is_estimated = (int) ($_POST['birth_is_estimated'] ?? 0 ? 1 : 0); + + $birth_est_years = trim((string) ($_POST['birth_est_years'] ?? '')); + $birth_est_months = trim((string) ($_POST['birth_est_months'] ?? '')); + + $birth_est_years = $birth_est_years === '' ? 0 : (int) $birth_est_years; + $birth_est_months = $birth_est_months === '' ? 0 : (int) $birth_est_months; + + $birth_estimated_months = $birth_est_years * 12 + $birth_est_months; + if ($birth_estimated_months === 0) { + $birth_estimated_months = null; + } + + // FIV/FELV (selon ton stockage actuel) + $fiv = array_key_exists('fiv', $_POST) ? (isset($_POST['fiv']) ? 1 : 0) : null; + $felv = array_key_exists('felv', $_POST) ? (isset($_POST['felv']) ? 1 : 0) : null; + + // Validations minimales + if ($name === '') { + http_response_code(400); + echo h(t('error.name_required')); + return; + } + if ($breed === '' && SpeciesService::isCat($species)) { + $breed = 'Chat Européen'; + } + if ($sex === '') { + $sex = 'U'; + } + + // Si naissance estimée et date vide => calcul auto depuis "mois" + if ($birth_is_estimated === 1 && $birth_date === '' && $birth_estimated_months !== null) { + $dt = new DateTime('now'); + $dt->modify('-' . $birth_estimated_months . ' months'); + $birth_date = $dt->format('Y-m-d'); + } + + // Normaliser dates vides en NULL (SQLite aime mieux) + $birth_date = $birth_date === '' ? null : $birth_date; + $intake_date = $intake_date === '' ? null : $intake_date; + + // Code interne : auto si vide (et idéalement readonly en edit) + $internal_code = trim((string) ($_POST['internal_code'] ?? '')); + if ($internal_code === '') { + $internal_code = self::nextInternalCode($name); + } + + // Quarantaine : durée issue des paramètres généraux. + $quarantine_until = null; + if (strtolower($status) === 'quarantaine' && $intake_date !== null) { + $ts = strtotime($intake_date); + if ($ts !== false) { + $quarantine_until = date('Y-m-d', $ts + AppSettings::int('quarantine_days', 1, 90) * 86400); + } + } else { + // si pas en quarantaine, on vide la sortie (optionnel, mais logique) + $quarantine_until = null; + } + + $rescueAddress = trim((string) ($_POST['rescue_address'] ?? '')); + $rescueLocationName = trim((string) ($_POST['rescue_location_name'] ?? '')); + $currentAddress = trim((string) ($_POST['current_address'] ?? '')); + $fosterContactId = (int) ($_POST['foster_contact_id'] ?? 0) ?: null; + $fosterContact = null; + if (!$isArchivedCorrection && in_array($status, ['fa', 'fa_permanente'], true) && $fosterContactId) { + $st = $db->prepare( + "SELECT dc.* FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='fa' WHERE dc.id=:id AND dc.deleted_at IS NULL", + ); + $st->execute([':id' => $fosterContactId]); + $fosterContact = $st->fetch(PDO::FETCH_ASSOC) ?: null; + if (!$fosterContact) { + http_response_code(400); + echo h(t('error.invalid_foster')); + return; + } + $currentAddress = + implode( + ' ', + array_filter([ + $fosterContact['address'] ?? '', + $fosterContact['postal_code'] ?? '', + $fosterContact['city'] ?? '', + ]), + ) ?: + 'Chez ' . $fosterContact['name']; + } + if ($status === 'chat_libre') { + $currentAddress = + trim(implode(' ', array_filter([$freeCatAddress, $freeCatCity]))) ?: + ($freeCatSiteName ?: + $currentAddress); + } + + $depositor = self::resolveDirectoryContact( + $db, + (int) ($_POST['depositor_contact_id'] ?? 0), + trim((string) ($_POST['new_depositor_name'] ?? '')), + 'person', + 'deposant', + ); + $depositorId = $depositor ? (int) $depositor['id'] : null; + + // Pour l’instant, tu bloques le lieu actuel + if ($isArchivedCorrection) { + $currentAddress = (string) ($old['current_address'] ?? ''); + } + if ($currentAddress === '' && !$isArchivedCorrection) { + $currentAddress = '5 RUE NOTRE DAME 29260 LESNEVEN'; + } + + // Géocode rescue si adresse présente + $rescueLat = null; + $rescueLng = null; + if ($rescueAddress !== '') { + $geo = GeoService::geocode($db, $rescueAddress); + if ($geo) { + $rescueLat = $geo['lat']; + $rescueLng = $geo['lng']; + } + } + + // Géocode current (toujours) + $currentLat = null; + $currentLng = null; + if ($currentAddress !== '' && !$isArchivedCorrection) { + $geo2 = GeoService::geocode($db, $currentAddress); + if ($geo2) { + $currentLat = $geo2['lat']; + $currentLng = $geo2['lng']; + } + } + + $fiv = $fiv ?? (int) ($old['fiv'] ?? 0); + $felv = $felv ?? (int) ($old['felv'] ?? 0); + $careBox = in_array($status, ['soin', 'quarantaine'], true) ? $old['care_box_key'] ?? null : null; + if ($isArchivedCorrection) { + $refugeRoom = $old['refuge_room'] ?? null; + $careBox = $old['care_box_key'] ?? null; + $quarantine_until = $old['quarantine_until'] ?? null; + $currentLat = $old['current_lat'] ?? null; + $currentLng = $old['current_lng'] ?? null; + } + + if ($id > 0) { + $stmt = $db->prepare(" + UPDATE animals SET + name=:name, + internal_code=:internal_code, + status=:status, + refuge_room=:refuge_room, + care_box_key=:care_box_key, + species=:species, + sex=:sex, + sterilized=:sterilized, + sterilization_status=:sterilization_status,sterilization_date=:sterilization_date,sterilization_vet_contact_id=:sterilization_vet,sterilization_clinic_contact_id=:sterilization_clinic, + chip_id=:chip_id, + identification_type=:identification_type,identification_date=:identification_date,identification_registration_status=:identification_registration_status, + adoption_availability=:adoption_availability,adoption_available_from=:adoption_available_from,adoption_unavailability_reason=:adoption_unavailability_reason, + website_published=:website_published,website_description=:website_description, + breed=:breed, + color=:color, + hair_type=:hair_type, + compatibility_dogs=:compatibility_dogs, + compatibility_cats=:compatibility_cats, + compatibility_children=:compatibility_children, + house_trained=:house_trained, + free_cat_site_name=:free_cat_site_name,free_cat_city=:free_cat_city,free_cat_address=:free_cat_address, + free_cat_caretaker_contact_id=:free_cat_caretaker_contact_id,free_cat_captured_at=:free_cat_captured_at, + free_cat_released_at=:free_cat_released_at,free_cat_tracking_status=:free_cat_tracking_status, + birth_date=:birth_date, + birth_is_estimated=:birth_is_estimated, + birth_estimated_months=:birth_estimated_months, + notes=:notes, + intake_date=:intake_date, + intake_type=:intake_type, + intake_reason=:intake_reason, + intake_owner_care_home=:intake_owner_care_home, + depositor_contact_id=:depositor_contact_id, + intake_circumstances=:intake_circumstances, + quarantine_until=:quarantine_until, + fiv=:fiv, + felv=:felv, + rescue_address=:rescue_address,rescue_location_name=:rescue_location_name, + rescue_location_confidence=CASE WHEN COALESCE(:rescue_location_name,'')=COALESCE(rescue_location_name,'') AND COALESCE(:rescue_address,'')=COALESCE(rescue_address,'') THEN rescue_location_confidence WHEN TRIM(COALESCE(:rescue_location_name,''))<>'' OR TRIM(COALESCE(:rescue_address,''))<>'' THEN 'documented' ELSE NULL END, + rescue_location_evidence=CASE WHEN COALESCE(:rescue_location_name,'')=COALESCE(rescue_location_name,'') AND COALESCE(:rescue_address,'')=COALESCE(rescue_address,'') THEN rescue_location_evidence WHEN TRIM(COALESCE(:rescue_location_name,''))<>'' OR TRIM(COALESCE(:rescue_address,''))<>'' THEN 'Lieu renseigné depuis la fiche animale' ELSE NULL END, + rescue_lat=:rescue_lat, rescue_lng=:rescue_lng, + current_address=:current_address, current_lat=:current_lat, current_lng=:current_lng, + updated_at=datetime('now') + WHERE id=:id + "); + + $stmt->execute([ + ':id' => $id, + ':name' => $name, + ':internal_code' => $internal_code, + ':status' => $status, + ':refuge_room' => $refugeRoom, + ':care_box_key' => $careBox, + ':species' => $species, + ':sex' => $sex, + ':sterilized' => $sterilized, + ':sterilization_status' => $sterilizationStatus, + ':sterilization_date' => $sterilizationDate ?: null, + ':sterilization_vet' => $sterilizationVetId, + ':sterilization_clinic' => $sterilizationClinicId, + ':chip_id' => $chip_id, + ':identification_type' => $identificationType, + ':identification_date' => $identificationDate ?: null, + ':identification_registration_status' => $identificationRegistration, + ':adoption_availability' => $adoptionAvailability, + ':adoption_available_from' => $adoptionAvailableFrom ?: null, + ':adoption_unavailability_reason' => $adoptionUnavailabilityReason ?: null, + ':website_published' => $websitePublished, + ':website_description' => $websiteDescription ?: null, + ':breed' => $breed, + ':color' => $color !== '' ? $color : null, + ':hair_type' => $hairType !== '' ? $hairType : null, + ':compatibility_dogs' => $compatibilityDogs, + ':compatibility_cats' => $compatibilityCats, + ':compatibility_children' => $compatibilityChildren, + ':house_trained' => $houseTrained, + ':free_cat_site_name' => $freeCatSiteName ?: null, + ':free_cat_city' => $freeCatCity ?: null, + ':free_cat_address' => $freeCatAddress ?: null, + ':free_cat_caretaker_contact_id' => $freeCatCaretakerId, + ':free_cat_captured_at' => $freeCatCapturedAt ?: null, + ':free_cat_released_at' => $freeCatReleasedAt ?: null, + ':free_cat_tracking_status' => $freeCatTrackingStatus, + ':birth_date' => $birth_date, + ':birth_is_estimated' => $birth_is_estimated, + ':birth_estimated_months' => $birth_estimated_months, + ':notes' => $notes, + ':intake_date' => $intake_date, + ':intake_type' => $intakeType, + ':intake_reason' => $intakeReason, + ':intake_owner_care_home' => $intakeOwnerCareHome, + ':depositor_contact_id' => $depositorId, + ':intake_circumstances' => $intakeCircumstances ?: null, + ':quarantine_until' => $quarantine_until, + ':fiv' => $fiv, + ':felv' => $felv, + ':rescue_address' => $rescueAddress, + ':rescue_location_name' => $rescueLocationName ?: null, + ':rescue_lat' => $rescueLat, + ':rescue_lng' => $rescueLng, + ':current_address' => $currentAddress, + ':current_lat' => $currentLat, + ':current_lng' => $currentLng, + ]); + + if ($old) { + LocationHistoryService::record( + $db, + $id, + $old, + [ + 'status' => $status, + 'refuge_room' => $refugeRoom, + 'care_box_key' => $careBox, + 'current_address' => $currentAddress, + ], + 'animal_form', + $locationChangeReason ?: 'Emplacement modifié depuis la fiche animale', + ); + } + + //self::logHistory( + // $id, + // 'update', + // 'Fiche modifiée' + //); + + // Code interne : en édition, on ne le regénère PAS + $internal_code = trim((string) ($_POST['internal_code'] ?? '')); + if ($id > 0) { + if ($internal_code === '' && $old && !empty($old['internal_code'])) { + $internal_code = (string) $old['internal_code']; + } + } else { + if ($internal_code === '') { + $internal_code = self::nextInternalCode($name); + } + } + + if ($old) { + // Les nouvelles valeurs “connues” (ce que tu viens de sauver) + // Astuce: tu mets TOUT ce que tu passes à l’UPDATE ici, mais sans maintenance lourde : + // si tu ajoutes un champ au formulaire + UPDATE, tu l’ajoutes déjà ici de toute façon. + $new = [ + 'name' => $name, + 'internal_code' => $internal_code, + 'status' => $status, + 'refuge_room' => $refugeRoom, + 'species' => $species, + 'sex' => $sex, + 'sterilized' => $sterilized, + 'sterilization_status' => $sterilizationStatus, + 'sterilization_date' => $sterilizationDate, + 'sterilization_vet_contact_id' => $sterilizationVetId, + 'sterilization_clinic_contact_id' => $sterilizationClinicId, + 'chip_id' => $chip_id, + 'identification_type' => $identificationType, + 'identification_date' => $identificationDate, + 'identification_registration_status' => $identificationRegistration, + 'adoption_availability' => $adoptionAvailability, + 'adoption_available_from' => $adoptionAvailableFrom, + 'adoption_unavailability_reason' => $adoptionUnavailabilityReason, + 'website_published' => $websitePublished, + 'website_description' => $websiteDescription, + 'breed' => $breed, + 'color' => $color, + 'hair_type' => $hairType, + 'compatibility_dogs' => $compatibilityDogs, + 'compatibility_cats' => $compatibilityCats, + 'compatibility_children' => $compatibilityChildren, + 'house_trained' => $houseTrained, + 'birth_date' => $birth_date, + 'notes' => $notes, + 'intake_date' => $intake_date, + 'intake_type' => $intakeType, + 'intake_reason' => $intakeReason, + 'intake_owner_care_home' => $intakeOwnerCareHome, + 'depositor_contact_id' => $depositorId, + 'intake_circumstances' => $intakeCircumstances, + 'rescue_location_name' => $rescueLocationName, + 'quarantine_until' => $quarantine_until, + 'fiv' => $fiv, + 'felv' => $felv, + // ajoute ici uniquement les champs que tu SAUVEGARDES dans UPDATE + // (si demain tu ajoutes 3 champs au form, tu les ajouteras déjà à l’UPDATE -> copie/colle aussi ici) + ]; + + // Champs à ignorer (bruit) + $ignore = ['id', 'created_at', 'updated_at', 'internal_code']; + + $changes = []; + foreach ($new as $field => $newVal) { + if (in_array($field, $ignore, true)) { + continue; + } + + $oldVal = $old[$field] ?? null; + + // Normalisation légère pour éviter les faux positifs + $o = is_null($oldVal) ? '' : (string) $oldVal; + $n = is_null($newVal) ? '' : (string) $newVal; + + if ($o !== $n) { + $label = match ($field) { + 'name' => 'Nom', + 'status' => 'Statut', + 'refuge_room' => 'Salle au refuge', + 'species' => 'Espèce', + 'sex' => 'Sexe', + 'sterilized' => 'Stérilisation', + 'sterilization_status' => 'État de stérilisation', + 'sterilization_date' => 'Date de stérilisation', + 'sterilization_vet_contact_id' => 'Vétérinaire de stérilisation', + 'sterilization_clinic_contact_id' => 'Cabinet de stérilisation', + 'chip_id' => 'Puce', + 'identification_type' => 'Type d’identification', + 'identification_date' => 'Date d’identification', + 'identification_registration_status' => 'Enregistrement I-CAD', + 'adoption_availability' => 'Disponibilité à l’adoption', + 'adoption_available_from' => 'Disponible à partir du', + 'adoption_unavailability_reason' => 'Motif d’indisponibilité', + 'website_published' => 'Publication sur le site', + 'website_description' => 'Présentation publique', + 'breed' => 'Race', + 'color' => 'Couleur / robe', + 'hair_type' => 'Type de poil', + 'compatibility_dogs' => 'Compatibilité chiens', + 'compatibility_cats' => 'Compatibilité chats', + 'compatibility_children' => 'Compatibilité enfants', + 'house_trained' => 'Propreté', + 'birth_date' => 'Naissance', + 'intake_date' => 'Arrivée', + 'intake_type' => 'Mode d’entrée', + 'intake_reason' => 'Motif d’entrée', + 'intake_owner_care_home' => 'Entrée du propriétaire en établissement', + 'depositor_contact_id' => 'Déposant', + 'intake_circumstances' => 'Circonstances de l’entrée', + 'rescue_location_name' => 'Commune d’origine', + 'quarantine_until' => 'Fin quarantaine', + 'fiv' => 'FIV', + 'felv' => 'FELV', + 'notes' => 'Notes', + default => $field, + }; + + // Evite d’exploser le log sur un gros champ notes + if (in_array($field, ['notes', 'website_description'], true)) { + $changes[] = "$label : (modifié)"; + } else { + if ( + in_array( + $field, + [ + 'compatibility_dogs', + 'compatibility_cats', + 'compatibility_children', + 'house_trained', + ], + true, + ) + ) { + $profileLabels = ['unknown' => 'Inconnu', 'yes' => 'Oui', 'no' => 'Non']; + $o = $profileLabels[$o] ?? $o; + $n = $profileLabels[$n] ?? $n; + } + $changes[] = "$label : " . ($o === '' ? '∅' : $o) . ' → ' . ($n === '' ? '∅' : $n); + } + } + } + + if (!empty($changes)) { + $statusChanged = (string) ($old['status'] ?? '') !== (string) $status; + $type = $statusChanged ? 'status' : 'update'; + $label = $statusChanged ? 'Changement de statut' : 'Fiche modifiée'; + + self::logHistory($id, $type, $label, implode("\n", $changes)); + } + } + } else { + $stmt = $db->prepare(" + INSERT INTO animals + (name, internal_code, status, refuge_room, species, sex, sterilized,sterilization_status,sterilization_date,sterilization_vet_contact_id,sterilization_clinic_contact_id, chip_id,identification_type,identification_date,identification_registration_status,adoption_availability,adoption_available_from,adoption_unavailability_reason,website_published,website_description, breed, color, hair_type, + compatibility_dogs,compatibility_cats,compatibility_children,house_trained, + free_cat_site_name,free_cat_city,free_cat_address,free_cat_caretaker_contact_id,free_cat_captured_at,free_cat_released_at,free_cat_tracking_status, + birth_date, birth_is_estimated, birth_estimated_months, + notes,intake_date,intake_type,intake_reason,intake_owner_care_home,depositor_contact_id,intake_circumstances,quarantine_until,fiv,felv,created_at,updated_at,rescue_address,rescue_location_name,rescue_location_confidence,rescue_location_evidence,rescue_lat,rescue_lng,current_address,current_lat,current_lng) + VALUES + (:name,:internal_code,:status,:refuge_room,:species,:sex,:sterilized,:sterilization_status,:sterilization_date,:sterilization_vet,:sterilization_clinic,:chip_id,:identification_type,:identification_date,:identification_registration_status,:adoption_availability,:adoption_available_from,:adoption_unavailability_reason,:website_published,:website_description,:breed,:color,:hair_type, + :compatibility_dogs,:compatibility_cats,:compatibility_children,:house_trained, + :free_cat_site_name,:free_cat_city,:free_cat_address,:free_cat_caretaker_contact_id,:free_cat_captured_at,:free_cat_released_at,:free_cat_tracking_status, + :birth_date,:birth_is_estimated,:birth_estimated_months, + :notes,:intake_date,:intake_type,:intake_reason,:intake_owner_care_home,:depositor_contact_id,:intake_circumstances,:quarantine_until,:fiv,:felv,datetime('now'),datetime('now'), + :rescue_address,:rescue_location_name,CASE WHEN TRIM(COALESCE(:rescue_location_name,''))<>'' OR TRIM(COALESCE(:rescue_address,''))<>'' THEN 'documented' ELSE NULL END,CASE WHEN TRIM(COALESCE(:rescue_location_name,''))<>'' OR TRIM(COALESCE(:rescue_address,''))<>'' THEN 'Lieu renseigné à la création de la fiche animale' ELSE NULL END,:rescue_lat,:rescue_lng,:current_address,:current_lat,:current_lng) + "); + + $stmt->execute([ + ':name' => $name, + ':internal_code' => $internal_code, + ':status' => $status, + ':refuge_room' => $refugeRoom, + ':species' => $species, + ':sex' => $sex, + ':sterilized' => $sterilized, + ':sterilization_status' => $sterilizationStatus, + ':sterilization_date' => $sterilizationDate ?: null, + ':sterilization_vet' => $sterilizationVetId, + ':sterilization_clinic' => $sterilizationClinicId, + ':chip_id' => $chip_id, + ':identification_type' => $identificationType, + ':identification_date' => $identificationDate ?: null, + ':identification_registration_status' => $identificationRegistration, + ':adoption_availability' => $adoptionAvailability, + ':adoption_available_from' => $adoptionAvailableFrom ?: null, + ':adoption_unavailability_reason' => $adoptionUnavailabilityReason ?: null, + ':website_published' => $websitePublished, + ':website_description' => $websiteDescription ?: null, + ':breed' => $breed, + ':color' => $color !== '' ? $color : null, + ':hair_type' => $hairType !== '' ? $hairType : null, + ':compatibility_dogs' => $compatibilityDogs, + ':compatibility_cats' => $compatibilityCats, + ':compatibility_children' => $compatibilityChildren, + ':house_trained' => $houseTrained, + ':free_cat_site_name' => $freeCatSiteName ?: null, + ':free_cat_city' => $freeCatCity ?: null, + ':free_cat_address' => $freeCatAddress ?: null, + ':free_cat_caretaker_contact_id' => $freeCatCaretakerId, + ':free_cat_captured_at' => $freeCatCapturedAt ?: null, + ':free_cat_released_at' => $freeCatReleasedAt ?: null, + ':free_cat_tracking_status' => $freeCatTrackingStatus, + ':birth_date' => $birth_date, + ':birth_is_estimated' => $birth_is_estimated, + ':birth_estimated_months' => $birth_estimated_months, + ':notes' => $notes, + ':intake_date' => $intake_date, + ':intake_type' => $intakeType, + ':intake_reason' => $intakeReason, + ':intake_owner_care_home' => $intakeOwnerCareHome, + ':depositor_contact_id' => $depositorId, + ':intake_circumstances' => $intakeCircumstances ?: null, + ':quarantine_until' => $quarantine_until, + ':fiv' => $fiv, + ':felv' => $felv, + ':rescue_address' => $rescueAddress, + ':rescue_location_name' => $rescueLocationName ?: null, + ':rescue_lat' => $rescueLat, + ':rescue_lng' => $rescueLng, + ':current_address' => $currentAddress, + ':current_lat' => $currentLat, + ':current_lng' => $currentLng, + ]); + + $id = (int) $db->lastInsertId(); + + LocationHistoryService::record( + $db, + $id, + null, + [ + 'status' => $status, + 'refuge_room' => $refugeRoom, + 'care_box_key' => null, + 'current_address' => $currentAddress, + ], + 'animal_creation', + 'Emplacement lors de la création de la fiche', + ); + + self::logHistory($id, 'create', 'Animal ajouté', "Nom : $name\nStatut : $status"); + } + + self::syncIntakeMovement( + $db, + $id, + $intake_date, + $intakeType, + $submittedIntakeReason, + $depositorId, + $rescueLocationName, + $rescueAddress, + $intakeCircumstances, + ); + + if (!$isArchivedCorrection && in_array($status, ['fa', 'fa_permanente'], true) && $fosterContactId) { + $latest = $db->prepare( + "SELECT contact_id FROM animal_placements WHERE animal_id=:id AND event_type='foster' ORDER BY date(event_date) DESC,id DESC LIMIT 1", + ); + $latest->execute([':id' => $id]); + $latestFosterId = (int) $latest->fetchColumn(); + $enteredFoster = !$old || !in_array((string) ($old['status'] ?? ''), ['fa', 'fa_permanente'], true); + if ($enteredFoster || $latestFosterId !== $fosterContactId) { + $db->prepare( + "INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(:animal,'foster',date('now'),:contact,:reason,:notes,:user)", + )->execute([ + ':animal' => $id, + ':contact' => $fosterContactId, + ':reason' => 'Changement d’emplacement depuis la fiche', + ':notes' => $status === 'fa_permanente' ? 'Famille d’accueil permanente' : null, + ':user' => Auth::id(), + ]); + } + } + + $initial_weight = trim((string) ($_POST['initial_weight'] ?? '')); + $initial_weight = $initial_weight === '' ? null : (float) $initial_weight; + + if ($initial_weight !== null && $initial_weight > 0) { + $stmt = $db->prepare(" + INSERT INTO measurements(animal_id, type, value, unit, measured_at) + VALUES(:aid,'weight',:v,'kg',datetime('now')) + "); + $stmt->execute([ + ':aid' => $id, + ':v' => $initial_weight, + ]); + } + + header('Location: /animal?id=' . $id); + exit(); + } +} diff --git a/app/Controllers/AnimalRelationshipActions.php b/app/Controllers/AnimalRelationshipActions.php new file mode 100644 index 0000000..e799555 --- /dev/null +++ b/app/Controllers/AnimalRelationshipActions.php @@ -0,0 +1,443 @@ + 0) { + $stmt = $db->prepare( + 'SELECT id, sex FROM animals WHERE id = :id AND deleted_at IS NULL AND archived_at IS NULL', + ); + $stmt->execute([':id' => $sourceAnimalId]); + $source = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$source) { + http_response_code(404); + echo h(t('error.animal_not_found')); + return; + } + if (($source['sex'] ?? '') === 'F') { + $motherId = $sourceAnimalId; + } + if (($source['sex'] ?? '') === 'M') { + $fatherId = $sourceAnimalId; + } + } + + $eligibleSql = " + SELECT id, name FROM animals + WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(species) IN ('chat', 'cat') AND sex = :sex + AND lower(status) NOT IN ('adopte', 'adopté', 'decede', 'décédé') + ORDER BY name COLLATE NOCASE + "; + $stmt = $db->prepare($eligibleSql); + $stmt->execute([':sex' => 'F']); + $mothers = $stmt->fetchAll(PDO::FETCH_ASSOC); + $stmt->execute([':sex' => 'M']); + $fathers = $stmt->fetchAll(PDO::FETCH_ASSOC); + $existingKittens = $db + ->query( + "SELECT a.id,a.name,a.internal_code,a.color,a.sex,a.status,a.archived_at FROM animals a WHERE a.deleted_at IS NULL AND lower(a.species) IN ('chat','cat') AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=a.id) ORDER BY CASE WHEN a.archived_at IS NULL THEN 0 ELSE 1 END,a.name COLLATE NOCASE,a.id", + ) + ->fetchAll(PDO::FETCH_ASSOC); + + render('litter_form.php', [ + 'title' => t('litter.create'), + 'pageDescription' => 'Création groupée des fiches d’une portée de chatons.', + 'sourceAnimalId' => $sourceAnimalId, + 'motherId' => $motherId, + 'fatherId' => $fatherId, + 'mothers' => $mothers, + 'fathers' => $fathers, + 'existingKittens' => $existingKittens, + ]); + } + + public static function saveLitter(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $motherId = (int) ($_POST['mother_id'] ?? 0) ?: null; + $fatherId = (int) ($_POST['father_id'] ?? 0) ?: null; + $birthDate = trim((string) ($_POST['birth_date'] ?? '')) ?: null; + $kittenMode = in_array((string) ($_POST['kitten_mode'] ?? 'new'), ['new', 'existing'], true) + ? (string) $_POST['kitten_mode'] + : 'new'; + if ($birthDate !== null && preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $birthDate, $dateParts)) { + $birthDate = $dateParts[3] . '-' . $dateParts[2] . '-' . $dateParts[1]; + } + $kittens = is_array($_POST['kittens'] ?? null) ? array_values($_POST['kittens']) : []; + $existingKittenIds = is_array($_POST['existing_kitten_ids'] ?? null) + ? array_values(array_unique(array_filter(array_map('intval', $_POST['existing_kitten_ids'])))) + : []; + if ($kittenMode === 'new') { + $existingKittenIds = []; + } else { + $kittens = []; + } + + if (!$kittens && !$existingKittenIds) { + http_response_code(400); + echo h(t('litter.need_kitten')); + return; + } + if ($motherId !== null && $fatherId !== null && $motherId === $fatherId) { + http_response_code(400); + echo h(t('litter.different_parents')); + return; + } + if ($birthDate !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate)) { + http_response_code(400); + echo h(t('error.invalid_birth_date')); + return; + } + + $parentIds = array_values(array_filter([$motherId, $fatherId])); + if ($parentIds) { + $placeholders = implode(',', array_fill(0, count($parentIds), '?')); + $stmt = $db->prepare( + "SELECT id, name, sex FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND archived_at IS NULL AND lower(species) IN ('chat','cat') AND lower(status) NOT IN ('adopte','adopté','decede','décédé')", + ); + $stmt->execute($parentIds); + $parents = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $parent) { + $parents[(int) $parent['id']] = $parent; + } + if ( + ($motherId && ($parents[$motherId]['sex'] ?? '') !== 'F') || + ($fatherId && ($parents[$fatherId]['sex'] ?? '') !== 'M') + ) { + http_response_code(400); + echo h(t('litter.invalid_parent')); + return; + } + } else { + $parents = []; + } + + if ($existingKittenIds) { + $placeholders = implode(',', array_fill(0, count($existingKittenIds), '?')); + $stmt = $db->prepare( + "SELECT id FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat') AND id NOT IN (?,?) AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=animals.id)", + ); + $stmt->execute([...$existingKittenIds, (int) ($motherId ?? 0), (int) ($fatherId ?? 0)]); + if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) !== count($existingKittenIds)) { + http_response_code(400); + echo h(t('litter.invalid_selected')); + return; + } + } + + $baseName = trim((string) ($parents[$motherId]['name'] ?? ($parents[$fatherId]['name'] ?? 'Portée'))); + $allowedSexes = ['F', 'M', 'U']; + try { + PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic === 1); + } catch (Throwable $e) { + http_response_code(500); + echo h(t('error.upload_failed')); + return; + } + $db->beginTransaction(); + try { + $stmt = $db->prepare( + 'INSERT INTO litters(mother_id, father_id, birth_date) VALUES(:mother, :father, :birth)', + ); + $stmt->execute([':mother' => $motherId, ':father' => $fatherId, ':birth' => $birthDate]); + $litterId = (int) $db->lastInsertId(); + $insertAnimal = $db->prepare(" + INSERT INTO animals(name, internal_code, status, species, sex, breed, color, birth_date, birth_is_estimated,intake_date,intake_type,intake_reason, created_at, updated_at) + VALUES(:name, :code, 'refuge', 'chat', :sex, 'Chat Européen', :color, :birth, 0,:birth,'born_in_care','birth', datetime('now'), datetime('now')) + "); + $link = $db->prepare( + 'INSERT INTO litter_kittens(litter_id, animal_id, position) VALUES(:litter, :animal, :position)', + ); + foreach ($kittens as $index => $kitten) { + if (!is_array($kitten)) { + continue; + } + $position = $index + 1; + $name = trim((string) ($kitten['name'] ?? '')); + if ($name === '') { + $name = $baseName . '-Bébé-' . $position; + } + $sex = (string) ($kitten['sex'] ?? 'U'); + if (!in_array($sex, $allowedSexes, true)) { + $sex = 'U'; + } + $color = trim((string) ($kitten['color'] ?? '')) ?: null; + $insertAnimal->execute([ + ':name' => $name, + ':code' => Ids::nextAnimalCode($name), + ':sex' => $sex, + ':color' => $color, + ':birth' => $birthDate, + ]); + $animalId = (int) $db->lastInsertId(); + $link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => $position]); + self::syncIntakeMovement( + $db, + $animalId, + $birthDate, + 'born_in_care', + 'birth', + null, + '', + '', + 'Naissance enregistrée avec la portée n°' . $litterId, + ); + LocationHistoryService::record( + $db, + $animalId, + null, + ['status' => 'refuge', 'refuge_room' => null, 'care_box_key' => null, 'current_address' => null], + 'litter_creation', + 'Emplacement lors de la création de la portée', + $birthDate ? $birthDate . ' 12:00:00' : null, + ); + self::logHistory($animalId, 'create', 'Chaton ajouté avec sa portée', 'Portée n°' . $litterId); + } + $position = count($kittens); + foreach ($existingKittenIds as $animalId) { + $link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => ++$position]); + self::logHistory($animalId, 'update', 'Rattaché à une nouvelle portée', 'Portée n°' . $litterId); + } + $db->commit(); + $targetId = (int) ($_POST['source_animal_id'] ?? 0) ?: ($motherId ?: ($fatherId ?: 0)); + header('Location: ' . ($targetId ? '/animal?id=' . $targetId . '#tab-litters' : '/animals')); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + + public static function litterAddExistingForm(): void + { + $db = DB::pdo(); + $litterId = (int) ($_GET['litter_id'] ?? 0); + $sourceAnimalId = (int) ($_GET['animal_id'] ?? 0); + $stmt = $db->prepare( + 'SELECT l.*,m.name mother_name,f.name father_name FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id WHERE l.id=:id', + ); + $stmt->execute([':id' => $litterId]); + $litter = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$litter) { + http_response_code(404); + echo h(t('litter.not_found')); + return; + } + $stmt = $db->prepare(" + SELECT a.id,a.name,a.internal_code,a.color,a.sex,a.status,a.archived_at + FROM animals a + WHERE a.deleted_at IS NULL AND lower(a.species) IN ('chat','cat') + AND a.id NOT IN (COALESCE(:mother,0),COALESCE(:father,0)) + AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=a.id) + ORDER BY CASE WHEN a.archived_at IS NULL THEN 0 ELSE 1 END,a.name COLLATE NOCASE,a.id + "); + $stmt->execute([':mother' => $litter['mother_id'], ':father' => $litter['father_id']]); + render('litter_add_existing.php', [ + 'title' => t('litter.add_title'), + 'pageDescription' => 'Rattacher des fiches animales existantes à une portée.', + 'litter' => $litter, + 'animals' => $stmt->fetchAll(PDO::FETCH_ASSOC), + 'sourceAnimalId' => $sourceAnimalId, + ]); + } + + public static function saveExistingLitterKittens(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $litterId = (int) ($_POST['litter_id'] ?? 0); + $sourceAnimalId = (int) ($_POST['source_animal_id'] ?? 0); + $ids = is_array($_POST['animal_ids'] ?? null) + ? array_values(array_unique(array_filter(array_map('intval', $_POST['animal_ids'])))) + : []; + if ($litterId <= 0 || !$ids) { + http_response_code(400); + echo h(t('litter.select_one')); + return; + } + $litterStmt = $db->prepare('SELECT id,mother_id,father_id FROM litters WHERE id=:id'); + $litterStmt->execute([':id' => $litterId]); + $litter = $litterStmt->fetch(PDO::FETCH_ASSOC); + if (!$litter) { + http_response_code(404); + echo h(t('litter.not_found')); + return; + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare( + "SELECT id FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat') AND id NOT IN (?,?) AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=animals.id)", + ); + $stmt->execute([...$ids, (int) ($litter['mother_id'] ?? 0), (int) ($litter['father_id'] ?? 0)]); + if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) !== count($ids)) { + http_response_code(400); + echo h(t('litter.invalid_existing')); + return; + } + $db->beginTransaction(); + try { + $position = (int) $db + ->query('SELECT COALESCE(MAX(position),0) FROM litter_kittens WHERE litter_id=' . $litterId) + ->fetchColumn(); + $link = $db->prepare( + 'INSERT INTO litter_kittens(litter_id,animal_id,position) VALUES(:litter,:animal,:position)', + ); + foreach ($ids as $animalId) { + $link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => ++$position]); + self::logHistory($animalId, 'update', 'Rattaché à une portée existante', 'Portée n°' . $litterId); + } + $db->commit(); + header( + 'Location: /animal?id=' . + ($sourceAnimalId ?: ($litter['mother_id'] ?: ($litter['father_id'] ?: $ids[0]))) . + '#tab-litters', + ); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + + public static function bondedForm(): void + { + $db = DB::pdo(); + $sourceAnimalId = (int) ($_GET['animal_id'] ?? 0); + if ($sourceAnimalId <= 0) { + http_response_code(400); + return; + } + $stmt = $db->prepare(" + SELECT a.id, a.name, a.color, a.species, a.status + FROM animals a + WHERE a.deleted_at IS NULL AND a.archived_at IS NULL + AND lower(a.species) IN ('chat', 'cat') + AND lower(a.status) NOT IN ('adopte', 'adopté', 'decede', 'décédé') + AND (a.id = :source OR NOT EXISTS ( + SELECT 1 FROM bonded_group_members bgm + JOIN bonded_groups bg ON bg.id = bgm.group_id AND bg.active = 1 + WHERE bgm.animal_id = a.id + )) + ORDER BY a.name COLLATE NOCASE + "); + $stmt->execute([':source' => $sourceAnimalId]); + $animals = $stmt->fetchAll(PDO::FETCH_ASSOC); + if (!array_filter($animals, fn(array $animal): bool => (int) $animal['id'] === $sourceAnimalId)) { + http_response_code(404); + echo h(t('bonded.unavailable')); + return; + } + render('bonded_form.php', [ + 'title' => t('bonded.create'), + 'pageDescription' => 'Associer plusieurs chats qui doivent rester ensemble.', + 'sourceAnimalId' => $sourceAnimalId, + 'animals' => $animals, + ]); + } + + public static function saveBondedGroup(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $ids = is_array($_POST['animal_ids'] ?? null) + ? array_values(array_unique(array_filter(array_map('intval', $_POST['animal_ids'])))) + : []; + if (count($ids) < 2) { + http_response_code(400); + echo h(t('bonded.need_two')); + return; + } + $db = DB::pdo(); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare(" + SELECT a.id FROM animals a + WHERE a.id IN ($placeholders) AND a.deleted_at IS NULL AND a.archived_at IS NULL + AND lower(a.species) IN ('chat', 'cat') + AND lower(a.status) NOT IN ('adopte', 'adopté', 'decede', 'décédé') + AND NOT EXISTS ( + SELECT 1 FROM bonded_group_members bgm JOIN bonded_groups bg ON bg.id=bgm.group_id + WHERE bgm.animal_id=a.id AND bg.active=1 + ) + "); + $stmt->execute($ids); + if (count($stmt->fetchAll()) !== count($ids)) { + http_response_code(400); + echo h(t('bonded.invalid_member')); + return; + } + $name = trim((string) ($_POST['name'] ?? '')) ?: null; + $notes = trim((string) ($_POST['notes'] ?? '')) ?: null; + $db->beginTransaction(); + try { + $stmt = $db->prepare('INSERT INTO bonded_groups(name, notes) VALUES(:name, :notes)'); + $stmt->execute([':name' => $name, ':notes' => $notes]); + $groupId = (int) $db->lastInsertId(); + $link = $db->prepare( + 'INSERT INTO bonded_group_members(group_id, animal_id, position) VALUES(:group_id, :animal_id, :position)', + ); + foreach ($ids as $index => $animalId) { + $link->execute([':group_id' => $groupId, ':animal_id' => $animalId, ':position' => $index + 1]); + self::logHistory( + $animalId, + 'update', + 'Ajouté à un groupe inséparable', + $name ?: 'Groupe n°' . $groupId, + ); + } + $db->commit(); + header('Location: /animal?id=' . $ids[0]); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + + public static function dissolveBondedGroup(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $groupId = (int) ($_POST['group_id'] ?? 0); + $animalId = (int) ($_POST['animal_id'] ?? 0); + if ($groupId <= 0) { + http_response_code(400); + return; + } + $db = DB::pdo(); + $members = $db->prepare('SELECT animal_id FROM bonded_group_members WHERE group_id=:id'); + $members->execute([':id' => $groupId]); + $memberIds = array_map('intval', array_column($members->fetchAll(PDO::FETCH_ASSOC), 'animal_id')); + $stmt = $db->prepare( + "UPDATE bonded_groups SET active=0, dissolved_at=datetime('now') WHERE id=:id AND active=1", + ); + $stmt->execute([':id' => $groupId]); + foreach ($memberIds as $memberId) { + self::logHistory($memberId, 'update', 'Groupe inséparable dissous'); + } + header('Location: ' . ($animalId > 0 ? '/animal?id=' . $animalId : '/animals')); + exit(); + } +} diff --git a/app/Controllers/AnimalsController.php b/app/Controllers/AnimalsController.php new file mode 100644 index 0000000..08e8ccc --- /dev/null +++ b/app/Controllers/AnimalsController.php @@ -0,0 +1,602 @@ + 'Non renseigné', + 'found' => 'Trouvé', + 'owner_surrender' => 'Cession par le propriétaire', + 'transfer' => 'Transfert d’une autre structure', + 'born_in_care' => 'Né sous la responsabilité du refuge', + 'return' => 'Retour après placement', + 'seizure' => 'Saisie ou réquisition', + 'other' => 'Autre mode d’entrée', + ]; + private const INTAKE_REASONS = [ + 'unknown' => 'Non renseigné', + 'stray_found' => 'Animal errant ou trouvé', + 'unwanted_litter' => 'Portée non désirée', + 'owner_health' => 'Santé ou hospitalisation du propriétaire', + 'owner_death' => 'Décès du propriétaire', + 'owner_care_home' => 'Entrée en EHPAD ou établissement spécialisé du propriétaire', + 'allergy' => 'Allergie', + 'housing' => 'Logement ou déménagement', + 'financial' => 'Difficultés financières', + 'behavior' => 'Difficultés comportementales', + 'abandonment' => 'Abandon sur place', + 'danger' => 'Mise en danger ou maltraitance', + 'transfer' => 'Transfert entre structures', + 'birth' => 'Naissance', + 'other' => 'Autre motif', + ]; + private static function intakeTypes(): array + { + $out = []; + foreach (self::INTAKE_TYPES as $key => $fallback) { + $out[$key] = t('statistics.intake.' . $key, [], $fallback); + } + return $out; + } + private static function intakeReasons(): array + { + $out = []; + foreach (self::INTAKE_REASONS as $key => $fallback) { + $out[$key] = t('statistics.reason.' . $key, [], $fallback); + } + return $out; + } + + public static function history(): void + { + $db = DB::pdo(); + $id = (int) ($_GET['id'] ?? 0); + if ($id <= 0) { + http_response_code(400); + return; + } + + $a = $db->prepare('SELECT * FROM animals WHERE id=:id'); + $a->execute([':id' => $id]); + $animal = $a->fetch(PDO::FETCH_ASSOC); + if (!$animal) { + http_response_code(404); + return; + } + + $h = $db->prepare(' + SELECT ah.*,COALESCE(u.display_name,u.username) actor_name FROM animal_history ah + LEFT JOIN users u ON u.id=ah.user_id + WHERE ah.animal_id=:id + ORDER BY ah.created_at DESC,ah.id DESC + LIMIT 500 + '); + $h->execute([':id' => $id]); + $history = $h->fetchAll(PDO::FETCH_ASSOC); + if (!PermissionService::can('medical')) { + $history = array_values( + array_filter( + $history, + static fn(array $row): bool => !in_array( + (string) ($row['type'] ?? ''), + ['medical', 'treatment', 'vaccine', 'deworming', 'death'], + true, + ), + ), + ); + } + + self::render('animal_history', [ + 'title' => 'Historique – ' . ($animal['name'] ?? ''), + 'animal' => $animal, + 'history' => $history, + ]); + } + + private static function logHistory(int $animalId, string $type, string $label, ?string $details = null): void + { + $db = DB::pdo(); + $db->prepare( + ' + INSERT INTO animal_history (animal_id, type, label, details, user_id) + VALUES (:aid, :type, :label, :details, :user) + ', + )->execute([ + ':aid' => $animalId, + ':type' => $type, + ':label' => $label, + ':details' => $details, + ':user' => Auth::id(), + ]); + } + + private static function syncIntakeMovement( + PDO $db, + int $animalId, + ?string $date, + string $type, + string $reason, + ?int $depositorId, + string $municipality, + string $address, + string $circumstances, + ): void { + $contact = null; + if ($depositorId) { + $s = $db->prepare('SELECT name,phone,email FROM directory_contacts WHERE id=:id AND deleted_at IS NULL'); + $s->execute([':id' => $depositorId]); + $contact = $s->fetch(PDO::FETCH_ASSOC) ?: null; + } + $s = $db->prepare( + "SELECT id FROM animal_movements WHERE animal_id=:animal AND kind='entry' ORDER BY id ASC LIMIT 1", + ); + $s->execute([':animal' => $animalId]); + $movementId = (int) $s->fetchColumn(); + $typeLabel = self::intakeTypes()[$type] ?? self::intakeTypes()['unknown']; + $reasonLabel = self::intakeReasons()[$reason] ?? self::intakeReasons()['unknown']; + $note = 'Motif : ' . $reasonLabel; + if ($circumstances !== '') { + $note .= "\nCirconstances : " . $circumstances; + } + $values = [ + ':animal' => $animalId, + ':place' => $municipality !== '' ? $municipality : ($address !== '' ? $address : null), + ':lieu' => $typeLabel, + ':name' => $contact['name'] ?? null, + ':phone' => $contact['phone'] ?? null, + ':email' => $contact['email'] ?? null, + ':note' => $note, + ':at' => ($date ?: date('Y-m-d')) . ' 12:00:00', + ]; + if ($movementId) { + $values[':id'] = $movementId; + $db->prepare( + 'UPDATE animal_movements SET place=:place,lieu=:lieu,contact_name=:name,contact_phone=:phone,contact_email=:email,note=:note,created_at=:at WHERE id=:id AND animal_id=:animal', + )->execute($values); + } else { + $db->prepare( + "INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,contact_phone,contact_email,note,created_at) VALUES(:animal,'entry',:place,:lieu,:name,:phone,:email,:note,:at)", + )->execute($values); + } + } + + private static function now(): string + { + return date('Y-m-d H:i:s'); + } + + private static function animalMediaDir(int $animalId): string + { + // public/media/animals/{id} + return __DIR__ . '/../../public/media/animals/' . $animalId; + } + + private static function ensureAnimalMediaDir(int $animalId): void + { + $dir = self::animalMediaDir($animalId); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + } + + private static function safeFilename(string $name): string + { + $name = preg_replace('~[^a-zA-Z0-9._-]+~', '_', $name); + $name = trim($name, '._-'); + return $name === '' ? 'photo' : $name; + } + + private static function resolveDirectoryContact( + PDO $db, + int $id, + string $newName, + string $kind, + string $role, + ): ?array { + if ($id > 0) { + $stmt = $db->prepare( + 'SELECT dc.id,dc.name,dc.organization_id FROM directory_contacts dc WHERE dc.id=:id AND dc.kind=:kind AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role=:role)', + ); + $stmt->execute([':id' => $id, ':kind' => $kind, ':role' => $role]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + $newName = trim($newName); + if ($newName === '') { + return null; + } + $stmt = $db->prepare( + 'SELECT id,name FROM directory_contacts WHERE deleted_at IS NULL AND kind=:kind AND lower(name)=lower(:name) LIMIT 1', + ); + $stmt->execute([':kind' => $kind, ':name' => $newName]); + $contact = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$contact) { + $db->prepare('INSERT INTO directory_contacts(kind,name) VALUES(:kind,:name)')->execute([ + ':kind' => $kind, + ':name' => $newName, + ]); + $contact = ['id' => (int) $db->lastInsertId(), 'name' => $newName, 'organization_id' => null]; + } + $db->prepare('INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:id,:role)')->execute([ + ':id' => $contact['id'], + ':role' => $role, + ]); + return $contact; + } + + private static function normalizeVeterinaryClinic(PDO $db, ?array &$vet, ?array &$clinic): void + { + if (!$vet) { + return; + } + $organizationId = (int) ($vet['organization_id'] ?? 0); + if ($organizationId > 0) { + $s = $db->prepare( + "SELECT id,name,organization_id FROM directory_contacts WHERE id=:id AND kind='organization' AND deleted_at IS NULL", + ); + $s->execute([':id' => $organizationId]); + $linked = $s->fetch(PDO::FETCH_ASSOC); + if ($linked) { + $clinic = $linked; + } + return; + } + if (!$clinic) { + return; + } + $db->prepare( + "UPDATE directory_contacts SET organization_id=:clinic,updated_at=datetime('now') WHERE id=:vet AND kind='person'", + )->execute([':clinic' => $clinic['id'], ':vet' => $vet['id']]); + $vet['organization_id'] = $clinic['id']; + } + + private static function isAllowedImage(string $tmpPath): bool + { + $info = @getimagesize($tmpPath); + if (!$info) { + return false; + } + $mime = $info['mime'] ?? ''; + return in_array($mime, ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], true); + } + + public static function togglePhotoVisibility(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + + $animalId = (int) ($_POST['animal_id'] ?? 0); + $photoId = (int) ($_POST['photo_id'] ?? 0); + $isPublic = (int) ($_POST['is_public'] ?? 1 ? 1 : 0); + + if ($animalId <= 0 || $photoId <= 0) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + + // onglet retour + $redirectHash = trim((string) ($_POST['redirect_hash'] ?? '#tab-photos')); + $hash = $redirectHash !== '' && $redirectHash[0] === '#' ? $redirectHash : '#tab-photos'; + + $db = DB::pdo(); + + // Récupère la photo ciblée + état actuel + $st = $db->prepare(' + SELECT id, is_primary, is_public, filename + FROM animal_photos + WHERE id = :pid AND animal_id = :aid + LIMIT 1 + '); + $st->execute([':pid' => $photoId, ':aid' => $animalId]); + $photo = $st->fetch(PDO::FETCH_ASSOC); + + if (!$photo) { + http_response_code(404); + echo h(t('error.photo_not_found')); + return; + } + + $wasPrimary = (int) ($photo['is_primary'] ?? 0) === 1; + $curPublic = (int) ($photo['is_public'] ?? 1); + + // Rien à faire si déjà le bon état + if ($curPublic === $isPublic) { + header('Location: /animal?id=' . $animalId . $hash); + exit(); + } + + try { + PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic === 1); + $db->beginTransaction(); + + // 1) Toggle visibilité + $db->prepare( + ' + UPDATE animal_photos + SET is_public = :pub + WHERE id = :pid AND animal_id = :aid + ', + )->execute([ + ':pub' => $isPublic, + ':pid' => $photoId, + ':aid' => $animalId, + ]); + + // 2) Si on rend PRIVÉ une photo qui était PRINCIPALE → on lui retire le flag principale + // et on promeut une autre photo publique (la plus récente) si possible. + if ($isPublic === 0 && $wasPrimary) { + // Retire principale à celle-ci + $db->prepare( + ' + UPDATE animal_photos + SET is_primary = 0 + WHERE id = :pid AND animal_id = :aid + ', + )->execute([':pid' => $photoId, ':aid' => $animalId]); + + // Cherche une photo publique à promouvoir + $newPrimaryId = $db + ->query( + ' + SELECT id + FROM animal_photos + WHERE animal_id = ' . + (int) $animalId . + ' + AND is_public = 1 + ORDER BY is_primary DESC, id DESC + LIMIT 1 + ', + ) + ->fetchColumn(); + + if ($newPrimaryId) { + // Met toutes les autres à 0 puis celle-là à 1 (propre) + $db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([ + ':aid' => $animalId, + ]); + $db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid AND animal_id=:aid')->execute([ + ':pid' => (int) $newPrimaryId, + ':aid' => $animalId, + ]); + } else { + // aucune publique => aucune principale (OK) + $db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([ + ':aid' => $animalId, + ]); + } + } + + // 3) Si on rend PUBLIC et qu'il n'y a aucune principale → cette photo devient principale + if ($isPublic === 1) { + $hasPrimary = (bool) $db + ->query( + ' + SELECT 1 + FROM animal_photos + WHERE animal_id=' . + (int) $animalId . + ' AND is_primary=1 + LIMIT 1 + ', + ) + ->fetchColumn(); + + if (!$hasPrimary) { + // On peut la promouvoir + $db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([ + ':aid' => $animalId, + ]); + $db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid AND animal_id=:aid')->execute([ + ':pid' => $photoId, + ':aid' => $animalId, + ]); + } + } + + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + try { + PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic !== 1); + } catch (Throwable) { + } + throw $e; + } + + // Historique (optionnel) + if (method_exists(__CLASS__, 'logHistory')) { + self::logHistory( + $animalId, + 'photo', + $isPublic ? 'Photo rendue publique' : 'Photo rendue privée', + (string) ($photo['filename'] ?? ''), + ); + } + + $db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]); + + header('Location: /animal?id=' . $animalId . $hash); + exit(); + } + + public static function icad(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + header('Allow: POST'); + return; + } + $db = DB::pdo(); + $id = (int) ($_POST['id'] ?? 0); + if ($id <= 0) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + + $force = (int) ($_POST['force'] ?? 0) === 1; + + // animal + puce + $st = $db->prepare('SELECT id, chip_id FROM animals WHERE id=:id LIMIT 1'); + $st->execute([':id' => $id]); + $animal = $st->fetch(PDO::FETCH_ASSOC); + if (!$animal) { + http_response_code(404); + echo h(t('error.animal_not_found')); + return; + } + + $chipId = preg_replace('/\D+/', '', (string) ($animal['chip_id'] ?? '')); + if ($chipId === '') { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['cached' => null, 'stale' => true, 'error' => 'chip_id vide'], JSON_UNESCAPED_UNICODE); + return; + } + + require_once __DIR__ . '/../Services/IcadService.php'; + $svc = new IcadService($db); + + $cached = $svc->getCachedByAnimal($id); + $stale = $svc->isStale($cached, 86400); + + if ($force || $stale) { + try { + $svc->refreshByChip($id, $chipId); + } catch (Throwable $e) { + // on laisse le cache en "error" + } + $cached = $svc->getCachedByAnimal($id); + $stale = $svc->isStale($cached, 86400); + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['cached' => $cached, 'stale' => $stale], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + public static function geocode(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + header('Allow: POST'); + return; + } + $db = DB::pdo(); + + $id = (int) ($_POST['id'] ?? 0); + if ($id <= 0) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + + $force = (int) ($_POST['force'] ?? 0) === 1; + + // Récupérer les adresses + coords existantes + $st = $db->prepare(' + SELECT id, + rescue_address, rescue_lat, rescue_lng, + current_address, current_lat, current_lng + FROM animals + WHERE id=:id + LIMIT 1 + '); + $st->execute([':id' => $id]); + $a = $st->fetch(PDO::FETCH_ASSOC); + if (!$a) { + http_response_code(404); + echo h(t('error.animal_not_found')); + return; + } + + require_once __DIR__ . '/../Services/GeocodeService.php'; + + $updates = []; + $debug = [ + 'animal_id' => $id, + 'force' => $force, + 'rescue' => null, + 'current' => null, + ]; + + // Rescue + $rescueAddr = trim((string) ($a['rescue_address'] ?? '')); + $hasRescueCoords = $a['rescue_lat'] !== null && $a['rescue_lng'] !== null; + + if ($rescueAddr !== '' && ($force || !$hasRescueCoords)) { + $g = GeocodeService::geocode($rescueAddr); + $debug['rescue'] = ['query' => $rescueAddr, 'result' => $g]; + if ($g) { + $updates['rescue_lat'] = $g['lat']; + $updates['rescue_lng'] = $g['lng']; + } + } + + // Current + $currentAddr = trim((string) ($a['current_address'] ?? '')); + $hasCurrentCoords = $a['current_lat'] !== null && $a['current_lng'] !== null; + + if ($currentAddr !== '' && ($force || !$hasCurrentCoords)) { + $g = GeocodeService::geocode($currentAddr); + $debug['current'] = ['query' => $currentAddr, 'result' => $g]; + if ($g) { + $updates['current_lat'] = $g['lat']; + $updates['current_lng'] = $g['lng']; + } + } + + if (!empty($updates)) { + $set = []; + $params = [':id' => $id]; + foreach ($updates as $k => $v) { + $set[] = "$k = :$k"; + $params[":$k"] = $v; + } + $sql = 'UPDATE animals SET ' . implode(', ', $set) . ", updated_at=datetime('now') WHERE id=:id"; + $u = $db->prepare($sql); + $u->execute($params); + } + + // Si appel AJAX/JSON + $wantsJson = isset($_POST['json']) && (int) $_POST['json'] === 1; + + if ($wantsJson) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode( + [ + 'ok' => true, + 'updated' => $updates, + 'debug' => $debug, + ], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + ); + return; + } + + // Sinon redirection propre vers l'onglet identité + header('Location: /animal?id=' . $id . '#tab-ident'); + exit(); + } +} diff --git a/app/Controllers/Asm3ImportController.php b/app/Controllers/Asm3ImportController.php new file mode 100644 index 0000000..4d05386 --- /dev/null +++ b/app/Controllers/Asm3ImportController.php @@ -0,0 +1,209 @@ +query( + 'SELECT j.*,COALESCE(u.display_name,u.username) actor FROM asm3_import_jobs j LEFT JOIN users u ON u.id=j.created_by ORDER BY j.id DESC LIMIT 20', + ) + ->fetchAll(PDO::FETCH_ASSOC); + render('settings_asm3.php', [ + 'title' => t('asm3.title'), + 'jobs' => $jobs, + 'error' => (string) ($_GET['error'] ?? ''), + 'success' => (string) ($_GET['success'] ?? ''), + ]); + } + public static function upload(): void + { + self::admin(); + $f = $_FILES['dump'] ?? null; + if (!is_array($f)) { + self::fail( + self::postTooLarge() + ? t('asm3.post_too_large', ['limit' => ini_get('post_max_size')]) + : t('asm3.select_dump'), + ); + } + $uploadError = (int) ($f['error'] ?? UPLOAD_ERR_NO_FILE); + if ($uploadError !== UPLOAD_ERR_OK) { + self::fail(self::uploadErrorMessage($uploadError)); + } + if (!is_uploaded_file((string) $f['tmp_name'])) { + self::fail(t('asm3.upload_unverified')); + } + if ((int) $f['size'] > 250_000_000) { + self::fail(t('asm3.over_250')); + } + $name = (string) $f['name']; + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if (!in_array($extension, ['sql', 'pgsql'], true)) { + self::fail(t('asm3.extension')); + } + $dir = dirname(__DIR__, 2) . '/storage/asm3-imports'; + if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { + self::fail(t('asm3.storage_unavailable')); + } + $stored = bin2hex(random_bytes(16)) . '.' . $extension; + $path = $dir . '/' . $stored; + if (!move_uploaded_file((string) $f['tmp_name'], $path)) { + self::fail(t('asm3.storage_failed')); + } + chmod($path, 0600); + try { + $analysis = Asm3Analyzer::analyze($path); + if (($analysis['tables']['animal'] ?? 0) < 1) { + throw new RuntimeException(t('asm3.no_animal')); + } + $animals = Asm3MigrationPlanner::plan($path, DB::pdo()); + $contacts = Asm3ContactPlanner::plan($path, DB::pdo()); + $report = [ + 'analysis' => $analysis, + 'animals' => $animals['summary'], + 'contacts' => $contacts['summary'], + 'movements' => Asm3MovementImporter::run($path, DB::pdo(), false)['summary'], + 'clinical' => Asm3ClinicalImporter::run($path, DB::pdo(), false), + 'litters' => Asm3LitterImporter::run($path, DB::pdo(), false), + 'media' => [ + 'metadata' => $analysis['tables']['media'] ?? 0, + 'importable' => 0, + 'reason' => 'Le dump SQL ne contient pas les fichiers binaires ASM3.', + ], + ]; + $db = DB::pdo(); + $db->prepare( + 'INSERT INTO asm3_import_jobs(original_name,stored_name,source_sha256,analysis_json,created_by) VALUES(:original,:stored,:hash,:analysis,:user)', + )->execute([ + ':original' => basename($name), + ':stored' => $stored, + ':hash' => hash_file('sha256', $path), + ':analysis' => json_encode($report, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ':user' => Auth::id(), + ]); + AuditService::log('asm3_analyzed', '/settings/asm3', 'Dump ASM3 analysé : ' . basename($name)); + header('Location: /settings/asm3?success=analyzed'); + exit(); + } catch (Throwable $e) { + @unlink($path); + self::fail($e->getMessage()); + } + } + public static function apply(): void + { + self::admin(); + $id = (int) ($_POST['id'] ?? 0); + $mode = ($_POST['mode'] ?? 'merge') === 'replace' ? 'replace' : 'merge'; + $expected = t($mode === 'replace' ? 'asm3.confirm_replace_phrase' : 'asm3.confirm_import_phrase'); + if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) { + self::fail(t('asm3.confirm_required', ['phrase' => $expected])); + } + $db = DB::pdo(); + $q = $db->prepare("SELECT * FROM asm3_import_jobs WHERE id=:id AND status='analyzed'"); + $q->execute([':id' => $id]); + $job = $q->fetch(PDO::FETCH_ASSOC); + $q->closeCursor(); + unset($q); + if (!$job) { + self::fail(t('asm3.analysis_unavailable')); + } + $path = dirname(__DIR__, 2) . '/storage/asm3-imports/' . basename($job['stored_name']); + if (!is_file($path) || !hash_equals($job['source_sha256'], hash_file('sha256', $path))) { + self::fail(t('asm3.dump_altered')); + } + try { + $backup = BackupService::create('pre-asm3-import'); + $db->beginTransaction(); + $removed = $mode === 'replace' ? Asm3ResetService::clear($db) : null; + $results = [ + 'mode' => $mode, + 'backup' => $backup['name'], + 'removed' => $removed, + 'animals' => Asm3AnimalImporter::run($path, $db, true, false), + 'contacts' => Asm3ContactImporter::run($path, $db, true, false), + 'movements' => Asm3MovementImporter::run($path, $db, true, false), + 'clinical' => Asm3ClinicalImporter::run($path, $db, true, false), + 'litters' => Asm3LitterImporter::run($path, $db, true, false), + ]; + $db->prepare( + "UPDATE asm3_import_jobs SET status='completed',result_json=:result,completed_at=datetime('now') WHERE id=:id", + )->execute([ + ':result' => json_encode($results, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ':id' => $id, + ]); + $db->commit(); + if ($mode === 'replace') { + Asm3ResetService::clearMedia(); + } + AuditService::log( + 'asm3_imported', + '/settings/asm3/apply', + 'Migration ASM3 terminée en mode ' . $mode . ' depuis ' . $job['original_name'], + ); + header('Location: /settings/asm3?success=imported'); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + $db->prepare( + "UPDATE asm3_import_jobs SET status='failed',result_json=:result,completed_at=datetime('now') WHERE id=:id", + )->execute([ + ':result' => json_encode(['error' => $e->getMessage(), 'mode' => $mode], JSON_UNESCAPED_UNICODE), + ':id' => $id, + ]); + self::fail(t('asm3.import_failed', ['error' => $e->getMessage()])); + } + } + private static function uploadErrorMessage(int $error): string + { + return match ($error) { + UPLOAD_ERR_INI_SIZE => t('asm3.upload_ini', ['limit' => ini_get('upload_max_filesize')]), + UPLOAD_ERR_FORM_SIZE => t('asm3.upload_form'), + UPLOAD_ERR_PARTIAL => t('asm3.upload_partial'), + UPLOAD_ERR_NO_FILE => t('asm3.select_dump'), + UPLOAD_ERR_NO_TMP_DIR => t('asm3.tmp_unavailable'), + UPLOAD_ERR_CANT_WRITE => t('asm3.write_failed'), + UPLOAD_ERR_EXTENSION => t('asm3.extension_stopped'), + default => t('asm3.upload_failed', ['code' => $error]), + }; + } + private static function postTooLarge(): bool + { + $length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); + $limit = self::iniBytes((string) ini_get('post_max_size')); + return $length > 0 && $limit > 0 && $length > $limit; + } + private static function iniBytes(string $value): int + { + $value = trim($value); + if ($value === '') { + return 0; + } + $number = (float) $value; + return (int) round( + $number * + match (strtolower(substr($value, -1))) { + 'g' => 1024 ** 3, + 'm' => 1024 ** 2, + 'k' => 1024, + default => 1, + }, + ); + } + private static function fail(string $message): never + { + header('Location: /settings/asm3?error=' . rawurlencode($message)); + exit(); + } + private static function admin(): void + { + if (!Auth::is('admin')) { + http_response_code(403); + exit(); + } + } +} diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..01d4903 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,199 @@ + 0) { + header('Location: /login'); + exit(); + } + $error = null; + $checks = InstallationService::runtimeChecks(); + $canInstall = InstallationService::canInstall($checks); + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + if (!Auth::validCsrf($_POST['csrf'] ?? null)) { + $error = t('auth.session_expired'); + } elseif (!$canInstall) { + $error = t('install.requirements_block'); + } else { + $username = trim((string) ($_POST['username'] ?? '')); + $display = trim((string) ($_POST['display_name'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + $confirm = (string) ($_POST['password_confirm'] ?? ''); + $association = trim((string) ($_POST['association_name'] ?? '')); + $email = trim((string) ($_POST['association_email'] ?? '')); + $language = (string) ($_POST['app_language'] ?? 'fr'); + $routing = (string) ($_POST['public_site_routing'] ?? 'integrated'); + if ($association === '') { + $error = t('install.association_required'); + } elseif ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $error = t('settings.invalid_email'); + } elseif (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username)) { + $error = t('auth.invalid_username'); + } elseif ($display === '') { + $error = t('auth.display_required'); + } elseif (strlen($password) < 10) { + $error = t('auth.password_min', ['count' => 10]); + } elseif ($password !== $confirm) { + $error = t('auth.password_mismatch'); + } else { + $db = DB::pdo(); + try { + $db->beginTransaction(); + $db->prepare( + "INSERT INTO users(username,password_hash,display_name,role) VALUES(:u,:p,:d,'admin')", + )->execute([ + ':u' => $username, + ':p' => password_hash($password, PASSWORD_DEFAULT), + ':d' => $display, + ]); + AppSettings::save( + [ + 'association_name' => $association, + 'association_address' => trim((string) ($_POST['association_address'] ?? '')), + 'association_postal_code' => trim((string) ($_POST['association_postal_code'] ?? '')), + 'association_city' => trim((string) ($_POST['association_city'] ?? '')), + 'association_phone' => trim((string) ($_POST['association_phone'] ?? '')), + 'association_email' => $email, + 'association_siret' => trim((string) ($_POST['association_siret'] ?? '')), + 'association_rna' => trim((string) ($_POST['association_rna'] ?? '')), + 'app_language' => isset(I18n::LOCALES[$language]) ? $language : 'fr', + 'public_site_routing' => in_array($routing, ['integrated', 'root'], true) + ? $routing + : 'integrated', + ], + (int) $db->lastInsertId(), + ); + $db->commit(); + Auth::login($username, $password); + AuditService::log( + 'setup', + '/setup', + 'Installation initiale et création du premier administrateur', + 'user', + Auth::id(), + ); + header('Location: /settings/health?installed=1'); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + $error = SecurityService::publicError($e); + } + } + } + } + self::view(t('auth.first_setup'), 'setup', $error, [ + 'checks' => $checks, + 'canInstall' => $canInstall, + 'publicPath' => InstallationService::publicPath(), + ]); + } + public static function setupUnavailable(array $checks): void + { + self::view(t('auth.first_setup'), 'setup', $error = t('install.runtime_missing'), [ + 'checks' => $checks, + 'canInstall' => false, + 'publicPath' => InstallationService::publicPath(), + ]); + } + public static function login(): void + { + if (Auth::countUsers() === 0) { + header('Location: /setup'); + exit(); + } + if (Auth::user()) { + header('Location: ' . PermissionService::firstAllowedPath()); + exit(); + } + $error = null; + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + if (!Auth::validCsrf($_POST['csrf'] ?? null)) { + $error = t('auth.session_expired'); + } elseif ( + Auth::login( + (string) ($_POST['username'] ?? ''), + (string) ($_POST['password'] ?? ''), + isset($_POST['remember_device']), + (string) ($_POST['device_name'] ?? ''), + ) + ) { + AuditService::log('login', '/login', 'Connexion réussie'); + $next = (string) ($_POST['next'] ?? ''); + if ( + !str_starts_with($next, '/') || + str_starts_with($next, '//') || + !PermissionService::canOpenPath((string) (parse_url($next, PHP_URL_PATH) ?: '/')) + ) { + $next = PermissionService::firstAllowedPath(); + } + header('Location: ' . $next); + exit(); + } else { + AuditService::log('login_failed', '/login', 'Échec de connexion', null, null, [ + 'username_hash' => hash('sha256', mb_strtolower(trim((string) ($_POST['username'] ?? '')))), + ]); + $error = t('auth.invalid_credentials'); + } + } + self::view(t('auth.login'), 'login', $error); + } + public static function logout(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !Auth::validCsrf($_POST['csrf'] ?? null)) { + http_response_code(400); + return; + } + AuditService::log('logout', '/logout', 'Déconnexion'); + Auth::logout(); + header('Location: /login'); + exit(); + } + private static function view(string $title, string $mode, ?string $error, array $vars = []): void + { + extract($vars, EXTR_SKIP); + require __DIR__ . '/../Views/auth.php'; + } +} diff --git a/app/Controllers/CareRoundsController.php b/app/Controllers/CareRoundsController.php new file mode 100644 index 0000000..07ee509 --- /dev/null +++ b/app/Controllers/CareRoundsController.php @@ -0,0 +1,463 @@ +prepare('SELECT * FROM care_rounds WHERE round_date=:date AND period=:period'); + $stmt->execute([':date' => $date, ':period' => $period]); + $round = $stmt->fetch(); + if (!$round) { + $round = ['id' => 0, 'round_date' => $date, 'period' => $period, 'performed_by_user_id' => null]; + } + $layouts = []; + foreach ($db->query('SELECT * FROM care_room_layouts')->fetchAll() as $l) { + $layouts[$l['room']] = $l; + } + $animals = $db + ->query( + " + SELECT a.*, p.filename AS primary_photo + FROM animals a LEFT JOIN animal_photos p ON p.animal_id=a.id AND p.is_primary=1 + WHERE a.deleted_at IS NULL AND a.archived_at IS NULL + AND lower(a.status) IN ('refuge','soin','quarantaine') + ORDER BY a.name COLLATE NOCASE + ", + ) + ->fetchAll(); + $observations = []; + $administrations = []; + if ((int) $round['id'] > 0) { + $stmt = $db->prepare('SELECT * FROM care_round_observations WHERE round_id=:id'); + $stmt->execute([':id' => $round['id']]); + foreach ($stmt->fetchAll() as $o) { + $observations[(int) $o['animal_id']] = $o; + } + $stmt = $db->prepare('SELECT treatment_id,status FROM treatment_administrations WHERE round_id=:id'); + $stmt->execute([':id' => $round['id']]); + foreach ($stmt->fetchAll() as $a) { + $administrations[(int) $a['treatment_id']] = $a['status']; + } + } + $treatments = []; + $stmt = $db->prepare(" + SELECT t.*,m.name AS medication_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id + WHERE t.ongoing=1 AND ((:period='morning' AND t.give_morning=1) OR (:period='evening' AND t.give_evening=1)) + ORDER BY m.name + "); + $stmt->execute([':period' => $period]); + foreach ($stmt->fetchAll() as $t) { + $treatments[(int) $t['animal_id']][] = $t; + } + $unconfigured = []; + $rows = $db + ->query( + 'SELECT t.id,t.animal_id,t.dose_text,m.name medication_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id WHERE t.ongoing=1 AND t.give_morning=0 AND t.give_evening=0 AND t.give_as_needed=0 ORDER BY m.name', + ) + ->fetchAll(); + foreach ($rows as $r) { + $unconfigured[(int) $r['animal_id']][] = $r; + } + render('care_round.php', [ + 'title' => t('care.title'), + 'pageDescription' => t('care.description'), + 'period' => $period, + 'date' => $date, + 'round' => $round, + 'layouts' => $layouts, + 'rooms' => ShelterRoomService::all(true), + 'animals' => $animals, + 'treatmentsByAnimal' => $treatments, + 'unconfiguredTreatments' => $unconfigured, + 'observations' => $observations, + 'administrations' => $administrations, + ]); + } + + public static function saveObservation(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $roundId = self::ensureRound($db); + $animalId = (int) ($_POST['animal_id'] ?? 0); + if (!$roundId || !$animalId) { + http_response_code(400); + return; + } + $animalCheck = $db->prepare( + "SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')", + ); + $animalCheck->execute([$animalId]); + if (!$animalCheck->fetchColumn()) { + http_response_code(404); + return; + } + $allowed = [ + 'food' => ['normal', 'little', 'refused'], + 'water' => ['normal', 'renewed', 'abnormal'], + 'urine' => ['normal', 'absent', 'abnormal'], + 'stool' => ['normal', 'absent', 'abnormal'], + 'general_state' => ['good', 'watch', 'alert'], + ]; + $values = []; + foreach ($allowed as $field => $opts) { + $value = (string) ($_POST[$field] ?? $opts[0]); + $values[$field] = in_array($value, $opts, true) ? $value : $opts[0]; + } + $db->prepare( + 'UPDATE care_rounds SET performed_by_user_id=COALESCE(performed_by_user_id,:user) WHERE id=:id', + )->execute([':user' => Auth::id(), ':id' => $roundId]); + $comment = trim((string) ($_POST['comment'] ?? '')); + $photoComment = trim((string) ($_POST['photo_comment'] ?? '')); + $photo = $_FILES['care_photo'] ?? null; + $hasPhoto = is_array($photo) && ($photo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE; + if ($hasPhoto) { + if ( + ($photo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || + !is_uploaded_file((string) $photo['tmp_name']) || + ($photo['size'] ?? 0) > 20 * 1024 * 1024 || + $photoComment === '' + ) { + http_response_code(400); + echo h(t('care.invalid_photo')); + return; + } + $imageInfo = @getimagesize((string) $photo['tmp_name']); + $mime = (string) ($imageInfo['mime'] ?? ''); + $extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; + if (!isset($extensions[$mime])) { + http_response_code(400); + echo h(t('care.invalid_photo_format')); + return; + } + } + $admins = is_array($_POST['treatments'] ?? null) ? $_POST['treatments'] : []; + $skippedIds = []; + foreach ($admins as $treatmentId => $status) { + if ($status === 'skipped') { + $skippedIds[] = (int) $treatmentId; + } + } + $alerts = []; + if ($values['food'] === 'little') { + $alerts[] = 'A peu mangé'; + } elseif ($values['food'] === 'refused') { + $alerts[] = 'A refusé de manger'; + } + if ($values['water'] === 'abnormal') { + $alerts[] = 'Eau anormale'; + } + if ($values['urine'] === 'absent') { + $alerts[] = 'Urines absentes'; + } elseif ($values['urine'] === 'abnormal') { + $alerts[] = 'Urines anormales'; + } + if ($values['stool'] === 'absent') { + $alerts[] = 'Selles absentes'; + } elseif ($values['stool'] === 'abnormal') { + $alerts[] = 'Selles anormales'; + } + if ($values['general_state'] === 'watch') { + $alerts[] = 'État général à surveiller'; + } elseif ($values['general_state'] === 'alert') { + $alerts[] = 'Alerte sur l’état général'; + } + if ($comment !== '') { + $alerts[] = 'Commentaire : ' . $comment; + } + foreach ($skippedIds as $treatmentId) { + $nameStmt = $db->prepare( + 'SELECT m.name FROM treatments t LEFT JOIN ref_medications m ON m.id=t.medication_id WHERE t.id=:id AND t.animal_id=:animal', + ); + $nameStmt->execute([':id' => $treatmentId, ':animal' => $animalId]); + $alerts[] = 'Traitement non donné : ' . ($nameStmt->fetchColumn() ?: 'traitement #' . $treatmentId); + } + $signature = $alerts + ? hash('sha256', json_encode([$values, $comment, $skippedIds], JSON_UNESCAPED_UNICODE)) + : null; + $previousStmt = $db->prepare( + 'SELECT alert_signature FROM care_round_observations WHERE round_id=:round AND animal_id=:animal', + ); + $previousStmt->execute([':round' => $roundId, ':animal' => $animalId]); + $previousSignature = $previousStmt->fetchColumn() ?: null; + + $savedPhotoPath = null; + $db->beginTransaction(); + try { + $stmt = $db->prepare( + "INSERT INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state,comment,alert_signature) VALUES(:round,:animal,:food,:water,:urine,:stool,:general,:comment,:signature) ON CONFLICT(round_id,animal_id) DO UPDATE SET food=excluded.food,water=excluded.water,urine=excluded.urine,stool=excluded.stool,general_state=excluded.general_state,comment=excluded.comment,alert_signature=excluded.alert_signature,checked_at=datetime('now')", + ); + $stmt->execute([ + ':round' => $roundId, + ':animal' => $animalId, + ':food' => $values['food'], + ':water' => $values['water'], + ':urine' => $values['urine'], + ':stool' => $values['stool'], + ':general' => $values['general_state'], + ':comment' => $comment ?: null, + ':signature' => $signature, + ]); + $ins = $db->prepare( + "INSERT INTO treatment_administrations(round_id,treatment_id,animal_id,status,comment) VALUES(:round,:treatment,:animal,:status,:comment) ON CONFLICT(round_id,treatment_id) DO UPDATE SET status=excluded.status,comment=excluded.comment,administered_at=datetime('now')", + ); + $treatmentCheck = $db->prepare('SELECT 1 FROM treatments WHERE id=? AND animal_id=? AND ongoing=1'); + foreach ($admins as $treatmentId => $status) { + if (!in_array($status, ['given', 'skipped'], true)) { + continue; + } + $treatmentCheck->execute([(int) $treatmentId, $animalId]); + if (!$treatmentCheck->fetchColumn()) { + continue; + } + $ins->execute([ + ':round' => $roundId, + ':treatment' => (int) $treatmentId, + ':animal' => $animalId, + ':status' => $status, + ':comment' => null, + ]); + } + if ($hasPhoto) { + $dir = PrivateMediaService::animalDir($animalId); + PrivateMediaService::ensure($dir); + $stored = ImageService::storeUploaded((string) $photo['tmp_name'], $dir, 'tournee', 'medical'); + $filename = $stored['filename']; + $savedPhotoPath = $stored['path']; + $mime = $stored['mime']; + @chmod($savedPhotoPath, 0600); + $photoStmt = $db->prepare( + 'INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption,care_round_id) VALUES(:animal,:filename,:original,:mime,:size,0,0,:caption,:round)', + ); + $photoStmt->execute([ + ':animal' => $animalId, + ':filename' => $filename, + ':original' => (string) ($photo['name'] ?? 'photo'), + ':mime' => $mime, + ':size' => $stored['size'], + ':caption' => $photoComment, + ':round' => $roundId, + ]); + $alerts[] = 'Photo privée ajoutée : ' . $photoComment; + } + if (($signature !== null && $signature !== $previousSignature) || $hasPhoto) { + $roundStmt = $db->prepare('SELECT round_date,period FROM care_rounds WHERE id=:id'); + $roundStmt->execute([':id' => $roundId]); + $roundInfo = $roundStmt->fetch(); + $roundDate = $roundInfo ? date('d/m/Y', strtotime((string) $roundInfo['round_date'])) : date('d/m/Y'); + $roundPeriod = ($roundInfo['period'] ?? 'morning') === 'evening' ? 'soir' : 'matin'; + $history = $db->prepare( + "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:animal,'care_round',:label,:details,:user)", + ); + $history->execute([ + ':animal' => $animalId, + ':label' => 'Anomalie durant la tournée du ' . $roundPeriod . ' — ' . $roundDate, + ':details' => implode("\n", $alerts), + ':user' => Auth::id(), + ]); + } + $db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]); + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + if ($savedPhotoPath && is_file($savedPhotoPath)) { + @unlink($savedPhotoPath); + } + throw $e; + } + $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + $date = (string) ($_POST['date'] ?? date('Y-m-d')); + header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#animal-' . $animalId); + exit(); + } + + public static function saveRoom(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $roundId = self::ensureRound($db); + $room = (string) ($_POST['room'] ?? ''); + $rooms = ShelterRoomService::byCode(true); + if (!$roundId || !isset($rooms[$room]) || empty($rooms[$room]['bulk_validation'])) { + http_response_code(400); + return; + } + + $animals = $db + ->query( + "SELECT id,status,refuge_room FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $stmt = $db->prepare( + "INSERT OR IGNORE INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state) VALUES(:round,:animal,'normal','normal','normal','normal','good')", + ); + foreach ($animals as $animal) { + if (ShelterRoomService::resolveAnimal($animal, $rooms) === $room) { + $stmt->execute([':round' => $roundId, ':animal' => $animal['id']]); + } + } + + $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? '')) + ? (string) $_POST['date'] + : date('Y-m-d'); + header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#room-' . $room); + exit(); + } + + public static function setup(): void + { + $db = DB::pdo(); + $layouts = []; + foreach ($db->query('SELECT * FROM care_room_layouts')->fetchAll() as $l) { + $layouts[$l['room']] = $l; + } + $animals = $db + ->query( + "SELECT id,name,status,refuge_room,care_box_key,current_address FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine') ORDER BY name COLLATE NOCASE", + ) + ->fetchAll(); + render('care_setup.php', [ + 'title' => t('care.setup_title'), + 'layouts' => $layouts, + 'rooms' => ShelterRoomService::all(true), + 'animals' => $animals, + ]); + } + + public static function saveSetup(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $topOpts = ['split', 'left2', 'right2', 'merged']; + $bottomOpts = ['split', 'merged']; + foreach (['care', 'quarantine'] as $room) { + $top = (string) ($_POST[$room . '_top'] ?? 'split'); + $bottom = (string) ($_POST[$room . '_bottom'] ?? 'split'); + if (!in_array($top, $topOpts, true)) { + $top = 'split'; + } + if (!in_array($bottom, $bottomOpts, true)) { + $bottom = 'split'; + } + $db->prepare( + "UPDATE care_room_layouts SET top_layout=:top,bottom_layout=:bottom,updated_at=datetime('now') WHERE room=:room", + )->execute([':top' => $top, ':bottom' => $bottom, ':room' => $room]); + } + $assignments = is_array($_POST['box'] ?? null) ? $_POST['box'] : []; + $roomAssignments = is_array($_POST['room'] ?? null) ? $_POST['room'] : []; + $rooms = ShelterRoomService::byCode(true); + $locationReason = trim((string) ($_POST['location_change_reason'] ?? '')); + $select = $db->prepare( + 'SELECT status,refuge_room,care_box_key,current_address FROM animals WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL', + ); + $stmt = $db->prepare( + "UPDATE animals SET status=:status,refuge_room=:room,care_box_key=:box,updated_at=datetime('now') WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL", + ); + foreach ($roomAssignments as $id => $roomCode) { + $animalId = (int) $id; + $select->execute([':id' => $animalId]); + $before = $select->fetch(PDO::FETCH_ASSOC); + if (!$before) { + continue; + } + $roomCode = (string) $roomCode; + if (!isset($rooms[$roomCode])) { + continue; + } + $room = $rooms[$roomCode]; + $allowedBoxes = array_column( + array_filter($room['boxes'], static fn($box) => (int) $box['active'] === 1), + 'code', + ); + if (in_array($roomCode, ['care', 'quarantine'], true)) { + $allowedBoxes = array_values( + array_unique(array_merge($allowedBoxes, ['top_ab', 'top_bc', 'top_all', 'bottom_all'])), + ); + } + $newBox = trim((string) ($assignments[$id] ?? '')); + if ($room['room_type'] !== 'boxes' || !in_array($newBox, $allowedBoxes, true)) { + $newBox = null; + } + $status = (string) $room['status_code']; + $stmt->execute([':status' => $status, ':room' => $roomCode, ':box' => $newBox, ':id' => $animalId]); + LocationHistoryService::record( + $db, + $animalId, + $before, + array_merge($before, ['status' => $status, 'refuge_room' => $roomCode, 'care_box_key' => $newBox]), + 'care_setup', + $locationReason ?: 'Attribution de la salle ou du box depuis la tournée', + ); + } + header('Location: /care-round'); + exit(); + } + + public static function saveTreatmentSchedule(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $id = (int) ($_POST['treatment_id'] ?? 0); + $schedule = (string) ($_POST['schedule'] ?? ''); + $values = match ($schedule) { + 'morning' => [1, 0, 0], + 'evening' => [0, 1, 0], + 'both' => [1, 1, 0], + 'needed' => [0, 0, 1], + default => null, + }; + if (!$id || !$values) { + http_response_code(400); + return; + } + DB::pdo() + ->prepare('UPDATE treatments SET give_morning=?,give_evening=?,give_as_needed=? WHERE id=?') + ->execute([$values[0], $values[1], $values[2], $id]); + $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + header('Location: /care-round?period=' . $period); + exit(); + } + + private static function ensureRound(PDO $db): int + { + $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? '')) + ? (string) $_POST['date'] + : date('Y-m-d'); + $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + $requested = (int) ($_POST['round_id'] ?? 0); + if ($requested) { + $s = $db->prepare('SELECT id FROM care_rounds WHERE id=? AND round_date=? AND period=?'); + $s->execute([$requested, $date, $period]); + if ($s->fetchColumn()) { + return $requested; + } + } + $db->prepare( + 'INSERT OR IGNORE INTO care_rounds(round_date,period,performed_by_user_id) VALUES(?,?,?)', + )->execute([$date, $period, Auth::id()]); + $s = $db->prepare('SELECT id FROM care_rounds WHERE round_date=? AND period=?'); + $s->execute([$date, $period]); + return (int) $s->fetchColumn(); + } +} diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..00646ed --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,300 @@ +exec(" + UPDATE animals + SET intake_date = date(updated_at) + WHERE lower(trim(status))='quarantaine' + AND deleted_at IS NULL AND archived_at IS NULL + AND (intake_date IS NULL OR trim(intake_date)='') + AND updated_at IS NOT NULL AND trim(updated_at)<>'' + "); + + $quarantineDays = AppSettings::int('quarantine_days', 1, 90); + $stmt = $db->prepare(" + UPDATE animals + SET quarantine_until = date(intake_date, '+' || :days || ' day') + WHERE lower(trim(status))='quarantaine' + AND deleted_at IS NULL AND archived_at IS NULL + AND (quarantine_until IS NULL OR trim(quarantine_until)='') + AND intake_date IS NOT NULL AND trim(intake_date)<>'' + "); + $stmt->execute([':days' => $quarantineDays]); + + // Stats par statut (avec tolérance accents) + $stats = $db + ->query( + " + SELECT + SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') THEN 1 ELSE 0 END) AS total, + SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND birth_date IS NOT NULL AND trim(birth_date) != '' AND date(birth_date) > date('now','-12 months') THEN 1 ELSE 0 END) AS kittens, + SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND birth_date IS NOT NULL AND trim(birth_date) != '' AND date(birth_date) <= date('now','-12 months') THEN 1 ELSE 0 END) AS adults, + SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND (birth_date IS NULL OR trim(birth_date) = '') THEN 1 ELSE 0 END) AS age_unknown, + SUM(CASE WHEN status='refuge' THEN 1 ELSE 0 END) AS refuge, + SUM(CASE WHEN status='refuge' AND refuge_room='silver' THEN 1 ELSE 0 END) AS refuge_silver, + SUM(CASE WHEN status='refuge' AND refuge_room='twix' THEN 1 ELSE 0 END) AS refuge_twix, + SUM(CASE WHEN status='refuge' AND (refuge_room IS NULL OR trim(refuge_room)='') THEN 1 ELSE 0 END) AS refuge_unassigned, + SUM(CASE WHEN status='fa' THEN 1 ELSE 0 END) AS fa, + SUM(CASE WHEN status='fa_permanente' THEN 1 ELSE 0 END) AS fa_permanente, + SUM(CASE WHEN status='quarantaine' THEN 1 ELSE 0 END) AS quarantaine, + SUM(CASE WHEN status='soin' THEN 1 ELSE 0 END) AS soin, + SUM(CASE WHEN status='isolation' THEN 1 ELSE 0 END) AS isolation, + SUM(CASE WHEN status='hospitalise' OR status='hospitalisé' THEN 1 ELSE 0 END) AS hospitalise, + SUM(CASE WHEN status='reserve' OR status='réservé' THEN 1 ELSE 0 END) AS reserve, + SUM(CASE WHEN status='adopte' OR status='adopté' THEN 1 ELSE 0 END) AS adopte, + SUM(CASE WHEN status='decede' OR status='décédé' THEN 1 ELSE 0 END) AS dece, + SUM(CASE WHEN status='enfui' THEN 1 ELSE 0 END) AS enfui + FROM animals + WHERE deleted_at IS NULL AND archived_at IS NULL + ", + ) + ->fetch(); + $shelterRoomCounts = []; + $configuredRooms = ShelterRoomService::byCode(true); + $roomAnimals = $db + ->query( + "SELECT status,refuge_room FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status IN ('refuge','soin','quarantaine')", + ) + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($configuredRooms as $code => $room) { + $shelterRoomCounts[$code] = [ + 'name' => $room['name'], + 'color' => $room['color'], + 'status' => $room['status_code'], + 'count' => 0, + ]; + } + foreach ($roomAnimals as $roomAnimal) { + $code = ShelterRoomService::resolveAnimal($roomAnimal, $configuredRooms); + if (isset($shelterRoomCounts[$code])) { + $shelterRoomCounts[$code]['count']++; + } + } + + $quality = + $db + ->query( + "SELECT COUNT(*) total, + SUM(CASE WHEN birth_date IS NULL OR trim(birth_date)='' THEN 1 ELSE 0 END) missing_birth_date, + SUM(CASE WHEN sex IS NULL OR trim(sex)='' OR sex='U' THEN 1 ELSE 0 END) missing_sex, + SUM(CASE WHEN color IS NULL OR trim(color)='' THEN 1 ELSE 0 END) missing_color, + SUM(CASE WHEN breed IS NULL OR trim(breed)='' THEN 1 ELSE 0 END) missing_breed, + SUM(CASE WHEN (rescue_location_name IS NULL OR trim(rescue_location_name)='') AND (rescue_address IS NULL OR trim(rescue_address)='') THEN 1 ELSE 0 END) missing_origin, + SUM(CASE WHEN (chip_id IS NULL OR trim(chip_id)='') AND (chip IS NULL OR trim(chip)='') THEN 1 ELSE 0 END) missing_chip, + SUM(CASE WHEN COALESCE(NULLIF(identification_registration_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_identification_registration, + SUM(CASE WHEN COALESCE(NULLIF(adoption_availability,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_adoption_availability, + SUM(CASE WHEN sterilization_status='no' THEN 1 ELSE 0 END) unsterilized, + SUM(CASE WHEN COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_sterilization, + SUM(CASE WHEN birth_date IS NULL OR trim(birth_date)='' OR sex IS NULL OR trim(sex)='' OR sex='U' OR color IS NULL OR trim(color)='' OR breed IS NULL OR trim(breed)='' OR ((rescue_location_name IS NULL OR trim(rescue_location_name)='') AND (rescue_address IS NULL OR trim(rescue_address)='')) OR ((chip_id IS NULL OR trim(chip_id)='') AND (chip IS NULL OR trim(chip)='')) OR COALESCE(NULLIF(identification_registration_status,''),'unknown')='unknown' OR COALESCE(NULLIF(adoption_availability,''),'unknown')='unknown' OR COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) incomplete + FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé','enfui','fugue')", + ) + ->fetch(PDO::FETCH_ASSOC) ?: + []; + + // Activité + $activity = $db + ->query( + " + SELECT + (SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND date(mn.noted_at) >= date('now','-7 day')) AS notes_7d, + (SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND v.done_date IS NOT NULL AND date(v.done_date) >= date('now','-30 day')) AS vaccines_30d, + (SELECT COUNT(*) FROM treatments t JOIN animals a ON a.id=t.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND t.start_date IS NOT NULL AND date(t.start_date) >= date('now','-30 day')) AS treatments_30d + ", + ) + ->fetch(); + + // Vaccins en retard + $stmt = $db->prepare(" + SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date + FROM vaccinations v + JOIN animals a ON a.id = v.animal_id + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + WHERE v.due_date IS NOT NULL + AND a.deleted_at IS NULL AND a.archived_at IS NULL + AND lower(a.status) NOT IN ('decede','décédé') + AND date(v.due_date) < date('now') + ORDER BY date(v.due_date) ASC + LIMIT 50 + "); + $stmt->execute(); + $vaccines_overdue = $stmt->fetchAll(); + + // Vaccins ≤ 7 jours + $stmt = $db->prepare(" + SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date + FROM vaccinations v + JOIN animals a ON a.id = v.animal_id + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + WHERE v.due_date IS NOT NULL + AND a.deleted_at IS NULL AND a.archived_at IS NULL + AND lower(a.status) NOT IN ('decede','décédé') + AND date(v.due_date) >= date('now') + AND date(v.due_date) <= date('now', '+7 day') + ORDER BY date(v.due_date) ASC + LIMIT 50 + "); + $stmt->execute(); + $vaccines_7d = $stmt->fetchAll(); + + // Vaccins ≤ 30 jours + $stmt = $db->prepare(" + SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date + FROM vaccinations v + JOIN animals a ON a.id = v.animal_id + JOIN ref_vaccines rv ON rv.id = v.vaccine_id + WHERE v.due_date IS NOT NULL + AND a.deleted_at IS NULL AND a.archived_at IS NULL + AND lower(a.status) NOT IN ('decede','décédé') + AND date(v.due_date) > date('now', '+7 day') + AND date(v.due_date) <= date('now', '+30 day') + ORDER BY date(v.due_date) ASC + LIMIT 50 + "); + $stmt->execute(); + $vaccines_30d = $stmt->fetchAll(); + + // Traitements en cours + $stmt = $db->prepare(' + SELECT a.id, a.name, a.internal_code AS code, + m.name AS medication_name, + t.start_date, t.end_date, t.dose_text, t.route + FROM treatments t + JOIN animals a ON a.id = t.animal_id + JOIN ref_medications m ON m.id = t.medication_id + WHERE t.ongoing = 1 + AND a.deleted_at IS NULL AND a.archived_at IS NULL + ORDER BY date(t.start_date) DESC, a.name ASC + LIMIT 50 + '); + $stmt->execute(); + $treatments = $stmt->fetchAll(); + + // Traitements à clôturer (fin dépassée mais ongoing=1) + $stmt = $db->prepare(" + SELECT a.id, a.name, a.internal_code AS code, m.name AS medication_name, t.end_date + FROM treatments t + JOIN animals a ON a.id = t.animal_id + JOIN ref_medications m ON m.id = t.medication_id + WHERE t.ongoing = 1 + AND a.deleted_at IS NULL AND a.archived_at IS NULL + AND t.end_date IS NOT NULL + AND date(t.end_date) < date('now') + ORDER BY date(t.end_date) ASC + LIMIT 50 + "); + $stmt->execute(); + $treatments_overdue = $stmt->fetchAll(); + + // Dernières notes médicales + $stmt = $db->prepare(' + SELECT mn.noted_at, mn.kind, mn.reason, + a.id AS animal_id, a.name, a.internal_code AS code + FROM medical_notes mn + JOIN animals a ON a.id = mn.animal_id + WHERE a.deleted_at IS NULL AND a.archived_at IS NULL + ORDER BY datetime(mn.noted_at) DESC, mn.id DESC + LIMIT 30 + '); + $stmt->execute(); + $recent_notes = $stmt->fetchAll(); + + // Quarantaine : liste + J-... (si dates renseignées) + $stmt = $db->prepare(" + SELECT + id, name, internal_code AS code, + intake_date, + quarantine_until, + CASE + WHEN quarantine_until IS NULL OR quarantine_until = '' THEN NULL + ELSE CAST(julianday(date(quarantine_until)) - julianday(date('now','localtime')) AS INT) + END AS days_left + FROM animals + WHERE lower(trim(status))='quarantaine' + AND deleted_at IS NULL AND archived_at IS NULL + ORDER BY + CASE + WHEN quarantine_until IS NULL OR quarantine_until = '' THEN 9999 + ELSE days_left + END ASC, + name ASC + LIMIT 100 + "); + + $stmt->execute(); + $quarantine = $stmt->fetchAll(); + + $stmt = $db->prepare(' + SELECT + a.id, a.name, a.internal_code AS code, + wa.last_weight, wa.w30_weight, wa.w30_date, + ROUND(((wa.last_weight - wa.w30_weight) / wa.w30_weight) * 100, 1) AS pct + FROM v_animals_weight_alerts wa + JOIN animals a ON a.id = wa.animal_id + WHERE wa.last_weight IS NOT NULL + AND a.deleted_at IS NULL AND a.archived_at IS NULL + AND wa.w30_weight IS NOT NULL + AND wa.w30_weight > 0 + AND ABS((wa.last_weight - wa.w30_weight) / wa.w30_weight) >= 0.15 + ORDER BY ABS((wa.last_weight - wa.w30_weight) / wa.w30_weight) DESC + LIMIT 50 + '); + $stmt->execute(); + $weight_alerts = $stmt->fetchAll(); + + $careToday = []; + $rows = $db + ->query( + "SELECT periods.period, + (SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) IN ('refuge','soin','quarantaine')) expected, + COUNT(o.id) checked, + SUM(CASE WHEN o.alert_signature IS NOT NULL THEN 1 ELSE 0 END) alerts + FROM (SELECT 'morning' period UNION ALL SELECT 'evening') periods + LEFT JOIN care_rounds r ON r.round_date=date('now','localtime') AND r.period=periods.period + LEFT JOIN care_round_observations o ON o.round_id=r.id + GROUP BY periods.period ORDER BY periods.period DESC", + ) + ->fetchAll(); + foreach ($rows as $row) { + $careToday[$row['period']] = $row; + } + + $stmt = $db->query( + "SELECT a.id,a.name,r.period,o.comment FROM care_round_observations o JOIN care_rounds r ON r.id=o.round_id JOIN animals a ON a.id=o.animal_id WHERE r.round_date=date('now','localtime') AND o.alert_signature IS NOT NULL AND a.deleted_at IS NULL ORDER BY o.checked_at DESC LIMIT 10", + ); + $careAlertsToday = $stmt->fetchAll(); + $grantAlerts = PermissionService::can('grants') ? GrantService::alerts((int) date('Y')) : []; + $agendaDue = PermissionService::can('agenda') ? AgendaService::due() : []; + + render('dashboard.php', [ + 'title' => 'Dashboard', + 'stats' => $stats, + 'shelterRoomCounts' => $shelterRoomCounts, + 'quality' => $quality, + 'activity' => $activity, + + 'vaccines_overdue' => $vaccines_overdue, + 'vaccines_7d' => $vaccines_7d, + 'vaccines_30d' => $vaccines_30d, + + 'treatments' => $treatments, + 'treatments_overdue' => $treatments_overdue, + + 'recent_notes' => $recent_notes, + 'quarantine' => $quarantine, + + 'weight_alerts' => $weight_alerts, + 'careToday' => $careToday, + 'careAlertsToday' => $careAlertsToday, + 'grantAlerts' => $grantAlerts, + 'agendaDue' => $agendaDue, + ]); + } +} diff --git a/app/Controllers/DeathController.php b/app/Controllers/DeathController.php new file mode 100644 index 0000000..4518524 --- /dev/null +++ b/app/Controllers/DeathController.php @@ -0,0 +1,305 @@ + 'Maladie', + 'accident' => 'Accident', + 'old_age' => 'Vieillesse', + 'congenital' => 'Malformation ou affection congénitale', + 'surgery_complication' => 'Complication médicale ou chirurgicale', + 'poisoning' => 'Empoisonnement', + 'unknown' => 'Cause inconnue', + 'other' => 'Autre cause', + ]; + public const PLACES = [ + 'refuge' => 'Au refuge', + 'fa' => 'En famille d’accueil', + 'veterinaire' => 'Chez le vétérinaire', + 'exterieur' => 'À l’extérieur', + 'inconnu' => 'Lieu inconnu', + 'autre' => 'Autre lieu', + ]; + public const DISPOSITIONS = [ + 'pending' => 'À organiser', + 'collective_cremation' => 'Crémation collective', + 'individual_cremation' => 'Crémation individuelle', + 'returned_family' => 'Corps ou cendres remis à la famille', + 'burial' => 'Inhumation', + 'other' => 'Autre prise en charge', + ]; + + public static function causeLabel(string $key): string + { + return t('death.cause.' . $key, [], self::CAUSES[$key] ?? $key); + } + public static function placeLabel(string $key): string + { + return t('death.place.' . $key, [], self::PLACES[$key] ?? $key); + } + public static function dispositionLabel(string $key): string + { + return t('death.disposition.' . $key, [], self::DISPOSITIONS[$key] ?? $key); + } + + public static function form(): void + { + $db = DB::pdo(); + $id = (int) ($_GET['id'] ?? 0); + $animal = self::animal($db, $id); + $stmt = $db->prepare( + 'SELECT ad.*,vet.name veterinarian_name,crem.name crematorium_name FROM animal_deaths ad LEFT JOIN directory_contacts vet ON vet.id=ad.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=ad.crematorium_contact_id WHERE ad.animal_id=:id', + ); + $stmt->execute([':id' => $id]); + $death = $stmt->fetch(PDO::FETCH_ASSOC) ?: [ + 'animal_id' => $id, + 'deceased_date' => date('Y-m-d'), + 'cause_code' => 'disease', + 'cause_details' => '', + 'occurred_in_care' => !in_array($animal['status'], ['adopte', 'adopté'], true) ? 1 : 0, + 'place_type' => 'refuge', + 'place_details' => '', + 'euthanized' => 0, + 'veterinarian_contact_id' => '', + 'crematorium_contact_id' => '', + 'body_disposition' => 'pending', + 'cremation_date' => '', + 'notes' => '', + ]; + $veterinarians = self::contacts($db, 'veterinaire'); + $crematoriums = self::contacts($db, 'crematorium'); + render('animal_death_form.php', [ + 'title' => t('death.title', ['name' => $animal['name']]), + 'animal' => $animal, + 'death' => $death, + 'causes' => self::CAUSES, + 'places' => self::PLACES, + 'dispositions' => self::DISPOSITIONS, + 'veterinarians' => $veterinarians, + 'crematoriums' => $crematoriums, + ]); + } + + public static function save(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $id = (int) ($_POST['animal_id'] ?? 0); + $animal = self::animal($db, $id); + $date = trim((string) ($_POST['deceased_date'] ?? '')); + $cause = (string) ($_POST['cause_code'] ?? ''); + $place = (string) ($_POST['place_type'] ?? ''); + $disposition = (string) ($_POST['body_disposition'] ?? 'pending'); + if ( + !self::validDate($date) || + $date > date('Y-m-d') || + !isset(self::CAUSES[$cause]) || + !isset(self::PLACES[$place]) || + !isset(self::DISPOSITIONS[$disposition]) + ) { + http_response_code(400); + echo h(t('death.invalid')); + return; + } + if ($cause === 'other' && trim((string) ($_POST['cause_details'] ?? '')) === '') { + http_response_code(400); + echo h(t('death.cause_required')); + return; + } + $vet = self::validContact($db, (int) ($_POST['veterinarian_contact_id'] ?? 0), 'veterinaire'); + $crem = self::validContact($db, (int) ($_POST['crematorium_contact_id'] ?? 0), 'crematorium'); + $cremationDate = trim((string) ($_POST['cremation_date'] ?? '')); + if ($cremationDate !== '' && (!self::validDate($cremationDate) || $cremationDate < $date)) { + http_response_code(400); + echo h(t('death.cremation_after')); + return; + } + $check = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=:id'); + $check->execute([':id' => $id]); + $isUpdate = (bool) $check->fetchColumn(); + $beforeLocation = LocationHistoryService::snapshot($animal); + $params = [ + ':animal' => $id, + ':date' => $date, + ':cause' => $cause, + ':details' => trim((string) ($_POST['cause_details'] ?? '')) ?: null, + ':care' => isset($_POST['occurred_in_care']) ? 1 : 0, + ':place' => $place, + ':place_details' => trim((string) ($_POST['place_details'] ?? '')) ?: null, + ':euthanized' => isset($_POST['euthanized']) ? 1 : 0, + ':vet' => $vet, + ':crem' => $crem, + ':disposition' => $disposition, + ':cremation_date' => $cremationDate ?: null, + ':notes' => trim((string) ($_POST['notes'] ?? '')) ?: null, + ':user' => Auth::id(), + ]; + $db->beginTransaction(); + try { + $db->prepare( + "INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,occurred_in_care,place_type,place_details,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,cremation_date,notes,created_by,updated_by) VALUES(:animal,:date,:cause,:details,:care,:place,:place_details,:euthanized,:vet,:crem,:disposition,:cremation_date,:notes,:user,:user) ON CONFLICT(animal_id) DO UPDATE SET deceased_date=excluded.deceased_date,cause_code=excluded.cause_code,cause_details=excluded.cause_details,occurred_in_care=excluded.occurred_in_care,place_type=excluded.place_type,place_details=excluded.place_details,euthanized=excluded.euthanized,veterinarian_contact_id=excluded.veterinarian_contact_id,crematorium_contact_id=excluded.crematorium_contact_id,body_disposition=excluded.body_disposition,cremation_date=excluded.cremation_date,notes=excluded.notes,updated_by=excluded.updated_by,updated_at=datetime('now')", + )->execute($params); + $db->prepare( + "UPDATE animals SET status='decede',refuge_room=NULL,care_box_key=NULL,updated_at=datetime('now') WHERE id=:id", + )->execute([':id' => $id]); + LocationHistoryService::record( + $db, + $id, + $beforeLocation, + [ + 'status' => 'decede', + 'refuge_room' => null, + 'care_box_key' => null, + 'current_address' => $animal['current_address'] ?? null, + ], + 'death', + 'Décès enregistré', + $date . ' 12:00:00', + ); + $db->prepare( + 'UPDATE treatments SET ongoing=0,end_date=COALESCE(end_date,:date) WHERE animal_id=:id AND ongoing=1', + )->execute([':date' => $date, ':id' => $id]); + $details = + 'Date : ' . + self::frDate($date) . + '\nCause : ' . + self::CAUSES[$cause] . + '\nContexte : ' . + ($params[':care'] ? 'Sous la responsabilité du refuge' : 'Après la prise en charge du refuge'); + if ($params[':euthanized']) { + $details .= '\nEuthanasie : oui'; + } + $db->prepare( + "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:id,'death',:label,:details,:user)", + )->execute([ + ':id' => $id, + ':label' => $isUpdate ? 'Informations de décès modifiées' : 'Décès enregistré', + ':details' => $details, + ':user' => Auth::id(), + ]); + self::syncMovement( + $db, + $id, + $date, + $cause, + $params[':details'], + $place, + $params[':place_details'], + $params[':euthanized'] === 1, + $vet, + $crem, + $disposition, + ); + $db->commit(); + header('Location: /animal?id=' . $id); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + + private static function animal(PDO $db, int $id): array + { + $s = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL'); + $s->execute([':id' => $id]); + $a = $s->fetch(PDO::FETCH_ASSOC); + if (!$a) { + http_response_code(404); + echo h(t('error.animal_not_found')); + exit(); + } + return $a; + } + private static function contacts(PDO $db, string $role): array + { + $s = $db->prepare( + 'SELECT dc.id,dc.name,dc.city FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE r.role=:role AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE', + ); + $s->execute([':role' => $role]); + return $s->fetchAll(PDO::FETCH_ASSOC); + } + private static function validContact(PDO $db, int $id, string $role): ?int + { + if (!$id) { + return null; + } + $s = $db->prepare( + 'SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=:id AND r.role=:role AND dc.deleted_at IS NULL', + ); + $s->execute([':id' => $id, ':role' => $role]); + return $s->fetchColumn() ? $id : null; + } + private static function syncMovement( + PDO $db, + int $animalId, + string $date, + string $cause, + ?string $causeDetails, + string $place, + ?string $placeDetails, + bool $euthanized, + ?int $vetId, + ?int $cremId, + string $disposition, + ): void { + $names = []; + if ($vetId || $cremId) { + $s = $db->prepare('SELECT id,name FROM directory_contacts WHERE id IN (:vet,:crem)'); + $s->execute([':vet' => $vetId ?? 0, ':crem' => $cremId ?? 0]); + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) { + $names[(int) $row['id']] = $row['name']; + } + } + $noteParts = ['Cause : ' . self::CAUSES[$cause] . ($causeDetails ? ' — ' . $causeDetails : '')]; + if ($euthanized) { + $noteParts[] = 'Euthanasie : oui'; + } + if ($cremId && isset($names[$cremId])) { + $noteParts[] = 'Crématorium : ' . $names[$cremId]; + } + $noteParts[] = 'Prise en charge : ' . self::DISPOSITIONS[$disposition]; + $s = $db->prepare( + "SELECT id FROM animal_movements WHERE animal_id=:animal AND kind='exit' AND lower(lieu) IN ('décès','deces','décès de l''animal','deces de l''animal') ORDER BY id DESC LIMIT 1", + ); + $s->execute([':animal' => $animalId]); + $movementId = (int) $s->fetchColumn(); + $values = [ + ':animal' => $animalId, + ':place' => self::PLACES[$place] . ($placeDetails ? ' — ' . $placeDetails : ''), + ':contact' => $vetId && isset($names[$vetId]) ? $names[$vetId] : null, + ':note' => implode("\n", $noteParts), + ':at' => $date . ' 12:00:00', + ]; + if ($movementId) { + $values[':movement'] = $movementId; + $db->prepare( + "UPDATE animal_movements SET place=:place,lieu='Décès de l''animal',contact_name=:contact,note=:note,created_at=:at WHERE id=:movement AND animal_id=:animal", + )->execute($values); + } else { + $db->prepare( + "INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,note,created_at) VALUES(:animal,'exit',:place,'Décès de l''animal',:contact,:note,:at)", + )->execute($values); + } + } + private static function validDate(string $date): bool + { + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $date); + $errors = DateTimeImmutable::getLastErrors(); + return $d !== false && + ($errors === false || ((int) $errors['warning_count'] === 0 && (int) $errors['error_count'] === 0)) && + $d->format('Y-m-d') === $date; + } + private static function frDate(string $date): string + { + $d = DateTimeImmutable::createFromFormat('Y-m-d', $date); + return $d ? $d->format('d/m/Y') : $date; + } +} diff --git a/app/Controllers/DevController.php b/app/Controllers/DevController.php new file mode 100644 index 0000000..55ecd75 --- /dev/null +++ b/app/Controllers/DevController.php @@ -0,0 +1,129 @@ +beginTransaction(); + try { + // Copie chaque note médicale ayant un poids -> measurements (sans dupliquer le même jour) + $sql = " + INSERT INTO measurements(animal_id, measured_at, type, value, unit, notes) + SELECT mn.animal_id, mn.noted_at, 'weight', mn.weight_kg, 'kg', 'backfill from medical_notes' + FROM medical_notes mn + WHERE mn.weight_kg IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM measurements m + WHERE m.animal_id = mn.animal_id + AND m.type = 'weight' + AND date(m.measured_at) = date(mn.noted_at) + ) + "; + $count = $db->exec($sql); + $db->commit(); + + echo 'Backfill OK. Inserted rows: ' . (int) $count . "\n"; + echo "Retourne sur /animal?id=...\n"; + } catch (Throwable $e) { + $db->rollBack(); + http_response_code(500); + echo 'Backfill FAILED: ' . $e->getMessage() . "\n"; + } + } + + public static function backup(): void + { + header('Content-Type: text/plain; charset=utf-8'); + $addr = $_SERVER['REMOTE_ADDR'] ?? ''; + if ($addr !== '127.0.0.1' && $addr !== '::1') { + http_response_code(403); + echo "Forbidden\n"; + return; + } + + $src = __DIR__ . '/../../data/refuge.sqlite'; + $dir = __DIR__ . '/../../storage/backups'; + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + $dst = $dir . '/refuge-' . date('Ymd-His') . '.sqlite'; + + // Copie cohérente SQLite + $pdo = DB::pdo(); + $pdo->exec('VACUUM INTO ' . $pdo->quote($dst)); + + echo 'Backup OK: ' . basename($dst) . "\n"; + } + + public static function backfillMovements(): void + { + $pdo = DB::pdo(); + + $animals = $pdo->query('SELECT * FROM animals')->fetchAll(PDO::FETCH_ASSOC); + + $pdo->beginTransaction(); + + try { + foreach ($animals as $a) { + // Vérifie si déjà migré + $check = $pdo->prepare('SELECT COUNT(*) FROM animal_movements WHERE animal_id = ?'); + $check->execute([$a['id']]); + if ($check->fetchColumn() > 0) { + continue; + } + + // ENTRY + $stmt = $pdo->prepare(" + INSERT INTO animal_movements + (animal_id, kind, moved_at, provenance, place, note) + VALUES (?, 'entry', ?, ?, ?, ?) + "); + + $stmt->execute([ + $a['id'], + $a['created_at'] ?? date('Y-m-d H:i:s'), + 'Migration ancienne donnée', + $a['rescue_address'] ?? '', + 'Migration automatique', + ]); + + // EXIT (si statut final) + if (in_array($a['status'], ['adopted', 'deceased', 'transferred', 'fugue'])) { + $stmt = $pdo->prepare(" + INSERT INTO animal_movements + (animal_id, kind, moved_at, exit_type, place, note) + VALUES (?, 'exit', ?, ?, ?, ?) + "); + + $stmt->execute([ + $a['id'], + $a['updated_at'] ?? date('Y-m-d H:i:s'), + $a['status'], + $a['current_address'] ?? '', + 'Migration automatique', + ]); + } + } + + $pdo->commit(); + echo 'Backfill OK.'; + } catch (Throwable $e) { + $pdo->rollBack(); + echo 'Erreur: ' . $e->getMessage(); + } + } +} diff --git a/app/Controllers/DevicesController.php b/app/Controllers/DevicesController.php new file mode 100644 index 0000000..2fe3ff6 --- /dev/null +++ b/app/Controllers/DevicesController.php @@ -0,0 +1,58 @@ + t('devices.title'), + 'devices' => TrustedDeviceService::devices(), + 'currentSelector' => TrustedDeviceService::currentSelector(), + 'saved' => isset($_GET['saved']), + ]); + } + public static function revoke(): void + { + self::postOnly(); + $id = (int) ($_POST['id'] ?? 0); + TrustedDeviceService::revoke($id, (int) Auth::id()); + AuditService::log( + 'trusted_device_revoked', + '/account/devices/revoke', + 'Appareil de confiance révoqué', + 'trusted_device', + $id, + ); + header('Location: /account/devices?saved=1'); + exit(); + } + public static function revokeAll(): void + { + self::postOnly(); + DB::pdo() + ->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE user_id=? AND revoked_at IS NULL") + ->execute([Auth::id()]); + AuditService::log( + 'trusted_devices_revoked', + '/account/devices/revoke-all', + 'Tous les appareils de confiance ont été révoqués', + ); + Auth::logout(); + header('Location: /login'); + exit(); + } + private static function postOnly(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + header('Allow: POST'); + exit(); + } + if (!Auth::validCsrf($_POST['csrf'] ?? null)) { + http_response_code(419); + echo h(t('auth.session_expired')); + exit(); + } + } +} diff --git a/app/Controllers/DewormingController.php b/app/Controllers/DewormingController.php new file mode 100644 index 0000000..dfe0e1a --- /dev/null +++ b/app/Controllers/DewormingController.php @@ -0,0 +1,163 @@ +prepare('SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL'); + $s->execute([$animalId]); + if (!$s->fetchColumn()) { + http_response_code(404); + echo h(t('error.animal_not_found')); + return; + } + if (isset($_POST['create_dewormer'])) { + $name = trim((string) ($_POST['new_dewormer_name'] ?? '')); + if ($name === '') { + http_response_code(400); + echo h(t('deworm.name_required')); + return; + } + $s = $db->prepare('SELECT id FROM ref_dewormers WHERE name=? COLLATE NOCASE'); + $s->execute([$name]); + $dewormer = (int) $s->fetchColumn(); + if (!$dewormer) { + $db->prepare('INSERT INTO ref_dewormers(name,active_ingredient,form) VALUES(?,?,?)')->execute([ + mb_substr($name, 0, 150), + trim((string) ($_POST['new_dewormer_ingredient'] ?? '')) ?: null, + trim((string) ($_POST['new_dewormer_form'] ?? '')) ?: null, + ]); + $dewormer = (int) $db->lastInsertId(); + } + } + if ($dewormer) { + $s = $db->prepare('SELECT 1 FROM ref_dewormers WHERE id=? AND active=1'); + $s->execute([$dewormer]); + if (!$s->fetchColumn()) { + $dewormer = 0; + } + } + $ids = [$animalId]; + $litterId = null; + if (isset($_POST['apply_litter'])) { + $s = $db->prepare( + 'SELECT l.id FROM litters l LEFT JOIN litter_kittens lk ON lk.litter_id=l.id WHERE l.mother_id=? OR lk.animal_id=? ORDER BY l.id DESC LIMIT 1', + ); + $s->execute([$animalId, $animalId]); + $litterId = (int) $s->fetchColumn() ?: null; + if ($litterId) { + $s = $db->prepare( + 'SELECT animal_id FROM litter_kittens WHERE litter_id=? UNION SELECT mother_id FROM litters WHERE id=? AND mother_id IS NOT NULL', + ); + $s->execute([$litterId, $litterId]); + $ids = array_values(array_unique(array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN)))); + } + } + $manual = trim((string) ($_POST['next_due_date'] ?? '')); + if ($manual !== '' && !self::date($manual)) { + http_response_code(400); + echo h(t('deworm.invalid_next_due')); + return; + } + $db->beginTransaction(); + try { + $insert = $db->prepare( + 'INSERT INTO dewormings(animal_id,dewormer_id,administered_on,dose_text,weight_kg,next_due_date,notes,source_litter_id,created_by) VALUES(?,?,?,?,?,?,?,?,?)', + ); + $history = $db->prepare( + "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'deworming','Vermifuge administré',?,?)", + ); + foreach ($ids as $id) { + $s = $db->prepare('SELECT name,birth_date FROM animals WHERE id=? AND deleted_at IS NULL'); + $s->execute([$id]); + $animal = $s->fetch(); + if (!$animal) { + continue; + } + $next = $manual ?: self::nextDue($animal['birth_date'] ?? null, $date); + $insert->execute([ + $id, + $dewormer ?: null, + $date, + trim((string) ($_POST['dose_text'] ?? '')) ?: null, + ($_POST['weight_kg'] ?? '') !== '' ? (float) $_POST['weight_kg'] : null, + $next, + trim((string) ($_POST['notes'] ?? '')) ?: null, + $litterId, + Auth::id(), + ]); + $history->execute([ + $id, + 'Administration : ' . self::fr($date) . ($next ? ' · Prochaine échéance : ' . self::fr($next) : ''), + Auth::id(), + ]); + } + $perAnimal = (float) str_replace(',', '.', (string) ($_POST['inventory_quantity'] ?? 0)); + InventoryService::consumeBatch( + (int) ($_POST['inventory_batch_id'] ?? 0), + $perAnimal * count($ids), + $animalId, + 'Vermifuge du ' . $date . ($litterId ? ' · portée #' . $litterId : ''), + ); + $db->commit(); + header('Location: /animal?id=' . $animalId . '#tab-med'); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + public static function nextDue(?string $birth, string $given): string + { + $admin = new DateTimeImmutable($given); + if ($birth && ($born = DateTimeImmutable::createFromFormat('!Y-m-d', $birth))) { + $targets = [ + $born->modify('+3 weeks'), + $born->modify('+5 weeks'), + $born->modify('+7 weeks'), + $born->modify('+3 months'), + $born->modify('+4 months'), + $born->modify('+5 months'), + $born->modify('+6 months'), + ]; + foreach ($targets as $target) { + if ($target > $admin) { + return $target->format('Y-m-d'); + } + } + } + return $admin->modify('+3 months')->format('Y-m-d'); + } + private static function date(string $v): bool + { + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $v); + return $d && $d->format('Y-m-d') === $v; + } + private static function fr(string $v): string + { + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $v); + return $d ? $d->format('d/m/Y') : $v; + } +} diff --git a/app/Controllers/DirectoryController.php b/app/Controllers/DirectoryController.php new file mode 100644 index 0000000..b52389f --- /dev/null +++ b/app/Controllers/DirectoryController.php @@ -0,0 +1,279 @@ + t('role.adoptant'), + 'benevole' => t('role.benevole'), + 'cabinet' => t('role.cabinet'), + 'crematorium' => t('role.crematorium'), + 'deposant' => t('role.deposant'), + 'dirigeant' => t('role.dirigeant'), + 'fa' => t('role.fa'), + 'fourriere' => t('role.fourriere'), + 'veterinaire' => t('role.veterinaire'), + ]; + } + + public static function index(): void + { + $db = DB::pdo(); + $q = trim((string) ($_GET['q'] ?? '')); + $role = trim((string) ($_GET['role'] ?? '')); + $where = ['dc.deleted_at IS NULL']; + $params = []; + if ($q !== '') { + $where[] = '(dc.name LIKE :q OR dc.phone LIKE :q OR dc.email LIKE :q OR dc.city LIKE :q)'; + $params[':q'] = '%' . $q . '%'; + } + $roles = self::roles(); + if (isset($roles[$role])) { + $where[] = 'EXISTS(SELECT 1 FROM directory_contact_roles f WHERE f.contact_id=dc.id AND f.role=:role)'; + $params[':role'] = $role; + } + $stmt = $db->prepare( + " + SELECT dc.*, org.name AS organization_name, group_concat(dcr.role, ',') AS roles, + (SELECT COUNT(*) FROM ( + SELECT animal_id FROM adoptions ad WHERE ad.adopter_contact_id=dc.id OR lower(trim(ad.adopter_name))=lower(trim(dc.name)) + UNION SELECT animal_id FROM animal_placements ap WHERE ap.contact_id=dc.id AND ap.event_type='adoption' + UNION SELECT animal_id FROM animal_movements am WHERE am.kind='exit' AND lower(trim(am.contact_name))=lower(trim(dc.name)) AND lower(COALESCE(am.lieu,'')) LIKE 'adopt%' + )) AS adoption_count, + (SELECT COALESCE(SUM(ae.total_cents),0) FROM animal_expenses ae WHERE ae.clinic_contact_id=dc.id) AS expense_total_cents + FROM directory_contacts dc + LEFT JOIN directory_contacts org ON org.id=dc.organization_id + LEFT JOIN directory_contact_roles dcr ON dcr.contact_id=dc.id + WHERE " . + implode(' AND ', $where) . + ' + GROUP BY dc.id ORDER BY dc.name COLLATE NOCASE + ', + ); + $stmt->execute($params); + render('directory_list.php', [ + 'title' => t('directory.title'), + 'contacts' => $stmt->fetchAll(), + 'roles' => $roles, + 'q' => $q, + 'role' => $role, + ]); + } + + public static function show(): void + { + $db = DB::pdo(); + $id = (int) ($_GET['id'] ?? 0); + $stmt = $db->prepare( + "SELECT dc.*,org.name organization_name,group_concat(r.role,',') roles FROM directory_contacts dc LEFT JOIN directory_contacts org ON org.id=dc.organization_id LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=:id AND dc.deleted_at IS NULL GROUP BY dc.id", + ); + $stmt->execute([':id' => $id]); + $contact = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$contact) { + http_response_code(404); + echo h(t('directory.not_found')); + return; + } + $adoptions = $db->prepare("SELECT a.id,a.name,a.internal_code,a.species,a.status,x.event_date FROM animals a JOIN (SELECT animal_id,MAX(event_date) event_date FROM ( + SELECT animal_id,adoption_date event_date FROM adoptions WHERE adopter_contact_id=:id1 OR lower(trim(adopter_name))=lower(trim(:name1)) + UNION ALL SELECT animal_id,event_date FROM animal_placements WHERE contact_id=:id2 AND event_type='adoption' + UNION ALL SELECT animal_id,created_at event_date FROM animal_movements WHERE kind='exit' AND lower(trim(contact_name))=lower(trim(:name2)) AND lower(COALESCE(lieu,'')) LIKE 'adopt%' + ) GROUP BY animal_id) x ON x.animal_id=a.id ORDER BY date(x.event_date) DESC,a.name COLLATE NOCASE"); + $adoptions->execute([':id1' => $id, ':name1' => $contact['name'], ':id2' => $id, ':name2' => $contact['name']]); + $fosters = $db->prepare( + "SELECT a.id,a.name,a.internal_code,a.species,a.status,MAX(ap.event_date) event_date FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=:id AND ap.event_type='foster' GROUP BY a.id ORDER BY date(event_date) DESC,a.name COLLATE NOCASE", + ); + $fosters->execute([':id' => $id]); + $deposited = $db->prepare( + 'SELECT id,name,internal_code,species,status,intake_date event_date FROM animals WHERE depositor_contact_id=:id ORDER BY date(intake_date) DESC,name COLLATE NOCASE', + ); + $deposited->execute([':id' => $id]); + $periodRequested = array_key_exists('year', $_GET); + $periodRaw = (string) ($_GET['year'] ?? 'all'); + $periodYear = + ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null; + $periodMunicipality = trim((string) ($_GET['municipality'] ?? '')); + $expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL']; + $expenseParams = [':id' => $id]; + if ($periodYear !== null) { + $expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end'; + $expenseParams[':expense_start'] = sprintf('%04d-01-01', $periodYear); + $expenseParams[':expense_end'] = sprintf('%04d-12-31', $periodYear); + } + $expenses = $db->prepare( + 'SELECT ae.*,a.name animal_name,a.internal_code,a.rescue_location_name,a.rescue_address FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id WHERE ' . + implode(' AND ', $expenseWhere) . + ' ORDER BY date(ae.occurred_on) DESC,ae.id DESC', + ); + $expenses->execute($expenseParams); + $expenseRows = $expenses->fetchAll(PDO::FETCH_ASSOC); + if ($periodMunicipality !== '') { + $expenseRows = array_values( + array_filter( + $expenseRows, + static fn($row) => GrantService::municipality( + (string) ($row['rescue_location_name'] ?: $row['rescue_address']), + ) === $periodMunicipality, + ), + ); + } + $expenseTotal = array_sum(array_map(static fn($row) => (int) $row['total_cents'], $expenseRows)); + $expenseCount = count($expenseRows); + $expenseRows = array_slice($expenseRows, 0, 250); + $invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL']; + $invoiceParams = [':vendor' => $id]; + if ($periodYear !== null) { + $invoiceWhere[] = "strftime('%Y',invoice_date)=:invoice_year"; + $invoiceParams[':invoice_year'] = (string) $periodYear; + } + $invoiceStmt = $db->prepare( + 'SELECT * FROM accounting_invoices WHERE ' . + implode(' AND ', $invoiceWhere) . + ' ORDER BY date(invoice_date) DESC,id DESC', + ); + $invoiceStmt->execute($invoiceParams); + $contactInvoices = $invoiceStmt->fetchAll(PDO::FETCH_ASSOC); + $contactInvoiceTotal = array_sum(array_column($contactInvoices, 'total_ttc_cents')); + $contactInvoiceDue = array_sum(array_column($contactInvoices, 'amount_due_cents')); + $linkedPeople = []; + if ($contact['kind'] === 'organization') { + $s = $db->prepare( + "SELECT dc.id,dc.name,dc.phone,dc.email,group_concat(r.role,',') roles FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.organization_id=:id AND dc.deleted_at IS NULL GROUP BY dc.id ORDER BY dc.name COLLATE NOCASE", + ); + $s->execute([':id' => $id]); + $linkedPeople = $s->fetchAll(PDO::FETCH_ASSOC); + } + render('directory_show_accounting.php', [ + 'title' => $contact['name'], + 'contact' => $contact, + 'roles' => self::roles(), + 'adoptions' => $adoptions->fetchAll(PDO::FETCH_ASSOC), + 'fosters' => $fosters->fetchAll(PDO::FETCH_ASSOC), + 'deposited' => $deposited->fetchAll(PDO::FETCH_ASSOC), + 'expenses' => $expenseRows, + 'expenseTotal' => $expenseTotal, + 'expenseCount' => $expenseCount, + 'expensePeriodRequested' => $periodRequested, + 'expensePeriodYear' => $periodYear, + 'expensePeriodMunicipality' => $periodMunicipality, + 'contactInvoices' => $contactInvoices, + 'contactInvoiceTotal' => $contactInvoiceTotal, + 'contactInvoiceDue' => $contactInvoiceDue, + 'linkedPeople' => $linkedPeople, + ]); + } + + public static function form(): void + { + $db = DB::pdo(); + $id = (int) ($_GET['id'] ?? 0); + $contact = [ + 'id' => 0, + 'kind' => 'person', + 'name' => '', + 'phone' => '', + 'email' => '', + 'address' => '', + 'postal_code' => '', + 'city' => '', + 'country' => 'France', + 'organization_id' => '', + 'notes' => '', + ]; + $selected = []; + if ($id) { + $stmt = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id AND deleted_at IS NULL'); + $stmt->execute([':id' => $id]); + $contact = $stmt->fetch() ?: $contact; + $stmt = $db->prepare('SELECT role FROM directory_contact_roles WHERE contact_id=:id'); + $stmt->execute([':id' => $id]); + $selected = $stmt->fetchAll(PDO::FETCH_COLUMN); + } + $organizations = $db + ->query( + "SELECT id,name FROM directory_contacts WHERE kind='organization' AND deleted_at IS NULL ORDER BY name COLLATE NOCASE", + ) + ->fetchAll(); + render('directory_form.php', [ + 'title' => $id ? t('directory.edit') : t('directory.add'), + 'contact' => $contact, + 'selectedRoles' => $selected, + 'roles' => self::roles(), + 'organizations' => $organizations, + ]); + } + + public static function save(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $id = (int) ($_POST['id'] ?? 0); + $name = trim((string) ($_POST['name'] ?? '')); + if ($name === '') { + http_response_code(400); + echo h(t('directory.name_required')); + return; + } + $kind = ($_POST['kind'] ?? 'person') === 'organization' ? 'organization' : 'person'; + $roles = array_values( + array_intersect(array_keys(self::roles()), is_array($_POST['roles'] ?? null) ? $_POST['roles'] : []), + ); + $organizationId = $kind === 'person' ? ((int) ($_POST['organization_id'] ?? 0) ?: null) : null; + if ($organizationId !== null) { + $check = $db->prepare( + "SELECT 1 FROM directory_contacts WHERE id=:id AND kind='organization' AND deleted_at IS NULL", + ); + $check->execute([':id' => $organizationId]); + if (!$check->fetchColumn() || $organizationId === $id) { + http_response_code(400); + echo h(t('directory.invalid_organization')); + return; + } + } + $data = [ + ':kind' => $kind, + ':name' => $name, + ':phone' => trim((string) ($_POST['phone'] ?? '')) ?: null, + ':email' => trim((string) ($_POST['email'] ?? '')) ?: null, + ':address' => trim((string) ($_POST['address'] ?? '')) ?: null, + ':postal' => trim((string) ($_POST['postal_code'] ?? '')) ?: null, + ':city' => trim((string) ($_POST['city'] ?? '')) ?: null, + ':country' => trim((string) ($_POST['country'] ?? 'France')) ?: 'France', + ':org' => $organizationId, + ':notes' => trim((string) ($_POST['notes'] ?? '')) ?: null, + ]; + $db->beginTransaction(); + try { + if ($id) { + $data[':id'] = $id; + $db->prepare( + "UPDATE directory_contacts SET kind=:kind,name=:name,phone=:phone,email=:email,address=:address,postal_code=:postal,city=:city,country=:country,organization_id=:org,notes=:notes,updated_at=datetime('now') WHERE id=:id", + )->execute($data); + } else { + $db->prepare( + 'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,country,organization_id,notes) VALUES(:kind,:name,:phone,:email,:address,:postal,:city,:country,:org,:notes)', + )->execute($data); + $id = (int) $db->lastInsertId(); + } + $db->prepare('DELETE FROM directory_contact_roles WHERE contact_id=:id')->execute([':id' => $id]); + $ins = $db->prepare('INSERT INTO directory_contact_roles(contact_id,role) VALUES(:id,:role)'); + foreach ($roles as $r) { + $ins->execute([':id' => $id, ':role' => $r]); + } + $db->commit(); + header('Location: /directory'); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } +} diff --git a/app/Controllers/DocumentsController.php b/app/Controllers/DocumentsController.php new file mode 100644 index 0000000..7ad8759 --- /dev/null +++ b/app/Controllers/DocumentsController.php @@ -0,0 +1,150 @@ + 'contrat-adoption', + 'abandon' => 'abandon-trouvaille', + 'benevole' => 'contrat-benevole', + 'fa' => 'proposition-fa', + 'pre_adoption' => 'certificat-visite-pre-adoption', + ]; + if (!isset($names[$type])) { + http_response_code(404); + return; + } + $format = ($_GET['format'] ?? 'odt') === 'pdf' ? 'pdf' : 'odt'; + $source = DocumentsService::template($type); + $dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8)); + mkdir($dir, 0775, true); + $odt = $dir . '/' . $names[$type] . '-vierge.odt'; + copy($source, $odt); + $path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt; + self::send($path, $names[$type] . '-vierge.' . $format, $format, $path); + } + + public static function adoption(): void + { + $id = (int) ($_GET['animal_id'] ?? 0); + if ($id <= 0) { + http_response_code(400); + return; + } + $db = DB::pdo(); + $stmt = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL'); + $stmt->execute([':id' => $id]); + $animal = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$animal) { + http_response_code(404); + return; + } + $stmt = $db->prepare('SELECT * FROM adoptions WHERE animal_id=:id ORDER BY id DESC LIMIT 1'); + $stmt->execute([':id' => $id]); + $adoption = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + $stmt = $db->prepare( + 'SELECT done_date,due_date FROM vaccinations WHERE animal_id=:id ORDER BY done_date DESC,id DESC LIMIT 1', + ); + $stmt->execute([':id' => $id]); + $vaccine = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + $format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf'; + $odt = DocumentsService::generateAdoption($animal, $adoption, $vaccine); + $path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt; + self::send( + $path, + 'contrat-adoption-' . self::safeName((string) $animal['name']) . '.' . $format, + $format, + $path, + ); + } + + public static function abandon(): void + { + $id = (int) ($_GET['animal_id'] ?? 0); + $db = DB::pdo(); + $stmt = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL'); + $stmt->execute([':id' => $id]); + $animal = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$animal) { + http_response_code(404); + return; + } + $format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf'; + $odt = DocumentsService::generateAbandon($animal); + $path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt; + self::send( + $path, + 'abandon-trouvaille-' . self::safeName((string) $animal['name']) . '.' . $format, + $format, + $path, + ); + } + + public static function contact(): void + { + $id = (int) ($_GET['contact_id'] ?? 0); + $type = (string) ($_GET['type'] ?? ''); + $role = $type === 'benevole' ? 'benevole' : ($type === 'fa' ? 'fa' : null); + if (!$role) { + http_response_code(400); + return; + } + $db = DB::pdo(); + $stmt = $db->prepare( + 'SELECT dc.* FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role=:role WHERE dc.id=:id AND dc.deleted_at IS NULL', + ); + $stmt->execute([':role' => $role, ':id' => $id]); + $contact = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$contact) { + http_response_code(404); + return; + } + $format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf'; + $odt = DocumentsService::generateContact($type, $contact); + $path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt; + self::send($path, $type . '-' . self::safeName((string) $contact['name']) . '.' . $format, $format, $path); + } + + private static function send(string $path, string $filename, string $format, ?string $cleanup): void + { + if (!is_file($path)) { + http_response_code(500); + return; + } + header('Content-Type: ' . ($format === 'pdf' ? 'application/pdf' : 'application/vnd.oasis.opendocument.text')); + header('Content-Length: ' . filesize($path)); + header( + 'Content-Disposition: ' . ($format === 'pdf' ? 'inline' : 'attachment') . '; filename="' . $filename . '"', + ); + readfile($path); + if ($cleanup) { + DocumentsService::cleanup($cleanup); + } + exit(); + } + private static function safeName(string $name): string + { + $name = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) ?: 'chat'; + return strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $name), '-')) ?: 'chat'; + } +} diff --git a/app/Controllers/GrantsController.php b/app/Controllers/GrantsController.php new file mode 100644 index 0000000..e1fca49 --- /dev/null +++ b/app/Controllers/GrantsController.php @@ -0,0 +1,240 @@ + t('grant.title'), + 'year' => $year, + 'annual' => GrantService::ensureYear($year), + 'stats' => GrantService::statistics($year), + 'details' => GrantService::detailedStatistics($year), + 'comparison' => GrantService::comparison($year), + 'applications' => $applications, + 'checklist' => GrantService::checklist($year), + 'documents' => GrantService::documents($year), + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function saveAnnual(): void + { + self::admin(); + $year = self::year(); + GrantService::ensureYear($year); + $ints = ['members_count', 'volunteers_count', 'employees_count']; + $nums = [ + 'employees_fte', + 'treasury_amount', + 'actual_expenses', + 'actual_income', + 'forecast_expenses', + 'forecast_income', + 'voluntary_contributions', + ]; + $texts = [ + 'annual_report', + 'completed_actions', + 'planned_actions', + 'beneficiaries', + 'officers_notes', + 'in_kind_support', + ]; + $values = [ + ':year' => $year, + ':last_ag_date' => self::date($_POST['last_ag_date'] ?? null), + ':uid' => Auth::id(), + ]; + foreach ($ints as $f) { + $values[':' . $f] = max(0, (int) ($_POST[$f] ?? 0)); + } + foreach ($nums as $f) { + $values[':' . $f] = max(0, self::amount($_POST[$f] ?? 0)); + } + foreach ($texts as $f) { + $values[':' . $f] = trim((string) ($_POST[$f] ?? '')); + } + $sets = []; + foreach (array_merge($ints, $nums, $texts, ['last_ag_date']) as $f) { + $sets[] = "$f=:$f"; + } + $sql = + 'UPDATE grant_years SET ' . + implode(',', $sets) . + ',updated_by=:uid,updated_at=datetime(\'now\') WHERE year=:year'; + DB::pdo()->prepare($sql)->execute($values); + AuditService::log('grant_year_saved', '/admin/grants/save', 'Bilan annuel ' . $year . ' mis à jour'); + header('Location: /admin/grants?year=' . $year . '&saved=1'); + } + public static function saveApplication(): void + { + self::admin(); + $year = self::year(); + GrantService::ensureYear($year); + $id = (int) ($_POST['application_id'] ?? 0); + $existing = []; + if ($id) { + $q = DB::pdo()->prepare('SELECT * FROM grant_applications WHERE id=? AND year=?'); + $q->execute([$id, $year]); + $existing = $q->fetch() ?: []; + } + $name = trim((string) ($_POST['organization_name'] ?? ($existing['organization_name'] ?? ''))); + if ($name === '') { + header('Location: /admin/grants?year=' . $year . '&error=organisme'); + return; + } + $status = (string) ($_POST['status'] ?? ($existing['status'] ?? 'draft')); + if (!isset(GrantService::STATUSES[$status])) { + $status = 'draft'; + } + $awardedRaw = $_POST['awarded_amount'] ?? ($existing['awarded_amount'] ?? ''); + $params = [ + ':year' => $year, + ':name' => $name, + ':deadline' => self::date($_POST['deadline'] ?? ($existing['deadline'] ?? null)), + ':requested' => self::amount($_POST['requested_amount'] ?? ($existing['requested_amount'] ?? 0)), + ':previous' => self::amount($_POST['previous_amount'] ?? ($existing['previous_amount'] ?? 0)), + ':awarded' => trim((string) $awardedRaw) === '' ? null : self::amount($awardedRaw), + ':status' => $status, + ':notes' => trim((string) ($_POST['notes'] ?? ($existing['notes'] ?? ''))), + ':uid' => Auth::id(), + ]; + if ($id) { + $params[':id'] = $id; + DB::pdo() + ->prepare( + 'UPDATE grant_applications SET organization_name=:name,deadline=:deadline,requested_amount=:requested,previous_amount=:previous,awarded_amount=:awarded,status=:status,notes=:notes,updated_by=:uid,updated_at=datetime(\'now\') WHERE id=:id AND year=:year', + ) + ->execute($params); + } else { + DB::pdo() + ->prepare( + 'INSERT INTO grant_applications(year,organization_name,deadline,requested_amount,previous_amount,awarded_amount,status,notes,created_by,updated_by) VALUES(:year,:name,:deadline,:requested,:previous,:awarded,:status,:notes,:uid,:uid)', + ) + ->execute($params); + } + header('Location: /admin/grants?year=' . $year . '&saved=1'); + } + public static function upload(): void + { + self::admin(); + $year = self::year(); + GrantService::ensureYear($year); + $f = $_FILES['document'] ?? null; + if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20_000_000) { + header('Location: /admin/grants?year=' . $year . '&error=document'); + return; + } + $mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']) ?: 'application/octet-stream'; + $allowed = ['application/pdf' => 'pdf', 'application/vnd.oasis.opendocument.text' => 'odt']; + if (!isset($allowed[$mime])) { + header('Location: /admin/grants?year=' . $year . '&error=format'); + return; + } + $dir = dirname(__DIR__, 2) . '/data/grants'; + if (!is_dir($dir)) { + mkdir($dir, 0770, true); + } + $stored = bin2hex(random_bytes(18)) . '.' . $allowed[$mime]; + if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $stored)) { + throw new RuntimeException(t('error.document_storage')); + } + $category = (string) ($_POST['category'] ?? 'other'); + if (!isset(GrantService::CATEGORIES[$category])) { + $category = 'other'; + } + $global = isset($_POST['global_document']); + $applicationId = (int) ($_POST['application_id'] ?? 0); + if ($applicationId) { + $check = DB::pdo()->prepare('SELECT 1 FROM grant_applications WHERE id=? AND year=?'); + $check->execute([$applicationId, $year]); + if (!$check->fetchColumn()) { + $applicationId = 0; + } + } + if ($category === 'official_form' && !$applicationId) { + @unlink($dir . '/' . $stored); + header('Location: /admin/grants?year=' . $year . '&error=organisme'); + return; + } + DB::pdo() + ->prepare( + 'INSERT INTO grant_documents(year,application_id,category,title,stored_name,original_name,mime_type,size_bytes,valid_until,notes,uploaded_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + ) + ->execute([ + $global ? null : $year, + $applicationId ?: null, + $category, + trim((string) ($_POST['title'] ?? '')) ?: pathinfo((string) $f['name'], PATHINFO_FILENAME), + $stored, + basename((string) $f['name']), + $mime, + (int) $f['size'], + self::date($_POST['valid_until'] ?? null), + trim((string) ($_POST['notes'] ?? '')), + Auth::id(), + ]); + header('Location: /admin/grants?year=' . $year . '&saved=1'); + } + public static function download(): void + { + self::admin(); + $s = DB::pdo()->prepare('SELECT * FROM grant_documents WHERE id=?'); + $s->execute([(int) ($_GET['id'] ?? 0)]); + $d = $s->fetch(); + $path = dirname(__DIR__, 2) . '/data/grants/' . ($d['stored_name'] ?? ''); + if (!$d || !is_file($path)) { + http_response_code(404); + echo h(t('grant.document_not_found')); + return; + } + PrivacyService::logAccess('grant_document', (int) $d['id'], null, (string) $d['original_name']); + header('Content-Type: ' . $d['mime_type']); + header('Content-Disposition: inline; filename="' . rawurlencode($d['original_name']) . '"'); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: private,no-store'); + readfile($path); + } + public static function print(): void + { + self::admin(); + $year = self::year(); + $annual = GrantService::ensureYear($year); + $stats = GrantService::statistics($year); + $details = GrantService::detailedStatistics($year); + $comparison = GrantService::comparison($year); + $applications = GrantService::applications($year); + $documents = GrantService::documents($year); + $generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')); + header('Content-Type: text/html; charset=UTF-8'); + require __DIR__ . '/../Views/admin_grants_print.php'; + } + private static function date(mixed $v): ?string + { + $v = trim((string) $v); + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null; + } + private static function amount(mixed $v): float + { + return round((float) str_replace([' ', ' ', ','], ['', '', '.'], (string) $v), 2); + } +} diff --git a/app/Controllers/InventoryController.php b/app/Controllers/InventoryController.php new file mode 100644 index 0000000..8ece0ab --- /dev/null +++ b/app/Controllers/InventoryController.php @@ -0,0 +1,72 @@ + t('inventory.title'), + 'products' => InventoryService::products(), + 'batches' => InventoryService::batches(), + 'movements' => InventoryService::movements(), + 'alerts' => InventoryService::alerts(), + 'references' => [ + 'medication' => $db + ->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE') + ->fetchAll(), + 'dewormer' => $db->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE')->fetchAll(), + 'vaccine' => $db->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE')->fetchAll(), + ], + 'suppliers' => $db + ->query( + "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='organization' AND r.role IN ('cabinet','fourriere') ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(), + 'animals' => $db + ->query( + 'SELECT id,name FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL ORDER BY name COLLATE NOCASE', + ) + ->fetchAll(), + 'status' => (string) ($_GET['status'] ?? ''), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function product(): void + { + self::admin(); + self::run(fn() => InventoryService::saveProduct($_POST), 'product'); + } + public static function receive(): void + { + self::admin(); + self::run(fn() => InventoryService::receive($_POST), 'received'); + } + public static function move(): void + { + self::admin(); + self::run(fn() => InventoryService::move($_POST), 'moved'); + } + private static function run(callable $fn, string $status): void + { + try { + $fn(); + header('Location: /settings/inventory?status=' . $status); + } catch (RuntimeException $e) { + header('Location: /settings/inventory?error=' . rawurlencode($e->getMessage())); + } catch (Throwable $e) { + header('Location: /settings/inventory?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } +} diff --git a/app/Controllers/MedicalDocumentsController.php b/app/Controllers/MedicalDocumentsController.php new file mode 100644 index 0000000..7bc7f12 --- /dev/null +++ b/app/Controllers/MedicalDocumentsController.php @@ -0,0 +1,334 @@ + 'pdf', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + ]; + + public static function savePrescription(): void + { + $animalId = self::animalId(); + $date = self::date((string) ($_POST['prescribed_on'] ?? '')); + if (!$animalId || !$date) { + self::fail(t('medical_document.invalid_data')); + return; + } + try { + $file = self::storeUpload('document', $animalId, 'prescription', true); + } catch (Throwable $e) { + self::fail($e->getMessage()); + return; + } + $db = DB::pdo(); + try { + $db->beginTransaction(); + $stmt = $db->prepare( + 'INSERT INTO medical_prescriptions(animal_id,prescribed_on,veterinarian_contact_id,clinic_contact_id,status,notes,original_name,stored_name,mime_type,size_bytes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + ); + $status = in_array($_POST['status'] ?? '', ['active', 'finished', 'replaced'], true) + ? $_POST['status'] + : 'active'; + $stmt->execute([ + $animalId, + $date, + self::contact('veterinarian_contact_id'), + self::contact('clinic_contact_id'), + $status, + self::text('notes'), + $file['original'], + $file['stored'], + $file['mime'], + $file['size'], + Auth::id(), + ]); + $id = (int) $db->lastInsertId(); + $link = $db->prepare( + 'INSERT OR IGNORE INTO prescription_treatments(prescription_id,treatment_id) SELECT ?,id FROM treatments WHERE id=? AND animal_id=?', + ); + foreach ((array) ($_POST['treatment_ids'] ?? []) as $treatmentId) { + $link->execute([$id, (int) $treatmentId, $animalId]); + } + self::history($db, $animalId, 'Ordonnance ajoutée', self::fr($date)); + $db->commit(); + AuditService::log( + 'prescription_added', + '/animal/prescription/save', + t('medical_document.prescription_added'), + 'animal', + $animalId, + ['prescription_id' => $id], + ); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + self::remove($animalId, $file['stored']); + throw $e; + } + self::back($animalId); + } + + public static function saveLabReport(): void + { + $animalId = self::animalId(); + $date = self::date((string) ($_POST['sampled_on'] ?? '')); + $names = (array) ($_POST['parameter_name'] ?? []); + $values = (array) ($_POST['value'] ?? []); + if (!$animalId || !$date) { + self::fail(t('medical_document.invalid_data')); + return; + } + try { + $file = self::storeUpload('document', $animalId, 'analysis', false); + } catch (Throwable $e) { + self::fail($e->getMessage()); + return; + } + $rows = []; + foreach ($names as $i => $name) { + $name = trim((string) $name); + $raw = str_replace(',', '.', trim((string) ($values[$i] ?? ''))); + if ($name === '' && $raw === '') { + continue; + } + if ($name === '' || !is_numeric($raw)) { + if ($file) { + self::remove($animalId, $file['stored']); + } + self::fail(t('medical_document.invalid_result')); + return; + } + $min = self::numberOrNull(($_POST['reference_min'] ?? [])[$i] ?? null); + $max = self::numberOrNull(($_POST['reference_max'] ?? [])[$i] ?? null); + if ($min !== null && $max !== null && $min > $max) { + if ($file) { + self::remove($animalId, $file['stored']); + } + self::fail(t('medical_document.invalid_result')); + return; + } + $rows[] = [ + 'name' => mb_substr($name, 0, 120), + 'value' => (float) $raw, + 'unit' => self::arrayText('unit', $i, 30), + 'min' => $min, + 'max' => $max, + 'notes' => self::arrayText('result_notes', $i, 255), + ]; + } + if (!$rows && !$file) { + self::fail(t('medical_document.result_or_document_required')); + return; + } + $db = DB::pdo(); + try { + $db->beginTransaction(); + $stmt = $db->prepare( + 'INSERT INTO lab_reports(animal_id,sampled_on,report_type,laboratory_name,veterinarian_contact_id,clinic_contact_id,notes,original_name,stored_name,mime_type,size_bytes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', + ); + $stmt->execute([ + $animalId, + $date, + self::text('report_type', 120), + self::text('laboratory_name', 150), + self::contact('veterinarian_contact_id'), + self::contact('clinic_contact_id'), + self::text('notes'), + $file['original'] ?? null, + $file['stored'] ?? null, + $file['mime'] ?? null, + $file['size'] ?? 0, + Auth::id(), + ]); + $reportId = (int) $db->lastInsertId(); + $insert = $db->prepare( + 'INSERT INTO lab_results(report_id,parameter_name,value,unit,reference_min,reference_max,notes,position) VALUES(?,?,?,?,?,?,?,?)', + ); + foreach ($rows as $i => $row) { + $insert->execute([ + $reportId, + $row['name'], + $row['value'], + $row['unit'], + $row['min'], + $row['max'], + $row['notes'], + $i, + ]); + } + self::history( + $db, + $animalId, + 'Résultats d’analyse ajoutés', + self::fr($date) . ($rows ? ' · ' . count($rows) . ' paramètre(s)' : ''), + ); + $db->commit(); + AuditService::log( + 'lab_report_added', + '/animal/lab-report/save', + t('medical_document.analysis_added'), + 'animal', + $animalId, + ['report_id' => $reportId, 'results' => count($rows)], + ); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + if ($file) { + self::remove($animalId, $file['stored']); + } + throw $e; + } + self::back($animalId); + } + + public static function download(): void + { + $id = (int) ($_GET['id'] ?? 0); + $kind = (string) ($_GET['kind'] ?? ''); + $table = $kind === 'prescription' ? 'medical_prescriptions' : ($kind === 'analysis' ? 'lab_reports' : ''); + if (!$id || !$table) { + http_response_code(404); + return; + } + $stmt = DB::pdo()->prepare( + "SELECT d.animal_id,d.original_name,d.stored_name,d.mime_type FROM $table d JOIN animals a ON a.id=d.animal_id WHERE d.id=? AND a.deleted_at IS NULL", + ); + $stmt->execute([$id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row || !$row['stored_name']) { + http_response_code(404); + return; + } + $path = self::directory((int) $row['animal_id']) . '/' . basename((string) $row['stored_name']); + if (!is_file($path)) { + http_response_code(404); + return; + } + PrivacyService::logAccess('medical_' . $kind, $id, (int) $row['animal_id'], (string) $row['original_name']); + AuditService::log( + 'private_file_viewed', + '/animal/medical-document', + 'Document médical privé consulté', + 'animal', + (int) $row['animal_id'], + ['kind' => $kind, 'document_id' => $id], + ); + header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream')); + header('Content-Length: ' . filesize($path)); + header("Content-Disposition: inline; filename*=UTF-8''" . rawurlencode((string) $row['original_name'])); + header('Cache-Control: private, no-store'); + readfile($path); + } + + private static function animalId(): int + { + $id = (int) ($_POST['animal_id'] ?? 0); + if (!$id) { + return 0; + } + $s = DB::pdo()->prepare('SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL'); + $s->execute([$id]); + return $s->fetchColumn() ? $id : 0; + } + private static function date(string $v): ?string + { + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $v); + return $d && $d->format('Y-m-d') === $v ? $v : null; + } + private static function contact(string $key): ?int + { + $id = (int) ($_POST[$key] ?? 0); + if ($id <= 0) { + return null; + } + $role = $key === 'veterinarian_contact_id' ? 'veterinaire' : 'cabinet'; + $s = DB::pdo()->prepare( + 'SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=? AND dc.deleted_at IS NULL AND r.role=?', + ); + $s->execute([$id, $role]); + return $s->fetchColumn() ? $id : null; + } + private static function text(string $key, int $max = 2000): ?string + { + $v = trim((string) ($_POST[$key] ?? '')); + return $v === '' ? null : mb_substr($v, 0, $max); + } + private static function arrayText(string $key, int $i, int $max): ?string + { + $v = trim((string) (($_POST[$key] ?? [])[$i] ?? '')); + return $v === '' ? null : mb_substr($v, 0, $max); + } + private static function numberOrNull(mixed $v): ?float + { + $v = str_replace(',', '.', trim((string) $v)); + return $v !== '' && is_numeric($v) ? (float) $v : null; + } + private static function directory(int $animalId): string + { + return dirname(__DIR__, 2) . '/data/medical-documents/' . $animalId; + } + private static function storeUpload(string $key, int $animalId, string $prefix, bool $required): ?array + { + $f = $_FILES[$key] ?? null; + if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { + if ($required) { + throw new RuntimeException(t('medical_document.file_required')); + } + return null; + } + if (($f['error'] ?? 1) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) <= 0 || $f['size'] > self::MAX_BYTES) { + throw new RuntimeException(t('medical_document.file_invalid')); + } + $mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']); + if (!isset(self::TYPES[$mime])) { + throw new RuntimeException(t('medical_document.file_type')); + } + $dir = self::directory($animalId); + if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) { + throw new RuntimeException(t('medical_document.storage_failed')); + } + $stored = $prefix . '-' . bin2hex(random_bytes(16)) . '.' . self::TYPES[$mime]; + if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $stored)) { + throw new RuntimeException(t('medical_document.storage_failed')); + } + return [ + 'original' => mb_substr(basename((string) $f['name']), 0, 240), + 'stored' => $stored, + 'mime' => $mime, + 'size' => (int) $f['size'], + ]; + } + private static function remove(int $animalId, string $name): void + { + @unlink(self::directory($animalId) . '/' . basename($name)); + } + private static function history(PDO $db, int $animalId, string $label, string $details): void + { + $db->prepare( + "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'medical',?,?,?)", + )->execute([$animalId, $label, $details, Auth::id()]); + } + private static function fr(string $date): string + { + return new DateTimeImmutable($date)->format('d/m/Y'); + } + private static function fail(string $message): void + { + http_response_code(400); + echo h($message); + } + private static function back(int $id): never + { + header('Location: /animal?id=' . $id . '#tab-med'); + exit(); + } +} diff --git a/app/Controllers/PlacementsController.php b/app/Controllers/PlacementsController.php new file mode 100644 index 0000000..7255d35 --- /dev/null +++ b/app/Controllers/PlacementsController.php @@ -0,0 +1,172 @@ + 'Réservation', + 'foster' => 'Placement en famille d’accueil', + 'return' => 'Retour au refuge', + 'cancellation' => 'Annulation', + ]; + + public static function save(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $animalId = (int) ($_POST['animal_id'] ?? 0); + $type = (string) ($_POST['event_type'] ?? ''); + $date = (string) ($_POST['event_date'] ?? ''); + if (!$animalId || !isset(self::TYPES[$type]) || !self::validDate($date)) { + http_response_code(400); + echo h(t('error.invalid_placement')); + return; + } + $reason = trim((string) ($_POST['reason'] ?? '')); + if (in_array($type, ['return', 'cancellation'], true) && $reason === '') { + http_response_code(400); + echo h(t('error.placement_reason_required')); + return; + } + $s = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL'); + $s->execute([':id' => $animalId]); + $animal = $s->fetch(PDO::FETCH_ASSOC); + if (!$animal) { + http_response_code(404); + return; + } + $contactId = (int) ($_POST['contact_id'] ?? 0) ?: null; + $contact = null; + if ($contactId) { + $s = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id AND deleted_at IS NULL'); + $s->execute([':id' => $contactId]); + $contact = $s->fetch(PDO::FETCH_ASSOC) ?: null; + if (!$contact) { + $contactId = null; + } + } + $status = match ($type) { + 'reservation' => 'reserve', + 'foster' => 'fa', + 'return' => 'refuge', + 'cancellation' => in_array($animal['status'], ['reserve', 'réservé'], true) ? 'refuge' : $animal['status'], + }; + $currentAddress = (string) ($animal['current_address'] ?? ''); + if ($type === 'foster' && $contact) { + $currentAddress = implode( + ' ', + array_filter([$contact['address'] ?? '', $contact['postal_code'] ?? '', $contact['city'] ?? '']), + ); + } + if ($type === 'return') { + $currentAddress = implode( + ' ', + array_filter([ + AppSettings::get('association_address'), + AppSettings::get('association_postal_code'), + AppSettings::get('association_city'), + ]), + ); + } + $notes = trim((string) ($_POST['notes'] ?? '')); + $db->beginTransaction(); + try { + $db->prepare( + 'INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(:animal,:type,:date,:contact,:reason,:notes,:user)', + )->execute([ + ':animal' => $animalId, + ':type' => $type, + ':date' => $date, + ':contact' => $contactId, + ':reason' => $reason ?: null, + ':notes' => $notes ?: null, + ':user' => Auth::id(), + ]); + $db->prepare( + 'UPDATE animals SET status=:status,refuge_room=NULL,care_box_key=NULL,current_address=:address,current_lat=NULL,current_lng=NULL,updated_at=datetime(\'now\') WHERE id=:id', + )->execute([':status' => $status, ':address' => $currentAddress ?: null, ':id' => $animalId]); + $after = $animal; + $after['status'] = $status; + $after['refuge_room'] = null; + $after['care_box_key'] = null; + $after['current_address'] = $currentAddress; + LocationHistoryService::record( + $db, + $animalId, + $animal, + $after, + 'placement', + self::TYPES[$type] . ($reason !== '' ? ' — ' . $reason : ''), + $date . ' 12:00:00', + ); + $details = []; + if ($reason !== '') { + $details[] = 'Motif : ' . $reason; + } + if ($notes !== '') { + $details[] = 'Notes : ' . $notes; + } + $db->prepare( + 'INSERT INTO animal_history(animal_id,type,label,details,user_id,created_at) VALUES(:animal,\'placement\',:label,:details,:user,:date)', + )->execute([ + ':animal' => $animalId, + ':label' => self::TYPES[$type], + ':details' => $details ? implode("\n", $details) : null, + ':user' => Auth::id(), + ':date' => $date . ' 12:00:00', + ]); + if ($type === 'foster' || $type === 'return') { + self::movement($db, $animalId, $type, $contactId, $reason, $notes, $date); + } + $db->commit(); + header('Location: /animal?id=' . $animalId . '#tab-placements'); + exit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + + private static function movement( + PDO $db, + int $animalId, + string $type, + ?int $contactId, + string $reason, + string $notes, + string $date, + ): void { + $contact = null; + if ($contactId) { + $s = $db->prepare('SELECT name,phone,email,city FROM directory_contacts WHERE id=:id'); + $s->execute([':id' => $contactId]); + $contact = $s->fetch(PDO::FETCH_ASSOC) ?: null; + } + $db->prepare( + 'INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,contact_phone,contact_email,note,created_at) VALUES(:animal,:kind,:place,:lieu,:name,:phone,:email,:note,:date)', + )->execute([ + ':animal' => $animalId, + ':kind' => $type === 'return' ? 'entry' : 'exit', + ':place' => $contact['city'] ?? null, + ':lieu' => $type === 'return' ? 'Retour de placement' : 'Placement en FA', + ':name' => $contact['name'] ?? null, + ':phone' => $contact['phone'] ?? null, + ':email' => $contact['email'] ?? null, + ':note' => + trim(($reason !== '' ? 'Motif : ' . $reason : '') . ($notes !== '' ? "\nNotes : " . $notes : '')) ?: + null, + ':date' => $date . ' 12:00:00', + ]); + } + private static function validDate(string $date): bool + { + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $date); + return $d !== false && $d->format('Y-m-d') === $date; + } +} diff --git a/app/Controllers/PrivacyController.php b/app/Controllers/PrivacyController.php new file mode 100644 index 0000000..db9dcff --- /dev/null +++ b/app/Controllers/PrivacyController.php @@ -0,0 +1,113 @@ + t('privacy.title'), + 'settings' => $settings, + 'review' => PrivacyService::retentionReview($years), + 'accesses' => PrivacyService::accessLog(), + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function save(): void + { + self::admin(); + self::postOnly(); + $values = []; + foreach ( + [ + 'privacy_retention_years' => 5, + 'privacy_retention_adopter_years' => 5, + 'privacy_retention_foster_years' => 5, + 'privacy_retention_volunteer_years' => 5, + ] + as $key => $default + ) { + $values[$key] = (string) max(1, min(30, (int) ($_POST[$key] ?? $default))); + } + $values['privacy_access_log_months'] = (string) max( + 1, + min(120, (int) ($_POST['privacy_access_log_months'] ?? 24)), + ); + AppSettings::save($values, Auth::id()); + AuditService::log( + 'privacy_settings_saved', + '/settings/privacy/save', + 'Politique de conservation modifiée', + null, + null, + $values, + ); + header('Location: /settings/privacy?saved=1'); + exit(); + } + public static function export(): void + { + self::admin(); + $id = (int) ($_GET['id'] ?? 0); + try { + $path = PrivacyService::exportContact($id); + PrivacyService::logAccess('contact_export', $id, null, 'contact-' . $id . '.zip', 'download'); + AuditService::log( + 'contact_exported', + '/settings/privacy/export', + 'Export RGPD d’un contact', + 'contact', + $id, + ); + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="contact-' . $id . '-globinours.zip"'); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: private,no-store'); + readfile($path); + @unlink($path); + } catch (RuntimeException $e) { + http_response_code(404); + echo h($e->getMessage()); + } catch (Throwable $e) { + http_response_code(500); + echo h(SecurityService::publicError($e)); + } + } + public static function anonymize(): void + { + self::admin(); + self::postOnly(); + $id = (int) ($_POST['id'] ?? 0); + try { + if (!isset($_POST['confirm_reviewed'])) { + throw new RuntimeException(t('privacy.review_required')); + } + PrivacyService::anonymize($id, (string) ($_POST['reason'] ?? '')); + header('Location: /settings/privacy?saved=1'); + } catch (Throwable $e) { + header('Location: /settings/privacy?error=' . rawurlencode($e->getMessage())); + } + exit(); + } + private static function postOnly(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + header('Allow: POST'); + exit(); + } + } +} diff --git a/app/Controllers/ProjectController.php b/app/Controllers/ProjectController.php new file mode 100644 index 0000000..ea00d50 --- /dev/null +++ b/app/Controllers/ProjectController.php @@ -0,0 +1,14 @@ + t('footer.evolutions'), + 'pageDescription' => t('project.evolutions_description'), + ]); + } +} diff --git a/app/Controllers/PublicSiteController.php b/app/Controllers/PublicSiteController.php new file mode 100644 index 0000000..d181995 --- /dev/null +++ b/app/Controllers/PublicSiteController.php @@ -0,0 +1,272 @@ + $root ? '/' : '/site', + 'animals' => $root ? '/animaux' : '/site/animaux', + 'animal' => $root ? '/adoption' : '/site/animal', + 'about' => $root ? '/presentation' : '/site/presentation', + 'donation' => $root ? '/dons' : '/site/dons', + 'contact' => $root ? '/contact' : '/site/contact', + 'legal' => $root ? '/mentions-legales' : '/site/mentions-legales', + 'privacy' => $root ? '/confidentialite' : '/site/confidentialite', + 'logo' => $root ? '/logo-association' : '/site/logo', + default => $root ? '/' : '/site', + }; + return $path . ($query ? '?' . http_build_query($query) : ''); + } + public static function dispatch(string $path): void + { + $path = self::normalize($path); + if (AppSettings::get('public_site_enabled') !== '1') { + http_response_code(404); + self::render('public_site_unavailable.php', ['title' => t('public_site.unavailable')]); + return; + } + if ($path === '/site/logo') { + self::logo(); + return; + } + match ($path) { + '/site', '/site/' => self::home(), + '/site/animaux' => self::animals(), + '/site/animal' => self::animal(), + '/site/dons' => self::donation(), + '/site/contact' => self::contact(), + '/site/presentation' => self::about(), + '/site/mentions-legales' => self::legal(), + '/site/confidentialite' => self::privacy(), + default => self::notFound(), + }; + } + private static function normalize(string $path): string + { + return match ($path) { + '/' => '/site', + '/animaux' => '/site/animaux', + '/adoption' => '/site/animal', + '/presentation' => '/site/presentation', + '/dons' => '/site/dons', + '/contact' => '/site/contact', + '/mentions-legales' => '/site/mentions-legales', + '/confidentialite' => '/site/confidentialite', + '/logo-association' => '/site/logo', + default => $path, + }; + } + private static function home(): void + { + $db = DB::pdo(); + $animals = self::animalQuery('', 8); + $counts = $db + ->query( + "SELECT species,COUNT(*) count FROM animals WHERE website_published=1 AND adoption_availability='available' AND archived_at IS NULL AND deleted_at IS NULL AND status NOT IN ('adopte','decede') GROUP BY species ORDER BY species", + ) + ->fetchAll(PDO::FETCH_ASSOC); + self::render('public_site_home.php', [ + 'title' => AppSettings::get('public_site_home_title'), + 'animals' => $animals, + 'speciesCounts' => $counts, + ]); + } + private static function animals(): void + { + $species = SpeciesService::normalize((string) ($_GET['espece'] ?? '')); + if ($species !== '' && !SpeciesService::exists($species, false)) { + $species = ''; + } + self::render('public_site_animals.php', [ + 'title' => t('public_site.animals'), + 'animals' => self::animalQuery($species, 200), + 'selectedSpecies' => $species, + 'publicSpecies' => self::species(), + ]); + } + private static function animal(): void + { + $id = (int) ($_GET['id'] ?? 0); + $s = DB::pdo()->prepare( + "SELECT a.*,s.name species_name,s.icon species_icon,(SELECT filename FROM animal_photos p WHERE p.animal_id=a.id AND p.is_public=1 ORDER BY p.is_primary DESC,p.id DESC LIMIT 1) primary_photo FROM animals a LEFT JOIN ref_species s ON s.code=a.species WHERE a.id=? AND a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede')", + ); + $s->execute([$id]); + $animal = $s->fetch(PDO::FETCH_ASSOC); + if (!$animal) { + self::notFound(); + return; + } + $p = DB::pdo()->prepare( + 'SELECT filename,caption FROM animal_photos WHERE animal_id=? AND is_public=1 ORDER BY is_primary DESC,id DESC', + ); + $p->execute([$id]); + self::render('public_site_animal.php', [ + 'title' => $animal['name'], + 'animal' => $animal, + 'photos' => $p->fetchAll(PDO::FETCH_ASSOC), + ]); + } + private static function donation(): void + { + self::render('public_site_text.php', [ + 'title' => AppSettings::get('public_site_donation_title'), + 'content' => AppSettings::get('public_site_donation_text'), + 'kind' => 'donation', + ]); + } + private static function contact(): void + { + self::render('public_site_contact.php', ['title' => t('public_site.contact')]); + } + private static function about(): void + { + if (AppSettings::get('public_site_about_enabled') !== '1') { + self::notFound(); + return; + } + self::render('public_site_text.php', [ + 'title' => AppSettings::get('public_site_about_title'), + 'content' => AppSettings::get('public_site_about_text'), + 'kind' => 'about', + ]); + } + private static function legal(): void + { + self::render('public_site_text.php', [ + 'title' => t('public_site.legal'), + 'content' => self::legalText(), + 'kind' => 'legal', + ]); + } + private static function privacy(): void + { + self::render('public_site_text.php', [ + 'title' => t('public_site.privacy'), + 'content' => self::privacyText(), + 'kind' => 'privacy', + ]); + } + public static function legalText(?array $settings = null): string + { + $settings ??= AppSettings::all(); + $custom = trim((string) ($settings['public_site_legal_text'] ?? '')); + if ($custom !== '') { + return $custom; + } + $address = + implode( + ', ', + array_filter([ + $settings['association_address'] ?? '', + trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? '')), + ]), + ) ?: + t('public_site.to_complete'); + return t('public_site.default_legal', [ + 'association' => $settings['association_name'] ?: t('public_site.to_complete'), + 'address' => $address, + 'email' => $settings['association_email'] ?: t('public_site.to_complete'), + 'rna' => $settings['association_rna'] ?: t('public_site.to_complete'), + 'siret' => $settings['association_siret'] ?: t('public_site.to_complete'), + ]); + } + public static function privacyText(?array $settings = null): string + { + $settings ??= AppSettings::all(); + $custom = trim((string) ($settings['public_site_privacy_text'] ?? '')); + if ($custom !== '') { + return $custom; + } + $address = + implode( + ', ', + array_filter([ + $settings['association_address'] ?? '', + trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? '')), + ]), + ) ?: + t('public_site.to_complete'); + return t('public_site.default_privacy', [ + 'association' => $settings['association_name'] ?: t('public_site.to_complete'), + 'address' => $address, + 'email' => $settings['association_email'] ?: t('public_site.to_complete'), + ]); + } + private static function logo(): void + { + $path = AssociationBrand::path(); + if (!$path) { + http_response_code(404); + return; + } + $mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream'; + header('Content-Type: ' . $mime); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: public,max-age=3600'); + readfile($path); + } + private static function animalQuery(string $species, int $limit): array + { + $sql = + "SELECT a.id,a.name,a.species,a.sex,a.birth_date,a.birth_is_estimated,a.birth_estimated_months,a.breed,a.color,a.sterilization_status,a.compatibility_dogs,a.compatibility_cats,a.compatibility_children,a.house_trained,a.website_description,s.name species_name,s.icon species_icon,(SELECT filename FROM animal_photos p WHERE p.animal_id=a.id AND p.is_public=1 ORDER BY p.is_primary DESC,p.id DESC LIMIT 1) primary_photo FROM animals a LEFT JOIN ref_species s ON s.code=a.species WHERE a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede')"; + $params = []; + if ($species !== '') { + $sql .= ' AND a.species=:species'; + $params[':species'] = $species; + } + $sql .= ' ORDER BY a.updated_at DESC,a.name COLLATE NOCASE LIMIT ' . max(1, min(500, $limit)); + $s = DB::pdo()->prepare($sql); + $s->execute($params); + return $s->fetchAll(PDO::FETCH_ASSOC); + } + private static function species(): array + { + return DB::pdo() + ->query( + "SELECT s.code,s.name,s.icon,COUNT(a.id) count FROM ref_species s JOIN animals a ON a.species=s.code AND a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede') GROUP BY s.id ORDER BY s.sort_order,s.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + private static function render(string $view, array $vars = []): void + { + extract($vars, EXTR_SKIP); + $settings = AppSettings::all(); + $publicSpecies = self::species(); + require dirname(__DIR__) . '/Views/public_site_layout.php'; + } + private static function notFound(): void + { + http_response_code(404); + self::render('public_site_unavailable.php', ['title' => t('public_site.not_found')]); + } +} diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php new file mode 100644 index 0000000..aee3eb6 --- /dev/null +++ b/app/Controllers/SearchController.php @@ -0,0 +1,26 @@ + t('search.title'), + 'searchQuery' => $q, + 'searchResults' => GlobalSearchService::search($q, 20), + ]); + } + public static function json(): void + { + $q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 120); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: private, no-store'); + echo json_encode( + ['query' => $q, 'groups' => GlobalSearchService::search($q)], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ); + } +} diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php new file mode 100644 index 0000000..abe656b --- /dev/null +++ b/app/Controllers/SettingsController.php @@ -0,0 +1,998 @@ + t('settings.general_title'), + 'settings' => AppSettings::all(), + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + 'associationLogo' => AssociationBrand::path(), + ]); + } + public static function saveGeneral(): void + { + self::admin(); + self::postOnly(); + $values = []; + foreach ( + [ + 'association_name', + 'association_address', + 'association_postal_code', + 'association_city', + 'association_phone', + 'association_email', + 'association_siret', + 'association_rna', + ] + as $key + ) { + $values[$key] = trim((string) ($_POST[$key] ?? '')); + } + if ($values['association_name'] === '') { + http_response_code(400); + echo h(t('settings.association_required')); + return; + } + if ($values['association_email'] !== '' && !filter_var($values['association_email'], FILTER_VALIDATE_EMAIL)) { + http_response_code(400); + echo h(t('settings.invalid_email')); + return; + } + $values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15))); + $values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10))); + $values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')]) + ? (string) $_POST['app_language'] + : 'fr'; + try { + $fullAddress = implode( + ', ', + array_filter([ + $values['association_address'], + trim($values['association_postal_code'] . ' ' . $values['association_city']), + ]), + ); + if ($fullAddress === '') { + $values['association_latitude'] = ''; + $values['association_longitude'] = ''; + } elseif ($point = GeoService::geocode(DB::pdo(), $fullAddress)) { + $values['association_latitude'] = (string) $point['lat']; + $values['association_longitude'] = (string) $point['lng']; + } + if (isset($_POST['delete_association_logo'])) { + AssociationBrand::delete(); + } elseif (isset($_FILES['association_logo'])) { + AssociationBrand::store($_FILES['association_logo']); + } + AppSettings::save($values, Auth::id()); + AuditService::log( + 'settings_saved', + '/settings/general/save', + 'Paramètres généraux et identité visuelle modifiés', + ); + header('Location: /settings/general?saved=1'); + } catch (Throwable $e) { + header('Location: /settings/general?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function associationLogo(): void + { + self::admin(); + $path = AssociationBrand::path(); + if (!$path) { + http_response_code(404); + return; + } + $mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream'; + header('Content-Type: ' . $mime); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: private, max-age=3600'); + header('X-Content-Type-Options: nosniff'); + readfile($path); + } + public static function users(): void + { + self::admin(); + $users = DB::pdo() + ->query('SELECT * FROM users ORDER BY active DESC,display_name COLLATE NOCASE,username COLLATE NOCASE') + ->fetchAll(PDO::FETCH_ASSOC); + render('settings_users.php', ['title' => t('settings.users_title'), 'users' => $users, 'roles' => self::ROLES]); + } + public static function saveUser(): void + { + self::admin(); + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $db = DB::pdo(); + $id = (int) ($_POST['id'] ?? 0); + $username = trim((string) ($_POST['username'] ?? '')); + $display = trim((string) ($_POST['display_name'] ?? '')); + $role = (string) ($_POST['role'] ?? 'lecture'); + $active = isset($_POST['active']) ? 1 : 0; + $password = (string) ($_POST['password'] ?? ''); + if (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username) || $display === '' || !isset(self::ROLES[$role])) { + http_response_code(400); + echo h(t('settings.invalid_data')); + return; + } + if ($id === Auth::id() && (!$active || $role !== 'admin')) { + http_response_code(400); + echo h(t('settings.keep_admin')); + return; + } + if ($id) { + $params = [':u' => $username, ':d' => $display, ':r' => $role, ':a' => $active, ':id' => $id]; + $sql = "UPDATE users SET username=:u,display_name=:d,role=:r,active=:a,updated_at=datetime('now')"; + if ($password !== '') { + if (strlen($password) < 10) { + http_response_code(400); + echo h(t('settings.password_short')); + return; + } + $sql .= ',password_hash=:p'; + $params[':p'] = password_hash($password, PASSWORD_DEFAULT); + } + $sql .= ' WHERE id=:id'; + $db->prepare($sql)->execute($params); + $summary = 'Utilisateur modifié : ' . $display; + } else { + if (strlen($password) < 10) { + http_response_code(400); + echo h(t('settings.password_required')); + return; + } + $db->prepare( + 'INSERT INTO users(username,password_hash,display_name,role,active) VALUES(:u,:p,:d,:r,:a)', + )->execute([ + ':u' => $username, + ':p' => password_hash($password, PASSWORD_DEFAULT), + ':d' => $display, + ':r' => $role, + ':a' => $active, + ]); + $id = (int) $db->lastInsertId(); + $summary = 'Utilisateur créé : ' . $display; + } + AuditService::log('user_saved', '/settings/users/save', $summary, 'user', $id, [ + 'role' => $role, + 'active' => $active, + ]); + header('Location: /settings/users'); + exit(); + } + public static function permissions(): void + { + self::admin(); + render('settings_permissions.php', [ + 'title' => t('settings.permissions_title'), + 'roles' => PermissionService::ROLES, + 'modules' => PermissionService::MODULES, + 'matrix' => PermissionService::matrix(), + 'saved' => isset($_GET['saved']), + ]); + } + public static function savePermissions(): void + { + self::admin(); + self::postOnly(); + PermissionService::save((array) ($_POST['permissions'] ?? []), Auth::id()); + AuditService::log('permissions_saved', '/settings/permissions/save', 'Permissions des rôles modifiées'); + header('Location: /settings/permissions?saved=1'); + exit(); + } + public static function species(): void + { + self::admin(); + render('settings_species.php', [ + 'title' => t('settings.species_title'), + 'species' => SpeciesService::all(), + 'categories' => SpeciesService::CATEGORIES, + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function rooms(): void + { + self::admin(); + render('settings_rooms.php', [ + 'title' => t('rooms.title'), + 'rooms' => ShelterRoomService::all(), + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function saveRooms(): void + { + self::admin(); + self::postOnly(); + $db = DB::pdo(); + $submitted = (array) ($_POST['rooms'] ?? []); + try { + $db->beginTransaction(); + foreach ($submitted as $id => $row) { + $id = (int) $id; + if (!$id) { + continue; + } + $name = trim((string) ($row['name'] ?? '')); + $type = (string) ($row['room_type'] ?? 'collective'); + $status = (string) ($row['status_code'] ?? 'refuge'); + $color = (string) ($row['color'] ?? '#6c757d'); + if ( + $name === '' || + !in_array($type, ['collective', 'boxes'], true) || + !in_array($status, ['refuge', 'soin', 'quarantaine'], true) || + !preg_match('/^#[0-9a-fA-F]{6}$/', $color) + ) { + throw new RuntimeException(t('rooms.invalid')); + } + $db->prepare( + "UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,updated_at=datetime('now') WHERE id=?", + )->execute([ + $name, + $type, + $status, + $color, + isset($row['bulk_validation']) ? 1 : 0, + isset($row['active']) ? 1 : 0, + max(0, (int) ($row['sort_order'] ?? 100)), + $id, + ]); + self::syncBoxes($db, $id, (string) ($row['boxes'] ?? '')); + } + $new = (array) ($_POST['new_room'] ?? []); + $newName = trim((string) ($new['name'] ?? '')); + if ($newName !== '') { + $code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName)); + if (strlen($code) < 2) { + throw new RuntimeException(t('rooms.invalid')); + } + $type = in_array($new['room_type'] ?? '', ['collective', 'boxes'], true) + ? (string) $new['room_type'] + : 'collective'; + $status = in_array($new['status_code'] ?? '', ['refuge', 'soin', 'quarantaine'], true) + ? (string) $new['status_code'] + : 'refuge'; + $color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? '')) + ? (string) $new['color'] + : '#6c757d'; + $db->prepare( + 'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)', + )->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]); + self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? '')); + } + $db->commit(); + AuditService::log('rooms_saved', '/settings/rooms/save', 'Salles et box du refuge modifiés'); + header('Location: /settings/rooms?saved=1'); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + header('Location: /settings/rooms?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + private static function syncBoxes(PDO $db, int $roomId, string $lines): void + { + $names = array_values( + array_filter(array_map('trim', preg_split('/\R/u', $lines) ?: []), static fn($name) => $name !== ''), + ); + $existing = $db->prepare('SELECT id,code FROM shelter_boxes WHERE room_id=? ORDER BY sort_order,id'); + $existing->execute([$roomId]); + $known = $existing->fetchAll(PDO::FETCH_ASSOC); + $kept = []; + foreach ($names as $index => $name) { + $position = ($index + 1) * 10; + if (isset($known[$index])) { + $id = (int) $known[$index]['id']; + $kept[] = $id; + $db->prepare( + "UPDATE shelter_boxes SET name=?,active=1,sort_order=?,updated_at=datetime('now') WHERE id=?", + )->execute([$name, $position, $id]); + continue; + } + $code = ShelterRoomService::normalizeCode($name) ?: 'box-' . ($index + 1); + $base = $code; + $suffix = 2; + $exists = $db->prepare('SELECT 1 FROM shelter_boxes WHERE room_id=? AND code=?'); + while (true) { + $exists->execute([$roomId, $code]); + if (!$exists->fetchColumn()) { + break; + } + $code = $base . '-' . $suffix++; + } + $db->prepare('INSERT INTO shelter_boxes(room_id,code,name,sort_order) VALUES(?,?,?,?)')->execute([ + $roomId, + $code, + $name, + $position, + ]); + $kept[] = (int) $db->lastInsertId(); + } + foreach ($known as $box) { + if (!in_array((int) $box['id'], $kept, true)) { + $db->prepare("UPDATE shelter_boxes SET active=0,updated_at=datetime('now') WHERE id=?")->execute([ + (int) $box['id'], + ]); + } + } + } + public static function saveSpecies(): void + { + self::admin(); + self::postOnly(); + $db = DB::pdo(); + $id = (int) ($_POST['id'] ?? 0); + $name = trim((string) ($_POST['name'] ?? '')); + $code = SpeciesService::normalize((string) ($_POST['code'] ?? '')); + $category = (string) ($_POST['category'] ?? 'autre'); + $icon = trim((string) ($_POST['icon'] ?? '')) ?: '🐾'; + $color = trim((string) ($_POST['color'] ?? '#6c757d')); + $active = isset($_POST['active']) ? 1 : 0; + $sort = max(0, min(999, (int) ($_POST['sort_order'] ?? 100))); + if ( + $name === '' || + !preg_match('/^[a-z0-9][a-z0-9-]{1,49}$/', $code) || + !isset(SpeciesService::CATEGORIES[$category]) || + !preg_match('/^#[0-9a-fA-F]{6}$/', $color) + ) { + header('Location: /settings/species?error=' . rawurlencode(t('settings.species_invalid'))); + exit(); + } + try { + if ($id > 0) { + $old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id'); + $old->execute([':id' => $id]); + $oldRow = $old->fetch(PDO::FETCH_ASSOC); + if (!$oldRow) { + throw new RuntimeException(t('settings.species_not_found')); + } + $oldCode = (string) $oldRow['code']; + if ((int) $oldRow['is_system'] === 1) { + $code = $oldCode; + } + $db->beginTransaction(); + $db->prepare( + 'UPDATE ref_species SET code=:code,name=:name,category=:category,icon=:icon,color=:color,active=:active,sort_order=:sort,updated_at=datetime(\'now\') WHERE id=:id', + )->execute([ + ':code' => $code, + ':name' => $name, + ':category' => $category, + ':icon' => $icon, + ':color' => $color, + ':active' => $active, + ':sort' => $sort, + ':id' => $id, + ]); + if ($oldCode !== $code) { + $db->prepare('UPDATE animals SET species=:new WHERE lower(trim(species))=lower(:old)')->execute([ + ':new' => $code, + ':old' => $oldCode, + ]); + } + $db->commit(); + } else { + $db->prepare( + 'INSERT INTO ref_species(code,name,category,icon,color,active,sort_order) VALUES(:code,:name,:category,:icon,:color,:active,:sort)', + )->execute([ + ':code' => $code, + ':name' => $name, + ':category' => $category, + ':icon' => $icon, + ':color' => $color, + ':active' => $active, + ':sort' => $sort, + ]); + } + AuditService::log( + 'species_saved', + '/settings/species/save', + 'Espèce enregistrée : ' . $name, + 'species', + $id ?: ((int) $db->lastInsertId()), + ); + header('Location: /settings/species?saved=1'); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + header('Location: /settings/species?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function pricing(): void + { + self::admin(); + $db = DB::pdo(); + $clinics = $db + ->query( + "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere') WHERE dc.kind='organization' AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $discounts = []; + foreach ($db->query('SELECT * FROM clinic_discount_rules')->fetchAll(PDO::FETCH_ASSOC) as $r) { + $discounts[(int) $r['clinic_contact_id']][(string) $r['discount_group']] = $r; + } + render('settings_pricing.php', [ + 'title' => t('pricing.title'), + 'tariffs' => PricingService::tariffs(), + 'clinics' => $clinics, + 'discounts' => $discounts, + 'categories' => PricingService::CATEGORIES, + 'groups' => PricingService::GROUPS, + 'medications' => $db + ->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE') + ->fetchAll(PDO::FETCH_ASSOC), + 'vaccines' => $db + ->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE') + ->fetchAll(PDO::FETCH_ASSOC), + 'dewormers' => $db + ->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE') + ->fetchAll(PDO::FETCH_ASSOC), + 'settings' => AppSettings::all(), + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function saveTariff(): void + { + self::admin(); + self::postOnly(); + $db = DB::pdo(); + $id = (int) ($_POST['id'] ?? 0); + $clinic = (int) ($_POST['clinic_contact_id'] ?? 0); + $label = trim((string) ($_POST['label'] ?? '')); + $category = (string) ($_POST['category'] ?? 'other'); + $group = (string) ($_POST['discount_group'] ?? 'act'); + $unit = trim((string) ($_POST['unit_label'] ?? 'acte')) ?: 'acte'; + $amount = PricingService::cents((string) ($_POST['amount'] ?? '0')); + $active = isset($_POST['active']) ? 1 : 0; + $eligible = $db->prepare( + "SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))", + ); + $eligible->execute([':id' => $clinic]); + if ( + !$clinic || + !$eligible->fetchColumn() || + $label === '' || + !isset(PricingService::CATEGORIES[$category]) || + !isset(PricingService::GROUPS[$group]) + ) { + header('Location: /settings/pricing?error=' . rawurlencode(t('pricing.invalid_structure'))); + exit(); + } + $params = [ + $clinic, + mb_substr($label, 0, 180), + $category, + $group, + mb_substr($unit, 0, 50), + (int) ($_POST['medication_id'] ?? 0) ?: null, + (int) ($_POST['vaccine_id'] ?? 0) ?: null, + (int) ($_POST['dewormer_id'] ?? 0) ?: null, + $amount, + $active, + trim((string) ($_POST['notes'] ?? '')) ?: null, + ]; + try { + if ($id) { + $params[] = $id; + $db->prepare( + "UPDATE clinic_tariffs SET clinic_contact_id=?,label=?,category=?,discount_group=?,unit_label=?,medication_id=?,vaccine_id=?,dewormer_id=?,amount_cents=?,active=?,notes=?,updated_at=datetime('now') WHERE id=?", + )->execute($params); + } else { + $db->prepare( + 'INSERT INTO clinic_tariffs(clinic_contact_id,label,category,discount_group,unit_label,medication_id,vaccine_id,dewormer_id,amount_cents,active,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + )->execute($params); + $id = (int) $db->lastInsertId(); + } + AuditService::log( + 'tariff_saved', + '/settings/pricing/tariff', + 'Tarif enregistré : ' . $label, + 'clinic_tariff', + $id, + ); + header('Location: /settings/pricing?saved=1'); + } catch (Throwable $e) { + header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function saveDiscount(): void + { + self::admin(); + self::postOnly(); + $clinic = (int) ($_POST['clinic_contact_id'] ?? 0); + $submitted = (array) ($_POST['discounts'] ?? []); + $db = DB::pdo(); + $eligible = $db->prepare( + "SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))", + ); + $eligible->execute([':id' => $clinic]); + if (!$clinic || !$eligible->fetchColumn()) { + http_response_code(400); + echo h(t('pricing.invalid_discount_structure')); + return; + } + $stmt = $db->prepare( + 'INSERT INTO clinic_discount_rules(clinic_contact_id,discount_group,label,percent) VALUES(?,?,?,?) ON CONFLICT(clinic_contact_id,discount_group) DO UPDATE SET label=excluded.label,percent=excluded.percent', + ); + $saved = []; + try { + $db->beginTransaction(); + foreach (PricingService::GROUPS as $group => $label) { + if ($group === 'none' || !array_key_exists($group, $submitted)) { + continue; + } + $percent = max(0, min(100, (float) str_replace(',', '.', (string) $submitted[$group]))); + $stmt->execute([$clinic, $group, $label, $percent]); + $saved[$group] = $percent; + } + $db->commit(); + AuditService::log( + 'discount_saved', + '/settings/pricing/discount', + 'Remises tarifaires modifiées', + 'clinic', + $clinic, + $saved, + ); + header('Location: /settings/pricing?saved=1'); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function saveAdoptionFees(): void + { + self::admin(); + self::postOnly(); + AppSettings::save( + [ + 'adoption_fee_sterilized' => (string) max( + 0, + min(2000, (int) ($_POST['adoption_fee_sterilized'] ?? 220)), + ), + 'adoption_fee_unsterilized' => (string) max( + 0, + min(2000, (int) ($_POST['adoption_fee_unsterilized'] ?? 200)), + ), + ], + Auth::id(), + ); + AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs d’adoption modifiés'); + header('Location: /settings/pricing?saved=1'); + exit(); + } + public static function labReferences(): void + { + self::admin(); + $rows = LabReferenceService::all(); + $groups = []; + foreach ($rows as $row) { + $groups[$row['analyzer_name']][$row['species_code']][] = $row; + } + render('settings_lab_references.php', [ + 'title' => t('lab_reference.title'), + 'groups' => $groups, + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function saveLabReferences(): void + { + self::admin(); + self::postOnly(); + $db = DB::pdo(); + $input = (array) ($_POST['ranges'] ?? []); + $stmt = $db->prepare( + "UPDATE lab_reference_ranges SET parameter_name=?,unit=?,reference_min=?,reference_max=?,source_note=?,active=?,updated_by=?,updated_at=datetime('now') WHERE id=?", + ); + try { + $db->beginTransaction(); + foreach ($input as $id => $row) { + $id = (int) $id; + $name = trim((string) ($row['name'] ?? '')); + $unit = trim((string) ($row['unit'] ?? '')); + $min = self::nullableNumber($row['min'] ?? ''); + $max = self::nullableNumber($row['max'] ?? ''); + if (!$id || $name === '' || $unit === '' || ($min !== null && $max !== null && $min > $max)) { + throw new RuntimeException(t('lab_reference.invalid')); + } + $stmt->execute([ + mb_substr($name, 0, 120), + mb_substr($unit, 0, 30), + $min, + $max, + mb_substr(trim((string) ($row['source'] ?? '')), 0, 255) ?: null, + isset($row['active']) ? 1 : 0, + Auth::id(), + $id, + ]); + } + $db->commit(); + AuditService::log( + 'lab_references_saved', + '/settings/lab-references/save', + 'Intervalles de référence biologiques modifiés', + ); + header('Location: /settings/lab-references?saved=1'); + } catch (RuntimeException $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + header('Location: /settings/lab-references?error=' . rawurlencode($e->getMessage())); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function publicSite(): void + { + self::admin(); + render('settings_public_site.php', [ + 'title' => t('public_site.settings_title'), + 'settings' => AppSettings::all(), + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function health(): void + { + self::admin(); + render('settings_health.php', [ + 'title' => t('health.page_title'), + 'report' => SystemHealthService::report(), + 'installed' => isset($_GET['installed']), + ]); + } + public static function notifications(): void + { + self::admin(); + render('settings_notifications.php', [ + 'title' => t('notification.page_title'), + 'settings' => AppSettings::all(), + 'saved' => isset($_GET['saved']), + 'sent' => (string) ($_GET['sent'] ?? ''), + ]); + } + public static function saveNotifications(): void + { + self::admin(); + self::postOnly(); + $email = trim((string) ($_POST['notification_email_recipient'] ?? '')); + if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + header('Location: /settings/notifications?sent=invalid'); + exit(); + } + $frequency = in_array((string) ($_POST['notification_email_frequency'] ?? ''), ['daily', 'weekly'], true) + ? (string) $_POST['notification_email_frequency'] + : 'weekly'; + AppSettings::save( + [ + 'notification_email_enabled' => isset($_POST['notification_email_enabled']) ? '1' : '0', + 'notification_email_frequency' => $frequency, + 'notification_email_recipient' => $email, + ], + Auth::id(), + ); + AuditService::log('notifications_saved', '/settings/notifications', 'Configuration des notifications modifiée'); + header('Location: /settings/notifications?saved=1'); + exit(); + } + public static function sendNotifications(): void + { + self::admin(); + self::postOnly(); + $result = NotificationService::send(true); + AuditService::log( + 'notification_digest', + '/settings/notifications/send', + 'Envoi manuel du résumé : ' . $result['reason'], + ); + header('Location: /settings/notifications?sent=' . rawurlencode((string) $result['reason'])); + exit(); + } + public static function savePublicSite(): void + { + self::admin(); + self::postOnly(); + $theme = (string) ($_POST['public_site_theme'] ?? 'warm'); + if (!in_array($theme, ['warm', 'nature', 'minimal'], true)) { + $theme = 'warm'; + } + $routing = (string) ($_POST['public_site_routing'] ?? 'integrated'); + if (!in_array($routing, ['integrated', 'root'], true)) { + $routing = 'integrated'; + } + $values = [ + 'public_site_enabled' => isset($_POST['public_site_enabled']) ? '1' : '0', + 'public_site_theme' => $theme, + 'public_site_routing' => $routing, + 'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0', + ]; + foreach ( + [ + 'public_site_home_title' => 150, + 'public_site_home_text' => 5000, + 'public_site_about_title' => 150, + 'public_site_about_text' => 10000, + 'public_site_donation_title' => 150, + 'public_site_donation_text' => 10000, + 'public_site_contact_text' => 5000, + 'public_site_legal_text' => 15000, + 'public_site_privacy_text' => 15000, + ] + as $key => $max + ) { + $values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max); + } + foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url'] as $key) { + $url = trim((string) ($_POST[$key] ?? '')); + $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); + if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) { + header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_url'))); + exit(); + } + $values[$key] = $url; + } + AppSettings::save($values, Auth::id()); + AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée'); + header('Location: /settings/public-site?saved=1'); + exit(); + } + public static function audit(): void + { + self::admin(); + $rows = DB::pdo() + ->query( + 'SELECT al.*,COALESCE(u.display_name,u.username) actor FROM audit_log al LEFT JOIN users u ON u.id=al.user_id ORDER BY al.id DESC LIMIT 500', + ) + ->fetchAll(PDO::FETCH_ASSOC); + render('settings_audit.php', ['title' => t('audit.title'), 'rows' => $rows]); + } + public static function dataTools(): void + { + self::admin(); + $db = DB::pdo(); + render('settings_data.php', [ + 'title' => t('data.title'), + 'counts' => [ + 'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(), + 'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(), + ], + 'status' => (string) ($_GET['status'] ?? ''), + 'backup' => (string) ($_GET['backup'] ?? ''), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function resetData(): void + { + self::dataOperation(false); + } + public static function loadDemoData(): void + { + self::dataOperation(true); + } + private static function dataOperation(bool $demo): void + { + self::admin(); + self::postOnly(); + $expected = t($demo ? 'data.confirm_demo_phrase' : 'data.confirm_reset_phrase'); + if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) { + header( + 'Location: /settings/data?error=' . rawurlencode(t('data.confirm_required', ['phrase' => $expected])), + ); + exit(); + } + $db = DB::pdo(); + try { + $backup = BackupService::create($demo ? 'pre-demo-data' : 'pre-data-reset'); + $db->beginTransaction(); + $removed = DemoDataService::reset($db); + $demo ? DemoDataService::seed($db, Auth::id()) : null; + $db->commit(); + DemoDataService::clearFiles(); + if ($demo) { + DemoDataService::installFiles($db); + } + AuditService::log( + $demo ? 'demo_data_loaded' : 'business_data_reset', + '/settings/data', + $demo ? 'Base fictive de démonstration chargée' : 'Données métier remises à zéro', + null, + null, + [ + 'removed_animals' => $removed['animals'], + 'removed_contacts' => $removed['contacts'], + 'backup' => $backup['name'], + ], + ); + header( + 'Location: /settings/data?status=' . + ($demo ? 'demo' : 'reset') . + '&backup=' . + rawurlencode($backup['name']), + ); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + header('Location: /settings/data?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function backups(): void + { + self::admin(); + render('settings_backups.php', [ + 'title' => t('backups.title'), + 'backups' => BackupService::list(), + 'status' => (string) ($_GET['status'] ?? ''), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function createBackup(): void + { + self::admin(); + self::postOnly(); + try { + $backup = BackupService::create(); + AuditService::log('backup_created', '/settings/backups/create', 'Sauvegarde créée : ' . $backup['name']); + header('Location: /settings/backups?status=created'); + } catch (Throwable $e) { + header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function saveBackupSchedule(): void + { + self::admin(); + self::postOnly(); + $frequency = (string) ($_POST['backup_schedule_frequency'] ?? 'daily'); + if (!in_array($frequency, ['daily', 'weekly'], true)) { + $frequency = 'daily'; + } + AppSettings::save( + [ + 'backup_schedule_enabled' => isset($_POST['backup_schedule_enabled']) ? '1' : '0', + 'backup_schedule_frequency' => $frequency, + ], + Auth::id(), + ); + AuditService::log( + 'backup_schedule_saved', + '/settings/backups/schedule', + 'Planification des sauvegardes modifiée', + ); + header('Location: /settings/backups?status=schedule'); + exit(); + } + public static function verifyBackup(): void + { + self::admin(); + self::postOnly(); + try { + $result = BackupService::verify((string) ($_POST['file'] ?? '')); + AppSettings::save(['backup_last_verified_at' => date('Y-m-d H:i:s')], Auth::id()); + AuditService::log( + 'backup_verified', + '/settings/backups/verify', + 'Sauvegarde vérifiée : ' . $result['name'], + ); + header('Location: /settings/backups?status=verified'); + } catch (RuntimeException $e) { + header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); + } catch (Throwable $e) { + header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function downloadBackup(): void + { + self::admin(); + $path = BackupService::path((string) ($_GET['file'] ?? '')); + if (!$path) { + http_response_code(404); + echo h(t('backups.not_found')); + return; + } + PrivacyService::logAccess('backup', null, null, basename($path)); + AuditService::log('private_file_viewed', '/settings/backups/download', 'Sauvegarde privée téléchargée'); + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . basename($path) . '"'); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: private,no-store'); + header('X-Content-Type-Options: nosniff'); + readfile($path); + } + public static function deleteBackup(): void + { + self::admin(); + self::postOnly(); + $name = (string) ($_POST['file'] ?? ''); + try { + BackupService::delete($name); + AuditService::log('backup_deleted', '/settings/backups/delete', 'Sauvegarde supprimée : ' . $name); + header('Location: /settings/backups?status=deleted'); + } catch (RuntimeException $e) { + header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); + } catch (Throwable $e) { + header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function restoreBackup(): void + { + self::admin(); + self::postOnly(); + $upload = $_FILES['backup'] ?? null; + if ( + !is_array($upload) || + ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || + !is_uploaded_file((string) $upload['tmp_name']) + ) { + header('Location: /settings/backups?error=' . rawurlencode(t('backups.invalid_file'))); + exit(); + } + if ((int) ($upload['size'] ?? 0) > 1_000_000_000) { + header('Location: /settings/backups?error=' . rawurlencode(t('backups.too_large'))); + exit(); + } + try { + BackupService::restore((string) $upload['tmp_name']); + Auth::logout(); + header('Location: /login?restored=1'); + } catch (RuntimeException $e) { + header('Location: /settings/backups?error=' . rawurlencode($e->getMessage())); + } catch (Throwable $e) { + header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + private static function postOnly(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + header('Allow: POST'); + exit(); + } + } + private static function nullableNumber(mixed $value): ?float + { + $value = str_replace(',', '.', trim((string) $value)); + if ($value === '') { + return null; + } + if (!is_numeric($value)) { + throw new RuntimeException(t('lab_reference.invalid')); + } + return (float) $value; + } + private static function admin(): void + { + if (!Auth::is('admin')) { + http_response_code(403); + echo h(t('settings.admin_only')); + exit(); + } + } +} diff --git a/app/Controllers/StatisticsController.php b/app/Controllers/StatisticsController.php new file mode 100644 index 0000000..9d1e906 --- /dev/null +++ b/app/Controllers/StatisticsController.php @@ -0,0 +1,42 @@ + t('statistics.title'), + 'pageDescription' => t('statistics.description'), + 'year' => $year, + 'allYears' => $all, + 'years' => $years, + 'municipality' => $municipality, + 'municipalities' => $municipalities, + 'municipalityCoverage' => StatisticsService::municipalityCoverage(), + 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids), + 'comparison' => $all ? [] : StatisticsService::comparison($year, $ids), + 'timeline' => StatisticsService::timeline($years, $ids), + 'undatedHandled' => StatisticsService::undatedHandled($ids), + 'undatedDeaths' => StatisticsService::undatedDeaths($ids), + 'undatedBirths' => StatisticsService::undatedBirths($ids), + 'undatedExits' => StatisticsService::undatedExits($ids), + 'scopeCounts' => StatisticsService::scopeCounts($ids), + 'distributions' => StatisticsService::distributions($year, $ids, $municipality ?: null), + 'health' => StatisticsService::currentHealth($ids), + 'costs' => StatisticsService::costs($year, $ids), + 'municipalityCosts' => StatisticsService::costsByMunicipality($year), + ]); + } +} diff --git a/app/Controllers/SurgeriesController.php b/app/Controllers/SurgeriesController.php new file mode 100644 index 0000000..01fab0d --- /dev/null +++ b/app/Controllers/SurgeriesController.php @@ -0,0 +1,116 @@ +prepare('SELECT id,name,species FROM animals WHERE id=? AND deleted_at IS NULL'); + $s->execute([$id]); + $animal = $s->fetch(PDO::FETCH_ASSOC); + if (!$animal) { + http_response_code(404); + return; + } + $clinics = $db + ->query( + "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND r.role IN ('cabinet','structure_veterinaire') ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $vets = $db + ->query( + "SELECT dc.id,dc.name,dc.organization_id FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='veterinaire' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $tariffs = $db + ->query( + "SELECT ct.id,ct.clinic_contact_id,ct.label,ct.amount_cents,COALESCE(dr.percent,0) discount FROM clinic_tariffs ct LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group WHERE ct.active=1 AND ct.category IN ('surgery','dental','imaging') ORDER BY ct.label COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + render('surgery_form.php', [ + 'title' => 'Chirurgie de ' . $animal['name'], + 'animal' => $animal, + 'clinics' => $clinics, + 'vets' => $vets, + 'tariffs' => $tariffs, + ]); + } + public static function save(): void + { + $animal = (int) ($_POST['animal_id'] ?? 0); + $name = trim((string) ($_POST['procedure_name'] ?? '')); + $date = (string) ($_POST['surgery_date'] ?? ''); + $category = (string) ($_POST['category'] ?? 'other'); + $status = (string) ($_POST['status'] ?? 'completed'); + if ( + !$animal || + $name === '' || + !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || + !in_array($status, ['planned', 'completed', 'cancelled'], true) + ) { + http_response_code(400); + echo h(t('surgery.invalid_data')); + return; + } + $db = DB::pdo(); + $tariff = (int) ($_POST['tariff_id'] ?? 0) ?: null; + $amountRaw = str_replace(',', '.', trim((string) ($_POST['amount'] ?? ''))); + $amount = $amountRaw === '' ? null : (int) round(max(0, (float) $amountRaw) * 100); + if ($amount === null && $tariff) { + $q = $db->prepare( + 'SELECT amount_cents*(1-COALESCE((SELECT percent FROM clinic_discount_rules WHERE clinic_contact_id=ct.clinic_contact_id AND discount_group=ct.discount_group),0)/100.0) FROM clinic_tariffs ct WHERE id=?', + ); + $q->execute([$tariff]); + $v = $q->fetchColumn(); + $amount = $v === false ? null : (int) round((float) $v); + } + $db->beginTransaction(); + try { + $s = $db->prepare( + 'INSERT INTO animal_surgeries(animal_id,category,procedure_name,surgery_date,status,clinic_contact_id,veterinarian_contact_id,tariff_id,amount_cents,anesthesia,notes,follow_up,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)', + ); + $s->execute([ + $animal, + $category, + $name, + $date, + $status, + (int) ($_POST['clinic_contact_id'] ?? 0) ?: null, + (int) ($_POST['veterinarian_contact_id'] ?? 0) ?: null, + $tariff, + $amount, + trim((string) ($_POST['anesthesia'] ?? '')) ?: null, + trim((string) ($_POST['notes'] ?? '')) ?: null, + trim((string) ($_POST['follow_up'] ?? '')) ?: null, + Auth::id(), + ]); + $id = (int) $db->lastInsertId(); + if ($status === 'completed' && $amount !== null) { + $db->prepare( + 'INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,source_type,source_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?,?,\'surgery\',?,?,?,1,?,0,?,?,?)', + )->execute([ + $animal, + (int) ($_POST['clinic_contact_id'] ?? 0) ?: null, + $tariff, + $id, + $name, + $date, + $amount, + $amount, + trim((string) ($_POST['notes'] ?? '')) ?: null, + Auth::id(), + ]); + } + $db->prepare( + "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'medical','Chirurgie enregistrée',?,?)", + )->execute([$animal, $name . ' — ' . $date, Auth::id()]); + $db->commit(); + header('Location: /animal?id=' . $animal . '#tab-med'); + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } + } +} diff --git a/app/Routes/web.php b/app/Routes/web.php new file mode 100644 index 0000000..dfd5a24 --- /dev/null +++ b/app/Routes/web.php @@ -0,0 +1,441 @@ +beginTransaction(); + + try { + $animalId = (int) $data['animal_id']; + $date = $data['adoption_date'] ?? date('Y-m-d'); + $beforeStmt = $db->prepare( + 'SELECT status,refuge_room,care_box_key,current_address FROM animals WHERE id=:id', + ); + $beforeStmt->execute([':id' => $animalId]); + $beforeLocation = $beforeStmt->fetch(PDO::FETCH_ASSOC) ?: []; + + // 1. Enregistrement de l’adoption + $stmt = $db->prepare(' + INSERT INTO adoptions ( + animal_id, + adopter_name, + adopter_phone, + adopter_email, + adopter_address, + adopter_postal_code, + adopter_city, + adoption_date, + notes, + adopter_contact_id, + created_at + ) VALUES ( + :animal_id, + :name, + :phone, + :email, + :address, + :postal, + :city, + :date, + :notes, + :contact_id, + CURRENT_TIMESTAMP + ) + '); + + $stmt->execute([ + ':animal_id' => $animalId, + ':name' => $data['adopter_name'], + ':phone' => $data['adopter_phone'] ?: null, + ':email' => $data['adopter_email'] ?: null, + ':address' => $data['adopter_address'] ?: null, + ':postal' => $data['adopter_postal_code'] ?: null, + ':city' => $data['adopter_city'] ?: null, + ':date' => $date, + ':notes' => $data['notes'] ?: null, + ':contact_id' => $data['adopter_contact_id'] ?? null, + ]); + $adoptionId = (int) $db->lastInsertId(); + $db->prepare( + "INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id,created_by) VALUES(:animal,'adoption',:date,:contact,'Adoption définitive',:notes,:adoption,:user)", + )->execute([ + ':animal' => $animalId, + ':date' => $date, + ':contact' => $data['adopter_contact_id'] ?? null, + ':notes' => $data['notes'] ?: null, + ':adoption' => $adoptionId, + ':user' => Auth::id(), + ]); + + // 2. Mise à jour du statut de l’animal + $currentAddress = implode( + ' ', + array_filter([ + $data['adopter_address'] ?? '', + $data['adopter_postal_code'] ?? '', + $data['adopter_city'] ?? '', + ]), + ); + + $lat = null; + $lng = null; + + if (!empty($currentAddress)) { + $coords = GeocodeService::geocode($currentAddress); + + if ($coords) { + $lat = $coords['lat'] ?? null; + $lng = $coords['lng'] ?? null; + } + } + + $db->prepare( + " + UPDATE animals + SET + status = 'adopte', + refuge_room = NULL, + care_box_key = NULL, + updated_at = CURRENT_TIMESTAMP, + current_address = :currentAddress, + current_lat = :lat, + current_lng = :lng + WHERE id = :id + ", + )->execute([':id' => $animalId, ':currentAddress' => $currentAddress, ':lat' => $lat, ':lng' => $lng]); + + LocationHistoryService::record( + $db, + $animalId, + $beforeLocation, + [ + 'status' => 'adopte', + 'refuge_room' => null, + 'care_box_key' => null, + 'current_address' => $currentAddress, + ], + 'adoption', + 'Adoption finalisée', + $date . ' 12:00:00', + ); + + // 3. Ajout dans les mouvements administratifs (sortie) + $stmt = $db->prepare(" + INSERT INTO animal_movements ( + animal_id, + kind, + place, + lieu, + contact_name, + contact_phone, + contact_email, + note, + created_at + ) VALUES ( + :animal_id, + 'exit', + :place, + 'Adoption', + :contact_name, + :contact_phone, + :contact_email, + :note, + CURRENT_TIMESTAMP + ) + "); + + $stmt->execute([ + ':animal_id' => $animalId, + ':place' => $data['adopter_address'] ?: null, + ':contact_name' => $data['adopter_name'] ?: null, + ':contact_phone' => $data['adopter_phone'] ?: null, + ':contact_email' => $data['adopter_email'] ?: null, + ':note' => 'Adoption finalisée', + ]); + + // 4. Ajout dans l’historique + $stmt = $db->prepare(" + INSERT INTO animal_history ( + animal_id, + type, + label, + details, + user_id, + created_at + ) VALUES ( + :animal_id, + 'adoption', + 'Adoption finalisée', + :details, + :user_id, + CURRENT_TIMESTAMP + ) + "); + + $stmt->execute([ + ':animal_id' => $animalId, + ':details' => 'Par ' . $data['adopter_name'] . ' - Note(s) : ' . $data['notes'], + ':user_id' => Auth::id(), + ]); + + $db->commit(); + } catch (Exception $e) { + $db->rollBack(); + throw $e; + } + } +} diff --git a/app/Services/AgendaService.php b/app/Services/AgendaService.php new file mode 100644 index 0000000..7b56830 --- /dev/null +++ b/app/Services/AgendaService.php @@ -0,0 +1,351 @@ + $type, + ':title' => mb_substr($title, 0, 180), + ':description' => trim((string) ($data['description'] ?? '')) ?: null, + ':start' => $start, + ':end' => $end, + ':all_day' => $allDay ? 1 : 0, + ':location' => trim((string) ($data['location'] ?? '')) ?: null, + ':priority' => in_array( + $p = (string) ($data['priority'] ?? 'normal'), + ['low', 'normal', 'high', 'urgent'], + true, + ) + ? $p + : 'normal', + ':assigned' => (int) ($data['assigned_user_id'] ?? 0) ?: null, + ':animal' => (int) ($data['animal_id'] ?? 0) ?: null, + ':contact' => (int) ($data['contact_id'] ?? 0) ?: null, + ':reminder' => ($data['reminder_minutes'] ?? '') === '' ? null : max(0, (int) $data['reminder_minutes']), + ':repeat' => $repeat, + ':until' => $until ?: null, + ':user' => Auth::id(), + ]; + $db = DB::pdo(); + $db->beginTransaction(); + try { + if ($id) { + $values[':id'] = $id; + $db->prepare( + "UPDATE agenda_items SET item_type=:type,title=:title,description=:description,starts_at=:start,ends_at=:end,all_day=:all_day,location=:location,priority=:priority,assigned_user_id=:assigned,animal_id=:animal,contact_id=:contact,reminder_minutes=:reminder,repeat_rule=:repeat,repeat_until=:until,updated_at=datetime('now') WHERE id=:id", + )->execute($values); + } else { + $db->prepare( + 'INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,all_day,location,priority,assigned_user_id,animal_id,contact_id,reminder_minutes,repeat_rule,repeat_until,created_by) VALUES(:type,:title,:description,:start,:end,:all_day,:location,:priority,:assigned,:animal,:contact,:reminder,:repeat,:until,:user)', + )->execute($values); + $id = (int) $db->lastInsertId(); + } + self::saveVolunteers($id, $data['volunteer_contact_ids'] ?? []); + $db->commit(); + return $id; + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + public static function find(int $id): ?array + { + $s = DB::pdo()->prepare( + "SELECT ai.*,(SELECT group_concat(contact_id,',') FROM agenda_volunteers WHERE agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai WHERE ai.id=?", + ); + $s->execute([$id]); + return $s->fetch(PDO::FETCH_ASSOC) ?: null; + } + public static function setStatus(int $id, string $status): void + { + if (!in_array($status, ['planned', 'completed', 'cancelled'], true)) { + throw new RuntimeException(t('agenda.invalid')); + } + DB::pdo() + ->prepare( + "UPDATE agenda_items SET status=?,completed_at=CASE WHEN ?='completed' THEN datetime('now') ELSE NULL END,completed_by=CASE WHEN ?='completed' THEN ? ELSE NULL END,updated_at=datetime('now') WHERE id=?", + ) + ->execute([$status, $status, $status, Auth::id(), $id]); + } + public static function conflicts(int $id): array + { + $db = DB::pdo(); + $s = $db->prepare( + "SELECT DISTINCT other.id,other.title,other.starts_at FROM agenda_items current JOIN agenda_items other ON other.id<>current.id AND other.status='planned' AND datetime(other.starts_at)datetime(current.starts_at) WHERE current.id=:id AND current.status='planned' AND ((current.assigned_user_id IS NOT NULL AND other.assigned_user_id=current.assigned_user_id) OR EXISTS(SELECT 1 FROM agenda_volunteers mine JOIN agenda_volunteers theirs ON theirs.contact_id=mine.contact_id AND theirs.agenda_item_id=other.id WHERE mine.agenda_item_id=current.id)) ORDER BY datetime(other.starts_at)", + ); + $s->execute([':id' => $id]); + return $s->fetchAll(PDO::FETCH_ASSOC); + } + public static function month(DateTimeImmutable $month): array + { + return self::between( + $month->modify('first day of this month')->setTime(0, 0), + $month->modify('last day of this month')->setTime(23, 59, 59), + ); + } + public static function week(DateTimeImmutable $date): array + { + $start = $date->modify('monday this week')->setTime(0, 0); + return self::between($start, $start->modify('+6 days')->setTime(23, 59, 59)); + } + public static function range(DateTimeImmutable $from, DateTimeImmutable $to): array + { + return self::between($from, $to); + } + public static function upcoming(int $days = 30): array + { + return self::between(new DateTimeImmutable('today'), new DateTimeImmutable('+' . $days . ' days 23:59:59')); + } + public static function due(): array + { + $db = DB::pdo(); + $overdue = $db + ->query( + "SELECT ai.*,ai.starts_at instance_starts_at,a.name animal_name,COALESCE(u.display_name,u.username) assigned_name FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE ai.status='planned' AND ai.repeat_rule='none' AND datetime(ai.starts_at)fetchAll(PDO::FETCH_ASSOC); + $soon = array_values( + array_filter( + self::upcoming(2), + static fn($row) => $row['status'] === 'planned' && + strtotime($row['instance_starts_at']) <= strtotime('+1 day'), + ), + ); + $seen = []; + $out = []; + foreach ([...$overdue, ...$soon] as $row) { + $key = (int) $row['id'] . '|' . ($row['instance_starts_at'] ?? $row['starts_at']); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $out[] = $row; + } + usort( + $out, + static fn($a, $b) => strcmp( + (string) ($a['instance_starts_at'] ?? $a['starts_at']), + (string) ($b['instance_starts_at'] ?? $b['starts_at']), + ), + ); + return array_slice($out, 0, 25); + } + private static function between(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $db = DB::pdo(); + $s = $db->prepare( + "SELECT ai.*,a.name animal_name,dc.name contact_name,COALESCE(u.display_name,u.username) assigned_name,(SELECT group_concat(vdc.name,', ') FROM agenda_volunteers av JOIN directory_contacts vdc ON vdc.id=av.contact_id WHERE av.agenda_item_id=ai.id) volunteer_names,(SELECT group_concat(av.contact_id,',') FROM agenda_volunteers av WHERE av.agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN directory_contacts dc ON dc.id=ai.contact_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE date(ai.starts_at)<=:to AND (ai.repeat_rule<>'none' OR datetime(ai.starts_at)>=:from) AND ai.status<>'cancelled' ORDER BY datetime(ai.starts_at),ai.priority DESC", + ); + $s->execute([':from' => $from->format('Y-m-d H:i:s'), ':to' => $to->format('Y-m-d')]); + $out = []; + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) { + $start = new DateTimeImmutable($row['starts_at']); + $end = $row['ends_at'] ? new DateTimeImmutable($row['ends_at']) : null; + $step = match ($row['repeat_rule']) { + 'daily' => '+1 day', + 'weekly' => '+1 week', + 'monthly' => '+1 month', + default => null, + }; + while ($start < $from && $step) { + $start = $start->modify($step); + } + do { + if ($start > $to) { + break; + } + if (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']) { + $instance = $row; + $instance['instance_starts_at'] = $start->format('Y-m-d H:i:s'); + $instance['instance_ends_at'] = $end + ? $end + ->modify( + $start->getTimestamp() - + new DateTimeImmutable($row['starts_at'])->getTimestamp() . + ' seconds', + ) + ->format('Y-m-d H:i:s') + : null; + $instance['source_type'] = 'agenda'; + if (!empty($instance['volunteer_names'])) { + $instance['title'] .= ' · 👥 ' . $instance['volunteer_names']; + } + $out[] = $instance; + } + if (!$step) { + break; + } + $start = $start->modify($step); + } while (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']); + } + $vaccines = $db->prepare( + "SELECT v.id,v.due_date,rv.name vaccine_name,a.id animal_id,a.name animal_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id=v.vaccine_id JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM vaccinations newer WHERE newer.animal_id=v.animal_id AND newer.vaccine_id=v.vaccine_id AND date(newer.done_date)>date(v.done_date)) ORDER BY date(v.due_date),a.name COLLATE NOCASE", + ); + $vaccines->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]); + foreach ($vaccines->fetchAll(PDO::FETCH_ASSOC) as $v) { + $out[] = self::reminder( + -(100000000 + (int) $v['id']), + (int) $v['id'], + 'vaccine', + 'vaccine', + t('agenda.vaccine_reminder', ['vaccine' => $v['vaccine_name']]), + $v['due_date'], + (int) $v['animal_id'], + $v['animal_name'], + 'high', + ); + } + + $dewormings = $db->prepare( + "SELECT d.id,d.next_due_date,COALESCE(rd.name,'') dewormer_name,a.id animal_id,a.name animal_name FROM dewormings d LEFT JOIN ref_dewormers rd ON rd.id=d.dewormer_id JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND (date(newer.administered_on)>date(d.administered_on) OR (date(newer.administered_on)=date(d.administered_on) AND newer.id>d.id))) ORDER BY date(d.next_due_date),a.name COLLATE NOCASE", + ); + $dewormings->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]); + foreach ($dewormings->fetchAll(PDO::FETCH_ASSOC) as $d) { + $out[] = self::reminder( + -(200000000 + (int) $d['id']), + (int) $d['id'], + 'deworming', + 'deworming', + t('agenda.deworming_reminder', ['product' => $d['dewormer_name'] ?: t('agenda.deworming')]), + $d['next_due_date'], + (int) $d['animal_id'], + $d['animal_name'], + 'high', + ); + } + + $treatments = $db->prepare( + "SELECT t.id,t.start_date,t.end_date,m.name medication_name,a.id animal_id,a.name animal_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND (date(t.start_date) BETWEEN :from AND :to OR (t.end_date IS NOT NULL AND date(t.end_date) BETWEEN :from AND :to)) AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') ORDER BY date(t.start_date),a.name COLLATE NOCASE", + ); + $treatments->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]); + foreach ($treatments->fetchAll(PDO::FETCH_ASSOC) as $t) { + if ($t['start_date'] >= $from->format('Y-m-d') && $t['start_date'] <= $to->format('Y-m-d')) { + $out[] = self::reminder( + -(300000000 + (int) $t['id']), + (int) $t['id'], + 'treatment_start', + 'treatment_start', + t('agenda.treatment_start', ['medication' => $t['medication_name']]), + $t['start_date'], + (int) $t['animal_id'], + $t['animal_name'], + 'high', + ); + } + if ($t['end_date'] && $t['end_date'] >= $from->format('Y-m-d') && $t['end_date'] <= $to->format('Y-m-d')) { + $out[] = self::reminder( + -(400000000 + (int) $t['id']), + (int) $t['id'], + 'treatment_end', + 'treatment_end', + t('agenda.treatment_end', ['medication' => $t['medication_name']]), + $t['end_date'], + (int) $t['animal_id'], + $t['animal_name'], + 'high', + ); + } + } + usort($out, static fn($a, $b) => strcmp($a['instance_starts_at'], $b['instance_starts_at'])); + return $out; + } + private static function reminder( + int $id, + int $sourceId, + string $sourceType, + string $itemType, + string $title, + string $date, + int $animalId, + string $animalName, + string $priority, + ): array { + return [ + 'id' => $id, + 'source_id' => $sourceId, + 'source_type' => $sourceType, + 'item_type' => $itemType, + 'title' => $title, + 'description' => null, + 'starts_at' => $date . ' 00:00:00', + 'instance_starts_at' => $date . ' 00:00:00', + 'instance_ends_at' => null, + 'all_day' => 1, + 'location' => null, + 'status' => 'planned', + 'priority' => $priority, + 'assigned_user_id' => null, + 'assigned_name' => null, + 'volunteer_names' => null, + 'volunteer_ids' => null, + 'animal_id' => $animalId, + 'animal_name' => $animalName, + 'contact_id' => null, + 'contact_name' => null, + 'reminder_minutes' => null, + 'repeat_rule' => 'none', + 'repeat_until' => null, + ]; + } + private static function saveVolunteers(int $itemId, mixed $input): void + { + $ids = is_array($input) ? array_values(array_unique(array_filter(array_map('intval', $input)))) : []; + $db = DB::pdo(); + $db->prepare('DELETE FROM agenda_volunteers WHERE agenda_item_id=?')->execute([$itemId]); + if (!$ids) { + return; + } + $check = $db->prepare( + "SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.id=? AND dc.deleted_at IS NULL", + ); + $insert = $db->prepare('INSERT INTO agenda_volunteers(agenda_item_id,contact_id) VALUES(?,?)'); + foreach ($ids as $id) { + $check->execute([$id]); + if ($check->fetchColumn()) { + $insert->execute([$itemId, $id]); + } + } + } + private static function date(string $v): bool + { + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $v); + return $d && $d->format('Y-m-d') === $v; + } +} diff --git a/app/Services/AppSettings.php b/app/Services/AppSettings.php new file mode 100644 index 0000000..33defc9 --- /dev/null +++ b/app/Services/AppSettings.php @@ -0,0 +1,95 @@ + 'Association', + 'association_address' => '', + 'association_postal_code' => '', + 'association_city' => '', + 'association_phone' => '', + 'association_email' => '', + 'association_siret' => '', + 'association_rna' => '', + 'association_latitude' => '', + 'association_longitude' => '', + 'adoption_fee_sterilized' => '220', + 'adoption_fee_unsterilized' => '200', + 'quarantine_days' => '15', + 'backup_retention' => '10', + 'app_language' => 'fr', + 'public_site_enabled' => '0', + 'public_site_theme' => 'warm', + 'public_site_routing' => 'integrated', + 'public_site_home_title' => 'Ils attendent une famille', + 'public_site_home_text' => 'Découvrez les animaux actuellement proposés à l’adoption par notre association.', + 'public_site_about_enabled' => '1', + 'public_site_about_title' => 'Notre association', + 'public_site_about_text' => 'Présentez ici votre association, son histoire et ses missions.', + 'public_site_donation_title' => 'Nous soutenir', + 'public_site_donation_text' => + 'Chaque don contribue directement aux soins, à l’alimentation et à la protection des animaux.', + 'public_site_donation_url' => '', + 'public_site_contact_text' => 'Une question sur une adoption ou sur l’association ? Contactez-nous.', + 'public_site_facebook_url' => '', + 'public_site_instagram_url' => '', + 'notification_email_enabled' => '0', + 'notification_email_frequency' => 'weekly', + 'notification_email_recipient' => '', + 'notification_last_sent_at' => '', + 'privacy_retention_years' => '5', + 'privacy_retention_adopter_years' => '5', + 'privacy_retention_foster_years' => '5', + 'privacy_retention_volunteer_years' => '5', + 'privacy_access_log_months' => '24', + 'backup_schedule_enabled' => '0', + 'backup_schedule_frequency' => 'daily', + 'backup_last_run_at' => '', + 'backup_last_verified_at' => '', + 'public_site_legal_text' => '', + 'public_site_privacy_text' => '', + ]; + private static ?array $cache = null; + public static function all(): array + { + if (self::$cache !== null) { + return self::$cache; + } + $values = self::DEFAULTS; + try { + foreach ( + DB::pdo()->query('SELECT setting_key,setting_value FROM app_settings')->fetchAll(PDO::FETCH_ASSOC) + as $row + ) { + if (array_key_exists($row['setting_key'], $values)) { + $values[$row['setting_key']] = (string) $row['setting_value']; + } + } + } catch (Throwable) { + } + return self::$cache = $values; + } + public static function get(string $key): string + { + return (string) (self::all()[$key] ?? ''); + } + public static function int(string $key, int $min = 0, int $max = PHP_INT_MAX): int + { + return max($min, min($max, (int) self::get($key))); + } + public static function save(array $values, ?int $userId): void + { + $allowed = array_keys(self::DEFAULTS); + $stmt = DB::pdo()->prepare( + "INSERT INTO app_settings(setting_key,setting_value,updated_by,updated_at) VALUES(:key,:value,:user,datetime('now')) ON CONFLICT(setting_key) DO UPDATE SET setting_value=excluded.setting_value,updated_by=excluded.updated_by,updated_at=datetime('now')", + ); + foreach ($allowed as $key) { + if (array_key_exists($key, $values)) { + $stmt->execute([':key' => $key, ':value' => (string) $values[$key], ':user' => $userId]); + } + } + self::$cache = null; + } +} diff --git a/app/Services/Asm3Analyzer.php b/app/Services/Asm3Analyzer.php new file mode 100644 index 0000000..d8adae8 --- /dev/null +++ b/app/Services/Asm3Analyzer.php @@ -0,0 +1,490 @@ + ['Animaux', 'animals', 'ready'], + 'owner' => ['Contacts et structures', 'directory_contacts', 'planned'], + 'adoption' => ['Mouvements, FA et adoptions', 'animal_placements / adoptions', 'planned'], + 'animalvaccination' => ['Vaccinations', 'vaccinations', 'planned'], + 'medical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'], + 'animalmedical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'], + 'media' => ['Médias', 'animal_photos / medical_photos', 'planned'], + 'animallitter' => ['Portées', 'litters', 'planned'], + 'animalcontrol' => ['Incidents et contrôles', 'non pris en charge', 'review'], + 'animaltest' => ['Tests médicaux', 'medical_notes', 'planned'], + ]; + + public static function analyze(string $path): array + { + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException(t('asm3.unreadable')); + } + if (self::isCopyDump($path)) { + return self::analyzeCopy($path); + } + $handle = fopen($path, 'rb'); + if (!$handle) { + throw new RuntimeException(t('asm3.open_failed')); + } + $counts = []; + $columns = []; + $animalStats = [ + 'active' => 0, + 'archived' => 0, + 'adoptable' => 0, + 'not_adoptable' => 0, + 'deceased' => 0, + 'microchip' => 0, + 'tattoo' => 0, + 'neutered' => 0, + 'missing_name' => 0, + 'missing_birth_date' => 0, + 'species' => [], + ]; + $statement = ''; + $quoted = false; + $bytes = 0; + try { + while (($chunk = fgets($handle)) !== false) { + $bytes += strlen($chunk); + $statement .= $chunk; + $length = strlen($statement); + $quoted = false; + for ($i = 0; $i < $length; $i++) { + if ($statement[$i] !== "'") { + continue; + } + if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") { + $i++; + continue; + } + $quoted = !$quoted; + } + if ($quoted || !preg_match('/;\s*$/s', $statement)) { + continue; + } + self::consume($statement, $counts, $columns, $animalStats); + $statement = ''; + } + if (trim($statement) !== '') { + self::consume($statement, $counts, $columns, $animalStats); + } + } finally { + fclose($handle); + } + arsort($counts); + $domains = []; + foreach (self::DOMAINS as $table => [$label, $target, $status]) { + $domains[] = [ + 'table' => $table, + 'label' => $label, + 'target' => $target, + 'status' => $status, + 'rows' => $counts[$table] ?? 0, + 'present' => isset($counts[$table]), + ]; + } + $warnings = []; + if (!isset($counts['animal'])) { + $warnings[] = t('asm3.animal_table_missing'); + } + if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) { + $warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]); + } + if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) { + $warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]); + } + $unsupported = []; + foreach ($counts as $table => $count) { + if (!isset(self::DOMAINS[$table]) && $count > 0) { + $unsupported[$table] = $count; + } + } + return [ + 'format' => 'ASM3 SQL INSERT', + 'source' => basename($path), + 'size_bytes' => filesize($path), + 'read_bytes' => $bytes, + 'generated_at' => date(DATE_ATOM), + 'tables' => $counts, + 'columns' => $columns, + 'domains' => $domains, + 'animals' => $animalStats, + 'warnings' => $warnings, + 'unmapped_tables' => $unsupported, + 'read_only' => true, + ]; + } + + public static function rows(string $path, array $wantedTables): array + { + if (self::isCopyDump($path)) { + return self::copyRows($path, $wantedTables); + } + $wanted = array_fill_keys(array_map('strtolower', $wantedTables), true); + $rows = []; + $columns = []; + $handle = fopen($path, 'rb'); + if (!$handle) { + throw new RuntimeException(t('asm3.open_failed')); + } + $statement = ''; + try { + while (($chunk = fgets($handle)) !== false) { + $statement .= $chunk; + $quoted = false; + $length = strlen($statement); + for ($i = 0; $i < $length; $i++) { + if ($statement[$i] !== "'") { + continue; + } + if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") { + $i++; + continue; + } + $quoted = !$quoted; + } + if ($quoted || !preg_match('/;\s*$/s', $statement)) { + continue; + } + if ( + preg_match( + '/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is', + $statement, + $m, + ) + ) { + $table = strtolower($m[1]); + if (isset($wanted[$table])) { + $cols = $columns[$table] ??= array_map( + static fn($v) => strtoupper(trim($v, " \t\r\n`\"")), + explode(',', $m[2]), + ); + $values = self::values($m[3]); + $row = []; + foreach ($cols as $i => $column) { + $row[$column] = $values[$i] ?? null; + } + $rows[$table][] = $row; + } + } + $statement = ''; + } + } finally { + fclose($handle); + } + return $rows; + } + + private static function consume(string $sql, array &$counts, array &$columns, array &$animalStats): void + { + if ( + !preg_match( + '/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is', + $sql, + $m, + ) + ) { + return; + } + $table = strtolower($m[1]); + $counts[$table] = ($counts[$table] ?? 0) + 1; + if (!isset($columns[$table])) { + $columns[$table] = array_map(static fn($v) => strtoupper(trim($v, " \t\r\n`\"")), explode(',', $m[2])); + } + if ($table !== 'animal') { + return; + } + $values = self::values($m[3]); + $row = []; + foreach ($columns[$table] as $i => $column) { + $row[$column] = $values[$i] ?? null; + } + $truthy = static fn($v): bool => (string) $v === '1'; + $blank = static fn($v): bool => $v === null || trim((string) $v) === ''; + $animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++; + $animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++; + if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) { + $animalStats['deceased']++; + } + if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) { + $animalStats['microchip']++; + } + if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) { + $animalStats['tattoo']++; + } + if ($truthy($row['NEUTERED'] ?? 0)) { + $animalStats['neutered']++; + } + if ($blank($row['ANIMALNAME'] ?? null)) { + $animalStats['missing_name']++; + } + if ($blank($row['DATEOFBIRTH'] ?? null)) { + $animalStats['missing_birth_date']++; + } + $species = (string) ($row['SPECIESID'] ?? 'unknown'); + $animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1; + } + + private static function isCopyDump(string $path): bool + { + $handle = @fopen($path, 'rb'); + if (!$handle) { + return false; + } + $sample = (string) fread($handle, 262144); + fclose($handle); + return preg_match('/^COPY\s+(?:public\.)?[a-zA-Z0-9_]+\s*\(/mi', $sample) === 1; + } + + private static function analyzeCopy(string $path): array + { + $counts = []; + $columns = []; + $animalStats = [ + 'active' => 0, + 'archived' => 0, + 'adoptable' => 0, + 'not_adoptable' => 0, + 'deceased' => 0, + 'microchip' => 0, + 'tattoo' => 0, + 'neutered' => 0, + 'missing_name' => 0, + 'missing_birth_date' => 0, + 'species' => [], + ]; + $handle = fopen($path, 'rb'); + if (!$handle) { + throw new RuntimeException(t('asm3.open_failed')); + } + $table = null; + $tableColumns = []; + $bytes = 0; + try { + while (($line = fgets($handle)) !== false) { + $bytes += strlen($line); + $line = rtrim($line, "\r\n"); + if ($table === null) { + if ( + preg_match( + '/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i', + $line, + $m, + ) + ) { + $table = strtolower($m[1]); + $tableColumns = array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2])); + $columns[$table] = $tableColumns; + $counts[$table] ??= 0; + } + continue; + } + if ($line === '\\.') { + $table = null; + $tableColumns = []; + continue; + } + $counts[$table]++; + if ($table === 'animal') { + self::accumulateAnimalStats(self::copyRow($line, $tableColumns), $animalStats); + } + } + } finally { + fclose($handle); + } + arsort($counts); + $domains = []; + foreach (self::DOMAINS as $name => [$label, $target, $status]) { + $domains[] = [ + 'table' => $name, + 'label' => $label, + 'target' => $target, + 'status' => $status, + 'rows' => $counts[$name] ?? 0, + 'present' => isset($counts[$name]), + ]; + } + $warnings = []; + if (!isset($counts['animal'])) { + $warnings[] = t('asm3.animal_table_missing'); + } + if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) { + $warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]); + } + if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) { + $warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]); + } + $unsupported = []; + foreach ($counts as $name => $count) { + if (!isset(self::DOMAINS[$name]) && $count > 0) { + $unsupported[$name] = $count; + } + } + return [ + 'format' => 'ASM3 PostgreSQL COPY', + 'source' => basename($path), + 'size_bytes' => filesize($path), + 'read_bytes' => $bytes, + 'generated_at' => date(DATE_ATOM), + 'tables' => $counts, + 'columns' => $columns, + 'domains' => $domains, + 'animals' => $animalStats, + 'warnings' => $warnings, + 'unmapped_tables' => $unsupported, + 'read_only' => true, + ]; + } + + private static function copyRows(string $path, array $wantedTables): array + { + $wanted = array_fill_keys(array_map('strtolower', $wantedTables), true); + $rows = []; + $handle = fopen($path, 'rb'); + if (!$handle) { + throw new RuntimeException(t('asm3.open_failed')); + } + $table = null; + $columns = []; + $capture = false; + try { + while (($line = fgets($handle)) !== false) { + $line = rtrim($line, "\r\n"); + if ($table === null) { + if ( + preg_match( + '/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i', + $line, + $m, + ) + ) { + $table = strtolower($m[1]); + $capture = isset($wanted[$table]); + $columns = $capture + ? array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2])) + : []; + } + continue; + } + if ($line === '\\.') { + $table = null; + $columns = []; + $capture = false; + continue; + } + if ($capture) { + $rows[$table][] = self::copyRow($line, $columns); + } + } + } finally { + fclose($handle); + } + return $rows; + } + + private static function copyRow(string $line, array $columns): array + { + $values = explode("\t", $line); + $row = []; + foreach ($columns as $i => $column) { + $row[$column] = self::copyScalar($values[$i] ?? '\\N'); + } + return $row; + } + + private static function copyScalar(string $value): ?string + { + if ($value === '\\N') { + return null; + } + return preg_replace_callback( + '/\\\\([0-7]{1,3}|.)/s', + static function (array $m): string { + $escape = $m[1]; + if (ctype_digit($escape)) { + return chr(octdec($escape)); + } + return match ($escape) { + 'b' => "\x08", + 'f' => "\x0c", + 'n' => "\n", + 'r' => "\r", + 't' => "\t", + 'v' => "\x0b", + '\\' => '\\', + default => $escape, + }; + }, + $value, + ) ?? $value; + } + + private static function accumulateAnimalStats(array $row, array &$animalStats): void + { + $truthy = static fn($v): bool => (string) $v === '1'; + $blank = static fn($v): bool => $v === null || trim((string) $v) === ''; + $animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++; + $animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++; + if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) { + $animalStats['deceased']++; + } + if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) { + $animalStats['microchip']++; + } + if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) { + $animalStats['tattoo']++; + } + if ($truthy($row['NEUTERED'] ?? 0)) { + $animalStats['neutered']++; + } + if ($blank($row['ANIMALNAME'] ?? null)) { + $animalStats['missing_name']++; + } + if ($blank($row['DATEOFBIRTH'] ?? null)) { + $animalStats['missing_birth_date']++; + } + $species = (string) ($row['SPECIESID'] ?? 'unknown'); + $animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1; + } + + private static function values(string $input): array + { + $values = []; + $value = ''; + $quoted = false; + $length = strlen($input); + for ($i = 0; $i < $length; $i++) { + $char = $input[$i]; + if ($char === "'") { + if ($quoted && $i + 1 < $length && $input[$i + 1] === "'") { + $value .= "'"; + $i++; + continue; + } + $quoted = !$quoted; + continue; + } + if ($char === ',' && !$quoted) { + $values[] = self::scalar($value); + $value = ''; + continue; + } + $value .= $char; + } + $values[] = self::scalar($value); + return $values; + } + + private static function scalar(string $value): mixed + { + $value = trim($value); + if (strcasecmp($value, 'null') === 0) { + return null; + } + return $value; + } +} diff --git a/app/Services/Asm3AnimalImporter.php b/app/Services/Asm3AnimalImporter.php new file mode 100644 index 0000000..e6f01d1 --- /dev/null +++ b/app/Services/Asm3AnimalImporter.php @@ -0,0 +1,138 @@ + $apply ? 'apply' : 'dry-run', + 'source' => basename($path), + 'source_sha256' => $hash, + 'planned' => $plan['summary'], + 'created' => 0, + 'linked_matches' => 0, + 'skipped_existing_import' => 0, + 'skipped_match' => 0, + 'skipped_review' => $plan['summary']['review'], + 'backup' => null, + 'read_only' => !$apply, + ]; + if (!$apply) { + $result['would_create'] = $plan['summary']['create']; + return $result; + } + if ($createBackup) { + $backup = BackupService::create('pre-asm3-import'); + $result['backup'] = $backup['name'] ?? null; + } + $ownsTransaction = !$db->inTransaction(); + if ($ownsTransaction) { + $db->beginTransaction(); + } + try { + $run = $db->prepare( + "INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)", + ); + $run->execute([':name' => basename($path), ':hash' => $hash, ':backup' => $result['backup']]); + $runId = (int) $db->lastInsertId(); + foreach ($plan['items'] as $item) { + if ($item['action'] === 'review') { + continue; + } + $check = $db->prepare( + "SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='animal' AND source_id=:source", + ); + $check->execute([':hash' => $hash, ':source' => $item['asm3_id']]); + if ($check->fetchColumn()) { + $result['skipped_existing_import']++; + continue; + } + if ($item['action'] === 'match') { + $target = (int) ($item['globinours_candidates'][0]['id'] ?? 0); + if ($target <= 0) { + $result['skipped_match']++; + continue; + } + $result['linked_matches']++; + } else { + $target = self::insert($db, $item['mapped'], $item['asm3_id']); + $result['created']++; + } + $db->prepare( + "INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'animal',:source,:target,:run)", + )->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]); + } + $db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([ + ':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ':id' => $runId, + ]); + if ($ownsTransaction) { + $db->commit(); + } + } catch (Throwable $e) { + if ($ownsTransaction && $db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + return $result; + } + + private static function insert(PDO $db, array $a, int $sourceId): int + { + $code = trim((string) $a['internal_code']); + if ($code === '') { + $code = 'ASM3-' . $sourceId; + } + $exists = $db->prepare('SELECT 1 FROM animals WHERE internal_code=:code'); + $exists->execute([':code' => $code]); + if ($exists->fetchColumn()) { + $code .= '-ASM3-' . $sourceId; + } + $stmt = $db->prepare( + "INSERT INTO animals(internal_code,name,species,sex,birth_date,birth_is_estimated,chip_id,status,sterilized,sterilization_status,sterilization_date,color,breed,notes,intake_date,archived_at,identification_type,identification_date,identification_registration_status,adoption_availability,adoption_available_from,adoption_unavailability_reason,compatibility_dogs,compatibility_cats,compatibility_children,house_trained,created_at,updated_at) VALUES(:code,:name,:species,:sex,:birth,:estimated,:chip,:status,:sterilized,:sterilization_status,:sterilization_date,:color,:breed,:notes,:intake,:archived,:identification_type,:identification_date,:registration,:availability,:available_from,:unavailable_reason,:dogs,:cats,:children,:trained,datetime('now'),datetime('now'))", + ); + $stmt->execute([ + ':code' => $code, + ':name' => $a['name'] ?: 'Animal ASM3 ' . $sourceId, + ':species' => $a['species'] ?: 'inconnu', + ':sex' => $a['sex'], + ':birth' => $a['birth_date'], + ':estimated' => $a['birth_is_estimated'], + ':chip' => $a['chip_id'] ?: null, + ':status' => $a['status'], + ':sterilized' => $a['sterilization_status'] === 'yes' ? 1 : 0, + ':sterilization_status' => $a['sterilization_status'], + ':sterilization_date' => $a['sterilization_date'], + ':color' => $a['color'], + ':breed' => $a['breed'] ?: null, + ':notes' => $a['notes'] ?: null, + ':intake' => $a['intake_date'], + ':archived' => $a['archived'] ? date('Y-m-d H:i:s') : null, + ':identification_type' => $a['identification_type'], + ':identification_date' => $a['identification_date'], + ':registration' => $a['identification_registration_status'], + ':availability' => $a['adoption_availability'], + ':available_from' => $a['adoption_available_from'], + ':unavailable_reason' => $a['adoption_unavailability_reason'] ?: null, + ':dogs' => $a['compatibility_dogs'], + ':cats' => $a['compatibility_cats'], + ':children' => $a['compatibility_children'], + ':trained' => $a['house_trained'], + ]); + $id = (int) $db->lastInsertId(); + if ($a['deceased_date']) { + $db->prepare( + "INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,place_type,body_disposition,notes) VALUES(:animal,:date,'unknown',NULL,'inconnu','pending',:notes)", + )->execute([':animal' => $id, ':date' => $a['deceased_date'], ':notes' => 'Importé depuis ASM3']); + } + return $id; + } +} diff --git a/app/Services/Asm3ClinicalImporter.php b/app/Services/Asm3ClinicalImporter.php new file mode 100644 index 0000000..2208348 --- /dev/null +++ b/app/Services/Asm3ClinicalImporter.php @@ -0,0 +1,169 @@ + $apply ? 'apply' : 'dry-run', + 'read_only' => !$apply, + 'vaccinations' => 0, + 'medical_notes' => 0, + 'weights' => 0, + 'unresolved' => 0, + 'already_imported' => 0, + 'backup' => null, + ]; + foreach ($src['animalvaccination'] ?? [] as $r) { + $result['vaccinations']++; + if (!isset($links[(int) $r['ANIMALID']])) { + $result['unresolved']++; + } + } + foreach ($src['log'] ?? [] as $r) { + if ((int) ($r['LINKTYPE'] ?? -1) === 0) { + if (!isset($links[(int) $r['LINKID']])) { + $result['unresolved']++; + } + $name = mb_strtolower($logTypes[(int) $r['LOGTYPEID']] ?? ''); + if ($name === 'poids') { + $result['weights']++; + } else { + $result['medical_notes']++; + } + } + } + if (!$apply) { + return $result; + } + if ($createBackup) { + $b = BackupService::create('pre-asm3-import'); + $result['backup'] = $b['name'] ?? null; + } + $ownsTransaction = !$db->inTransaction(); + if ($ownsTransaction) { + $db->beginTransaction(); + } + try { + $runId = self::runId($db, $path, $hash, $result['backup'], 'médical'); + foreach ($src['animalvaccination'] ?? [] as $r) { + $animal = $links[(int) $r['ANIMALID']] ?? 0; + if (!$animal) { + continue; + } + $source = (int) $r['ID']; + if (self::seen($db, $hash, 'vaccination', $source)) { + $result['already_imported']++; + continue; + } + $name = $types[(int) $r['VACCINATIONID']] ?? 'Vaccin ASM3 ' . (int) $r['VACCINATIONID']; + $db->prepare('INSERT OR IGNORE INTO ref_vaccines(name) VALUES(:name)')->execute([':name' => $name]); + $q = $db->prepare('SELECT id FROM ref_vaccines WHERE name=:name'); + $q->execute([':name' => $name]); + $vaccine = (int) $q->fetchColumn(); + $db->prepare( + 'INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,notes,manufacturer,batch_expires_on,administered_by_name) VALUES(:animal,:vaccine,:done,:due,:lot,:notes,:manufacturer,:expires,:by)', + )->execute([ + ':animal' => $animal, + ':vaccine' => $vaccine, + ':done' => + self::date($r['DATEOFVACCINATION'] ?? null) ?: + self::date($r['DATEREQUIRED'] ?? null) ?: + date('Y-m-d'), + ':due' => self::date($r['DATEREQUIRED'] ?? null), + ':lot' => trim((string) $r['BATCHNUMBER']) ?: null, + ':notes' => trim((string) $r['COMMENTS']) ?: null, + ':manufacturer' => trim((string) $r['MANUFACTURER']) ?: null, + ':expires' => self::date($r['BATCHEXPIRYDATE'] ?? null), + ':by' => trim((string) $r['GIVENBY']) ?: null, + ]); + self::link($db, $hash, 'vaccination', $source, (int) $db->lastInsertId(), $runId); + } + foreach ($src['log'] ?? [] as $r) { + if ((int) ($r['LINKTYPE'] ?? -1) !== 0) { + continue; + } + $animal = $links[(int) $r['LINKID']] ?? 0; + if (!$animal) { + continue; + } + $source = (int) $r['ID']; + $name = $logTypes[(int) $r['LOGTYPEID']] ?? 'Historique'; + $entity = mb_strtolower($name) === 'poids' ? 'measurement' : 'medical-log'; + if (self::seen($db, $hash, $entity, $source)) { + continue; + } + $text = trim((string) $r['COMMENTS']); + $date = self::date($r['DATE'] ?? null) ?: date('Y-m-d'); + if ($entity === 'measurement' && preg_match('/([0-9]+(?:[.,][0-9]+)?)/', $text, $m)) { + $value = (float) str_replace(',', '.', $m[1]); + $db->prepare( + "INSERT INTO measurements(animal_id,measured_at,type,value,unit,notes) VALUES(:animal,:date,'weight',:value,'kg',:notes)", + )->execute([':animal' => $animal, ':date' => $date, ':value' => $value, ':notes' => $text]); + } else { + $db->prepare( + "INSERT INTO medical_notes(animal_id,noted_at,kind,reason,plan) VALUES(:animal,:date,'autre',:reason,:plan)", + )->execute([':animal' => $animal, ':date' => $date, ':reason' => $name, ':plan' => $text ?: null]); + } + self::link($db, $hash, $entity, $source, (int) $db->lastInsertId(), $runId); + } + if ($ownsTransaction) { + $db->commit(); + } + } catch (Throwable $e) { + if ($ownsTransaction && $db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + return $result; + } + private static function animalLinks(PDO $db, string $hash): array + { + $s = $db->prepare( + "SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'", + ); + $s->execute([':h' => $hash]); + $o = []; + foreach ($s as $r) { + $o[(int) $r['source_id']] = (int) $r['target_id']; + } + return $o; + } + private static function runId(PDO $db, string $p, string $h, ?string $b, string $label): int + { + $db->prepare( + "INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)", + )->execute([':n' => basename($p) . ' · ' . $label, ':h' => $h, ':b' => $b]); + return (int) $db->lastInsertId(); + } + private static function seen(PDO $db, string $h, string $e, int $s): bool + { + $q = $db->prepare('SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type=:e AND source_id=:s'); + $q->execute([':h' => $h, ':e' => $e, ':s' => $s]); + return (bool) $q->fetchColumn(); + } + private static function link(PDO $db, string $h, string $e, int $s, int $t, int $r): void + { + $db->prepare( + 'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,:e,:s,:t,:r)', + )->execute([':h' => $h, ':e' => $e, ':s' => $s, ':t' => $t, ':r' => $r]); + } + private static function date(mixed $v): ?string + { + return $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null; + } +} diff --git a/app/Services/Asm3ContactImporter.php b/app/Services/Asm3ContactImporter.php new file mode 100644 index 0000000..93e7b6b --- /dev/null +++ b/app/Services/Asm3ContactImporter.php @@ -0,0 +1,103 @@ + $apply ? 'apply' : 'dry-run', + 'entity' => 'contacts', + 'planned' => $plan['summary'], + 'created' => 0, + 'linked_matches' => 0, + 'skipped_match' => 0, + 'skipped_review' => $plan['summary']['review'], + 'backup' => null, + 'read_only' => !$apply, + 'would_create' => $plan['summary']['create'], + ]; + if (!$apply) { + return $result; + } + if ($createBackup) { + $backup = BackupService::create('pre-asm3-import'); + $result['backup'] = $backup['name'] ?? null; + } + $ownsTransaction = !$db->inTransaction(); + if ($ownsTransaction) { + $db->beginTransaction(); + } + try { + $db->prepare( + "INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)", + )->execute([':name' => basename($path) . ' · contacts', ':hash' => $hash, ':backup' => $result['backup']]); + $runId = (int) $db->lastInsertId(); + foreach ($plan['items'] as $item) { + if ($item['action'] === 'review') { + continue; + } + $check = $db->prepare( + "SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='contact' AND source_id=:source", + ); + $check->execute([':hash' => $hash, ':source' => $item['asm3_id']]); + if ($check->fetchColumn()) { + continue; + } + $c = $item['mapped']; + if ($item['action'] === 'match') { + $target = (int) ($item['globinours_candidates'][0]['id'] ?? 0); + if ($target <= 0) { + $result['skipped_match']++; + continue; + } + $result['linked_matches']++; + } else { + $db->prepare( + 'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,country,notes) VALUES(:kind,:name,:phone,:email,:address,:postal,:city,:country,:notes)', + )->execute([ + ':kind' => $c['kind'], + ':name' => $c['name'], + ':phone' => $c['phone'], + ':email' => $c['email'], + ':address' => $c['address'], + ':postal' => $c['postal_code'], + ':city' => $c['city'], + ':country' => $c['country'], + ':notes' => $c['notes'], + ]); + $target = (int) $db->lastInsertId(); + $result['created']++; + } + $role = $db->prepare( + 'INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:contact,:role)', + ); + foreach ($c['roles'] as $value) { + $role->execute([':contact' => $target, ':role' => $value]); + } + $db->prepare( + "INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'contact',:source,:target,:run)", + )->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]); + } + $db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([ + ':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ':id' => $runId, + ]); + if ($ownsTransaction) { + $db->commit(); + } + } catch (Throwable $e) { + if ($ownsTransaction && $db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + return $result; + } +} diff --git a/app/Services/Asm3ContactPlanner.php b/app/Services/Asm3ContactPlanner.php new file mode 100644 index 0000000..73b93cb --- /dev/null +++ b/app/Services/Asm3ContactPlanner.php @@ -0,0 +1,164 @@ + 0) { + $adopters[(int) $r['OWNERID']] = true; + } + } + foreach ($source['animal'] ?? [] as $r) { + foreach (['BROUGHTINBYOWNERID', 'ORIGINALOWNERID'] as $field) { + if ((int) ($r[$field] ?? 0) > 0) { + $depositors[(int) $r[$field]] = true; + } + } + foreach (['CURRENTVETID', 'OWNERSVETID'] as $field) { + if ((int) ($r[$field] ?? 0) > 0) { + $vets[(int) $r[$field]] = true; + } + } + } + foreach ($source['animalvaccination'] ?? [] as $r) { + if ((int) ($r['ADMINISTERINGVETID'] ?? 0) > 0) { + $vets[(int) $r['ADMINISTERINGVETID']] = true; + } + } + $existing = $db + ->query('SELECT id,kind,name,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL') + ->fetchAll(PDO::FETCH_ASSOC); + $items = []; + $summary = ['create' => 0, 'match' => 0, 'review' => 0]; + foreach ($source['owner'] ?? [] as $row) { + $mapped = self::map( + $row, + isset($adopters[(int) $row['ID']]), + isset($depositors[(int) $row['ID']]), + isset($vets[(int) $row['ID']]), + ); + $strong = []; + $weak = []; + $reasons = []; + foreach ($existing as $contact) { + $email = self::text($mapped['email']); + $phone = self::phone($mapped['phone']); + $sameEmail = $email !== '' && $email === self::text($contact['email']); + $samePhone = $phone !== '' && $phone === self::phone($contact['phone']); + $sameIdentity = + self::text($mapped['name']) === self::text($contact['name']) && + self::text($mapped['city']) === self::text($contact['city']); + if ($sameEmail || $samePhone || $sameIdentity) { + $strong[(int) $contact['id']] = $contact; + if ($sameEmail) { + $reasons[] = 'même adresse e-mail'; + } + if ($samePhone) { + $reasons[] = 'même téléphone'; + } + if ($sameIdentity) { + $reasons[] = 'même nom et ville'; + } + } elseif ( + self::text($mapped['name']) !== '' && + self::text($mapped['name']) === self::text($contact['name']) + ) { + $weak[(int) $contact['id']] = $contact; + } + } + if (count($strong) === 1) { + $action = 'match'; + $candidates = $strong; + } elseif (count($strong) > 1) { + $action = 'review'; + $candidates = $strong; + } elseif ($weak) { + $action = 'review'; + $candidates = $weak; + $reasons[] = 'même nom uniquement'; + } else { + $action = 'create'; + $candidates = []; + } + $summary[$action]++; + $items[] = [ + 'asm3_id' => (int) $row['ID'], + 'action' => $action, + 'match_reasons' => array_values(array_unique($reasons)), + 'globinours_candidates' => array_values( + array_map( + static fn($c) => ['id' => (int) $c['id'], 'name' => $c['name'], 'city' => $c['city']], + $candidates, + ), + ), + 'mapped' => $mapped, + ]; + } + return ['source' => basename($path), 'read_only' => true, 'summary' => $summary, 'items' => $items]; + } + private static function map(array $r, bool $adopter, bool $depositor, bool $vetReferenced): array + { + $yes = static fn($v) => (string) $v === '1'; + $name = trim( + (string) ($r['OWNERNAME'] ?? '' ?: + trim((string) ($r['OWNERFORENAMES'] ?? '') . ' ' . (string) ($r['OWNERSURNAME'] ?? ''))), + ); + $organization = + (bool) preg_match('/\b(cabinet|clinique|cr[eé]matorium|association|refuge|fourri[eè]re)\b/iu', $name) || + $yes($r['ISSHELTER'] ?? 0) || + $yes($r['ISSUPPLIER'] ?? 0); + $roles = []; + if ($adopter || $yes($r['ISADOPTER'] ?? 0)) { + $roles[] = 'adoptant'; + } + if ($yes($r['ISFOSTERER'] ?? 0)) { + $roles[] = 'fa'; + } + if ($yes($r['ISVOLUNTEER'] ?? 0)) { + $roles[] = 'benevole'; + } + if ($depositor) { + $roles[] = 'deposant'; + } + if ($vetReferenced || $yes($r['ISVET'] ?? 0)) { + $roles[] = $organization ? 'cabinet' : 'veterinaire'; + } + if (preg_match('/cr[eé]matorium/iu', $name)) { + $roles[] = 'crematorium'; + } + $phones = array_values( + array_filter([ + trim((string) ($r['MOBILETELEPHONE'] ?? '')), + trim((string) ($r['HOMETELEPHONE'] ?? '')), + trim((string) ($r['WORKTELEPHONE'] ?? '')), + ]), + ); + return [ + 'kind' => $organization ? 'organization' : 'person', + 'name' => $name ?: 'Contact ASM3 ' . (int) $r['ID'], + 'phone' => $phones[0] ?? null, + 'email' => trim((string) ($r['EMAILADDRESS'] ?? '')) ?: null, + 'address' => trim((string) ($r['OWNERADDRESS'] ?? '')) ?: null, + 'postal_code' => trim((string) ($r['OWNERPOSTCODE'] ?? '')) ?: null, + 'city' => trim((string) ($r['OWNERTOWN'] ?? '')) ?: null, + 'country' => trim((string) ($r['OWNERCOUNTRY'] ?? '')) ?: 'France', + 'notes' => trim((string) ($r['COMMENTS'] ?? '')) ?: null, + 'roles' => array_values(array_unique($roles)), + ]; + } + private static function text(mixed $v): string + { + return mb_strtolower(trim((string) $v)); + } + private static function phone(mixed $v): string + { + return (string) preg_replace('/\D+/', '', (string) $v); + } +} diff --git a/app/Services/Asm3IntakeRecovery.php b/app/Services/Asm3IntakeRecovery.php new file mode 100644 index 0000000..4e9cbf8 --- /dev/null +++ b/app/Services/Asm3IntakeRecovery.php @@ -0,0 +1,92 @@ + ['betton', 'beton'], + 'Brignogan-Plages' => ['brignogan'], + 'Fougères' => ['fougère', 'fougeres'], + 'Gouesnou' => ['gouesnou'], + 'Goulven' => ['goulven'], + 'Guissény' => ['guissény', 'guisseny'], + 'Kernouës' => ['kernouës', 'kernoues'], + 'Kerlouan' => ['kerlouan'], + 'Landerneau' => ['landerneau'], + 'Lanhouarneau' => ['lanhouarneau'], + 'Landivisiau' => ['landivisiau'], + 'Le Drennec' => ['le drennec'], + 'Le Folgoët' => ['le folgoët', 'le folgoet', 'du folgoët', 'du folgoet'], + 'Lesneven' => ['lesneven', 'lesnevenet'], + 'Perros-Guirec' => ['perros guirec', 'perros guierrec'], + 'Ploudaniel' => ['ploudaniel'], + 'Plouescat' => ['plouescat'], + 'Plouguerneau' => ['plouguerneau'], + 'Plouider' => ['plouider'], + 'Plounéour-Brignogan-Plages' => [ + 'plounéour trez', + 'plouneour trez', + 'plounéour brignogan plage', + 'plouneour brignogan plage', + ], + 'Plounévez-Lochrist' => ['plounevez lochrist', 'plounévez lochrist'], + 'Plouzané' => ['plouzané', 'plouzane'], + 'Saint-Frégant' => ['saint frégant', 'saint fregant'], + 'Saint-Méen' => ['saint méen', 'saint meen'], + 'Saint-Pol-de-Léon' => ['saint pol de leon', 'saint-pol-de-léon'], + ]; + + public static function reason(array $row): string + { + return trim((string) ($row['REASONFORENTRY'] ?? '')); + } + + public static function municipality(string $reason): ?string + { + $text = self::plain($reason); + if ($text === '') { + return null; + } + $found = []; + foreach (self::CITIES as $city => $aliases) { + foreach ($aliases as $alias) { + if (str_contains($text, self::plain($alias))) { + $found[$city] = true; + break; + } + } + } + if (count($found) !== 1) { + return null; + } + $city = (string) array_key_first($found); + $words = + 'trouv|errant|recueill|recup|ramass|vient|arriv|fourriere|mairie|police municipale|jardin|terrain|ferme|hangar|grange|ne dans|abandonn|signal'; + if (preg_match('/^\s*' . self::cityPattern($city) . '\b/u', $text)) { + return $city; + } + if (preg_match('/(?:' . $words . ').{0,100}' . self::cityPattern($city) . '/u', $text)) { + return $city; + } + if (preg_match('/' . self::cityPattern($city) . '.{0,55}(?:' . $words . ')/u', $text)) { + return $city; + } + return null; + } + + private static function cityPattern(string $city): string + { + $parts = []; + foreach (self::CITIES[$city] as $alias) { + $parts[] = preg_quote(self::plain($alias), '/'); + } + return '(?:' . implode('|', $parts) . ')'; + } + private static function plain(string $value): string + { + $value = mb_strtolower(trim($value)); + $v = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + return preg_replace('/\s+/u', ' ', is_string($v) ? $v : $value) ?? $value; + } +} diff --git a/app/Services/Asm3LitterImporter.php b/app/Services/Asm3LitterImporter.php new file mode 100644 index 0000000..bbd8e82 --- /dev/null +++ b/app/Services/Asm3LitterImporter.php @@ -0,0 +1,102 @@ +prepare( + "SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'", + ); + $q->execute([':h' => $hash]); + foreach ($q as $r) { + $links[(int) $r['source_id']] = (int) $r['target_id']; + } + $animals = []; + foreach ($src['animal'] ?? [] as $a) { + $animals[(string) $a['ACCEPTANCENUMBER']][] = (int) $a['ID']; + } + $result = [ + 'mode' => $apply ? 'apply' : 'dry-run', + 'read_only' => !$apply, + 'litters' => count($src['animallitter'] ?? []), + 'would_import' => 0, + 'unresolved' => 0, + 'created' => 0, + 'kittens_linked' => 0, + 'backup' => null, + ]; + foreach ($src['animallitter'] ?? [] as $l) { + $parent = (int) $l['PARENTANIMALID']; + $kids = array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent); + if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) { + $result['unresolved']++; + continue; + } + $result['would_import']++; + } + if (!$apply) { + return $result; + } + if ($createBackup) { + $b = BackupService::create('pre-asm3-import'); + $result['backup'] = $b['name'] ?? null; + } + $owns = !$db->inTransaction(); + if ($owns) { + $db->beginTransaction(); + } + try { + $db->prepare( + "INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)", + )->execute([':n' => basename($path) . ' · portées', ':h' => $hash, ':b' => $result['backup']]); + $run = (int) $db->lastInsertId(); + foreach ($src['animallitter'] ?? [] as $l) { + $source = (int) $l['ID']; + $seen = $db->prepare( + "SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type='litter' AND source_id=:s", + ); + $seen->execute([':h' => $hash, ':s' => $source]); + if ($seen->fetchColumn()) { + continue; + } + $parent = (int) $l['PARENTANIMALID']; + $kids = array_values( + array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent), + ); + if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) { + continue; + } + $db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(:parent,:date,:notes)')->execute([ + ':parent' => $links[$parent], + ':date' => substr((string) $l['DATE'], 0, 10), + ':notes' => trim((string) $l['COMMENTS']) ?: null, + ]); + $target = (int) $db->lastInsertId(); + $ins = $db->prepare( + 'INSERT OR IGNORE INTO litter_kittens(litter_id,animal_id,position) VALUES(:litter,:animal,:position)', + ); + foreach ($kids as $i => $kid) { + $ins->execute([':litter' => $target, ':animal' => $links[$kid], ':position' => $i + 1]); + $result['kittens_linked']++; + } + $db->prepare( + "INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,'litter',:s,:t,:r)", + )->execute([':h' => $hash, ':s' => $source, ':t' => $target, ':r' => $run]); + $result['created']++; + } + if ($owns) { + $db->commit(); + } + } catch (Throwable $e) { + if ($owns && $db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + return $result; + } +} diff --git a/app/Services/Asm3MigrationPlanner.php b/app/Services/Asm3MigrationPlanner.php new file mode 100644 index 0000000..90edbc2 --- /dev/null +++ b/app/Services/Asm3MigrationPlanner.php @@ -0,0 +1,199 @@ +query('SELECT id,name,internal_code,chip_id,birth_date FROM animals') + ->fetchAll(PDO::FETCH_ASSOC); + $byChip = []; + $byCode = []; + $byIdentity = []; + $byName = []; + foreach ($existing as $animal) { + $chip = self::identifier($animal['chip_id'] ?? ''); + if ($chip !== '') { + $byChip[$chip][] = $animal; + } + $code = self::text($animal['internal_code'] ?? ''); + if ($code !== '') { + $byCode[$code][] = $animal; + } + $identity = self::identity($animal['name'] ?? '', $animal['birth_date'] ?? ''); + if ($identity !== '') { + $byIdentity[$identity][] = $animal; + } + $name = self::text($animal['name'] ?? ''); + if ($name !== '') { + $byName[$name][] = $animal; + } + } + $items = []; + $summary = ['create' => 0, 'match' => 0, 'review' => 0]; + foreach ($source['animal'] ?? [] as $row) { + $mapped = self::mapAnimal($row, $species, $colors, $sexes); + $candidates = []; + $reasons = []; + $chip = self::identifier($mapped['chip_id']); + if ($chip !== '' && !empty($byChip[$chip])) { + foreach ($byChip[$chip] as $a) { + $candidates[(int) $a['id']] = $a; + } + $reasons[] = 'même identification'; + } + // Les codes refuge peuvent être réutilisés après une remise à zéro d’ASM3. + // Un code seul n’est donc jamais une preuve suffisante entre deux sauvegardes historiques. + $code = self::text($mapped['internal_code']); + $identity = self::identity($mapped['name'], $mapped['birth_date']); + if ($identity !== '' && !empty($byIdentity[$identity])) { + foreach ($byIdentity[$identity] as $a) { + $candidates[(int) $a['id']] = $a; + } + $reasons[] = 'même nom et naissance'; + } + $strongMatch = count($candidates) > 0; + if (!$strongMatch) { + $name = self::text($mapped['name']); + if ($name !== '' && !empty($byName[$name])) { + $weakByName = $byName[$name]; + $knownDifferentBirths = $mapped['birth_date'] !== '' && $mapped['birth_date'] !== null; + foreach ($weakByName as $a) { + $knownDifferentBirths = + $knownDifferentBirths && + !empty($a['birth_date']) && + $a['birth_date'] !== $mapped['birth_date']; + } + if (!$knownDifferentBirths) { + foreach ($weakByName as $a) { + $candidates[(int) $a['id']] = $a; + } + $reasons[] = 'même nom uniquement'; + } + } + } + // Un code isolé et réutilisé ne doit ni fusionner, ni bloquer la création. + // Asm3AnimalImporter suffixera le code technique si nécessaire. + if (!$strongMatch && count($candidates) === 0) { + $action = 'create'; + } elseif ($strongMatch && count($candidates) === 1) { + $action = 'match'; + } else { + $action = 'review'; + } + $summary[$action]++; + $items[] = [ + 'asm3_id' => (int) ($row['ID'] ?? 0), + 'action' => $action, + 'match_reasons' => array_values(array_unique($reasons)), + 'globinours_candidates' => array_values( + array_map( + static fn($a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'code' => $a['internal_code']], + $candidates, + ), + ), + 'mapped' => $mapped, + ]; + } + return [ + 'source' => basename($path), + 'generated_at' => date(DATE_ATOM), + 'read_only' => true, + 'summary' => $summary, + 'items' => $items, + 'lookups' => ['species' => $species, 'colors' => $colors, 'sexes' => $sexes], + ]; + } + + private static function mapAnimal(array $r, array $species, array $colors, array $sexes): array + { + $date = static fn($v) => $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null; + $yes = static fn($v) => (string) $v === '1'; + $tri = static fn($v) => match ((string) $v) { + '0' => 'no', + '1' => 'yes', + default => 'unknown', + }; + $chip = trim((string) ($r['IDENTICHIPNUMBER'] ?? '')); + $tattoo = trim((string) ($r['TATTOONUMBER'] ?? '')); + $identifier = $chip !== '' ? $chip : $tattoo; + $deceased = $date($r['DECEASEDDATE'] ?? null) !== null || $yes($r['PUTTOSLEEP'] ?? 0); + $availability = $yes($r['ISNOTAVAILABLEFORADOPTION'] ?? 0) + ? 'not_available' + : ($yes($r['ADOPTABLE'] ?? 0) + ? 'available' + : 'unknown'); + return [ + 'name' => trim((string) ($r['ANIMALNAME'] ?? '')), + 'internal_code' => trim((string) ($r['SHELTERCODE'] ?? '' ?: $r['SHORTCODE'] ?? '')), + 'species' => self::species($species[(string) ($r['SPECIESID'] ?? '')] ?? ''), + 'sex' => match (mb_strtolower((string) ($sexes[(string) ($r['SEX'] ?? '')] ?? ''))) { + 'mâle' => 'M', + 'femelle' => 'F', + default => 'U', + }, + 'birth_date' => $date($r['DATEOFBIRTH'] ?? null), + 'birth_is_estimated' => $yes($r['ESTIMATEDDOB'] ?? 0) ? 1 : 0, + 'breed' => trim((string) ($r['BREEDNAME'] ?? '')), + 'color' => $colors[(string) ($r['BASECOLOURID'] ?? '')] ?? null, + 'notes' => trim((string) ($r['ANIMALCOMMENTS'] ?? '')), + 'intake_date' => $date($r['MOSTRECENTENTRYDATE'] ?? null ?: $r['DATEBROUGHTIN'] ?? null), + 'status' => $deceased ? 'decede' : ($yes($r['ISQUARANTINE'] ?? 0) ? 'quarantaine' : 'refuge'), + 'archived' => $yes($r['ARCHIVED'] ?? 0), + 'deceased_date' => $date($r['DECEASEDDATE'] ?? null), + 'identification_type' => $chip !== '' ? 'microchip' : ($tattoo !== '' ? 'tattoo' : 'unknown'), + 'chip_id' => $identifier, + 'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null), + 'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) { + '1' => 'registered', + '0' => 'pending', + default => 'unknown', + }, + 'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown', + 'sterilization_date' => $date($r['NEUTEREDDATE'] ?? null), + 'adoption_availability' => $availability, + 'adoption_available_from' => $date($r['HOLDUNTILDATE'] ?? null), + 'adoption_unavailability_reason' => trim((string) ($r['REASONNO'] ?? '')), + 'compatibility_dogs' => $tri($r['ISGOODWITHDOGS'] ?? null), + 'compatibility_cats' => $tri($r['ISGOODWITHCATS'] ?? null), + 'compatibility_children' => $tri($r['ISGOODWITHCHILDREN'] ?? null), + 'house_trained' => $tri($r['ISHOUSETRAINED'] ?? null), + ]; + } + private static function lookup(array $rows, string $key, string $value): array + { + $out = []; + foreach ($rows as $row) { + $out[(string) $row[$key]] = (string) $row[$value]; + } + return $out; + } + private static function species(string $value): string + { + return match (mb_strtolower($value)) { + 'chat' => 'chat', + 'chien' => 'chien', + default => mb_strtolower($value) ?: 'inconnu', + }; + } + private static function identifier(mixed $value): string + { + return strtoupper((string) preg_replace('/[^a-zA-Z0-9]/', '', (string) $value)); + } + private static function text(mixed $value): string + { + return mb_strtolower(trim((string) $value)); + } + private static function identity(mixed $name, mixed $birth): string + { + $name = self::text($name); + $birth = trim((string) $birth); + return $name !== '' && $birth !== '' ? $name . '|' . $birth : ''; + } +} diff --git a/app/Services/Asm3MovementImporter.php b/app/Services/Asm3MovementImporter.php new file mode 100644 index 0000000..7e43952 --- /dev/null +++ b/app/Services/Asm3MovementImporter.php @@ -0,0 +1,271 @@ + count($events), + 'adoptions' => 0, + 'fosters' => 0, + 'returns' => 0, + 'reservations' => 0, + 'cancellations' => 0, + 'unresolved_animals' => 0, + 'unresolved_contacts' => 0, + 'unsupported_movements' => $unsupported, + 'would_import' => 0, + 'created' => 0, + 'already_imported' => 0, + ]; + foreach ($events as $event) { + $summary[ + $event['type'] === 'adoption' + ? 'adoptions' + : ($event['type'] === 'foster' + ? 'fosters' + : ($event['type'] === 'return' + ? 'returns' + : ($event['type'] === 'reservation' + ? 'reservations' + : 'cancellations'))) + ]++; + if (!isset($links['animal'][$event['animal_source_id']])) { + $summary['unresolved_animals']++; + continue; + } + if ($event['contact_source_id'] > 0 && !isset($links['contact'][$event['contact_source_id']])) { + $summary['unresolved_contacts']++; + } + $summary['would_import']++; + } + if (!$apply) { + return ['mode' => 'dry-run', 'read_only' => true, 'summary' => $summary]; + } + if ($createBackup) { + $backup = BackupService::create('pre-asm3-import'); + $backupName = $backup['name'] ?? null; + } else { + $backupName = null; + } + $ownsTransaction = !$db->inTransaction(); + if ($ownsTransaction) { + $db->beginTransaction(); + } + try { + $db->prepare( + "INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)", + )->execute([':name' => basename($path) . ' · mouvements', ':hash' => $hash, ':backup' => $backupName]); + $runId = (int) $db->lastInsertId(); + $affected = []; + foreach ($events as $event) { + $animalId = $links['animal'][$event['animal_source_id']] ?? 0; + if (!$animalId) { + continue; + } + $affected[$animalId] = true; + $entity = 'placement-' . $event['type']; + $exists = $db->prepare( + 'SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type=:entity AND source_id=:source', + ); + $exists->execute([':hash' => $hash, ':entity' => $entity, ':source' => $event['source_id']]); + if ($exists->fetchColumn()) { + $summary['already_imported']++; + continue; + } + $contactId = $links['contact'][$event['contact_source_id']] ?? null; + $adoptionId = null; + if ($event['type'] === 'adoption') { + $contact = self::contact($db, $contactId); + $db->prepare( + 'INSERT INTO adoptions(animal_id,adopter_name,adopter_phone,adopter_email,adopter_address,adopter_postal_code,adopter_city,adopter_country,adoption_date,notes,adopter_contact_id) VALUES(:animal,:name,:phone,:email,:address,:postal,:city,:country,:date,:notes,:contact)', + )->execute([ + ':animal' => $animalId, + ':name' => $contact['name'] ?? 'Adoptant ASM3 non retrouvé', + ':phone' => $contact['phone'] ?? null, + ':email' => $contact['email'] ?? null, + ':address' => $contact['address'] ?? null, + ':postal' => $contact['postal_code'] ?? null, + ':city' => $contact['city'] ?? null, + ':country' => $contact['country'] ?? 'France', + ':date' => $event['date'], + ':notes' => $event['notes'] ?: null, + ':contact' => $contactId, + ]); + $adoptionId = (int) $db->lastInsertId(); + } + $db->prepare( + 'INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id) VALUES(:animal,:type,:date,:contact,:reason,:notes,:adoption)', + )->execute([ + ':animal' => $animalId, + ':type' => $event['type'], + ':date' => $event['date'], + ':contact' => $contactId, + ':reason' => $event['reason'], + ':notes' => $event['notes'] ?: null, + ':adoption' => $adoptionId, + ]); + $placementId = (int) $db->lastInsertId(); + $db->prepare( + 'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,:entity,:source,:target,:run)', + )->execute([ + ':hash' => $hash, + ':entity' => $entity, + ':source' => $event['source_id'], + ':target' => $placementId, + ':run' => $runId, + ]); + $summary['created']++; + } + self::syncAnimalStates($db, array_keys($affected)); + $db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([ + ':summary' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ':id' => $runId, + ]); + if ($ownsTransaction) { + $db->commit(); + } + } catch (Throwable $e) { + if ($ownsTransaction && $db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + return ['mode' => 'apply', 'read_only' => false, 'backup' => $backupName, 'summary' => $summary]; + } + private static function event(array $row, int $source, string $type, string $date, string $reason): array + { + return [ + 'source_id' => $source, + 'type' => $type, + 'date' => $date, + 'animal_source_id' => (int) ($row['ANIMALID'] ?? 0), + 'contact_source_id' => (int) ($row['OWNERID'] ?? 0), + 'reason' => $reason, + 'notes' => trim((string) ($row['COMMENTS'] ?? '')), + ]; + } + private static function date(mixed $value): ?string + { + return $value && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $value, $m) ? $m[0] : null; + } + private static function links(PDO $db, string $hash): array + { + $out = ['animal' => [], 'contact' => []]; + $s = $db->prepare( + "SELECT entity_type,source_id,target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type IN ('animal','contact')", + ); + $s->execute([':hash' => $hash]); + foreach ($s as $r) { + $out[$r['entity_type']][(int) $r['source_id']] = (int) $r['target_id']; + } + return $out; + } + private static function contact(PDO $db, ?int $id): ?array + { + if (!$id) { + return null; + } + $s = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id'); + $s->execute([':id' => $id]); + return $s->fetch(PDO::FETCH_ASSOC) ?: null; + } + private static function syncAnimalStates(PDO $db, array $animalIds): void + { + $latest = $db->prepare( + 'SELECT ap.*,dc.name contact_name,dc.address,dc.postal_code,dc.city FROM animal_placements ap LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id WHERE ap.animal_id=:animal ORDER BY date(ap.event_date) DESC,ap.id DESC LIMIT 1', + ); + foreach ($animalIds as $animalId) { + $death = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=?'); + $death->execute([$animalId]); + if ($death->fetchColumn()) { + continue; + } + $latest->execute([':animal' => $animalId]); + $event = $latest->fetch(PDO::FETCH_ASSOC); + if (!$event) { + continue; + } + $address = implode( + ' ', + array_filter([$event['address'] ?? null, $event['postal_code'] ?? null, $event['city'] ?? null]), + ); + if ($event['event_type'] === 'adoption') { + $db->prepare( + "UPDATE animals SET status='adopte',is_archived=1,archived_at=:archived,current_address=:address,refuge_room=NULL,care_box_key=NULL,quarantine_until=NULL,updated_at=datetime('now') WHERE id=:id", + )->execute([ + ':archived' => $event['event_date'] . ' 12:00:00', + ':address' => + $address !== '' + ? $address + : 'Adopté par ' . ($event['contact_name'] ?: 'un adoptant non renseigné'), + ':id' => $animalId, + ]); + } elseif ($event['event_type'] === 'foster') { + $db->prepare( + "UPDATE animals SET status=CASE WHEN lower(COALESCE(reason,'')) LIKE '%permanente%' THEN 'fa_permanente' ELSE 'fa' END,is_archived=0,archived_at=NULL,current_address=:address,refuge_room=NULL,care_box_key=NULL,updated_at=datetime('now') WHERE id=:id", + )->execute([ + ':address' => + $address !== '' ? $address : 'Chez ' . ($event['contact_name'] ?: 'une famille d’accueil'), + ':id' => $animalId, + ]); + } elseif (in_array($event['event_type'], ['return', 'cancellation'], true)) { + $db->prepare( + "UPDATE animals SET status='refuge',is_archived=0,archived_at=NULL,current_address=NULL,updated_at=datetime('now') WHERE id=?", + )->execute([$animalId]); + } elseif ($event['event_type'] === 'reservation') { + $db->prepare( + "UPDATE animals SET status='reserve',is_archived=0,archived_at=NULL,updated_at=datetime('now') WHERE id=?", + )->execute([$animalId]); + } + } + } +} diff --git a/app/Services/Asm3ResetService.php b/app/Services/Asm3ResetService.php new file mode 100644 index 0000000..6330c82 --- /dev/null +++ b/app/Services/Asm3ResetService.php @@ -0,0 +1,76 @@ + (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(), + 'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(), + ]; + $tables = [ + 'agenda_volunteers', + 'agenda_items', + 'prescription_treatments', + 'lab_results', + 'medical_prescriptions', + 'lab_reports', + 'treatment_administrations', + 'care_round_observations', + 'animal_photos', + 'medical_photos', + 'animal_health_conditions', + 'vaccinations', + 'treatments', + 'medical_notes', + 'measurements', + 'animal_placements', + 'adoptions', + 'animal_movements', + 'animal_deaths', + 'animal_location_history', + 'animal_history', + 'litter_kittens', + 'litters', + 'bonded_group_members', + 'bonded_groups', + 'care_rounds', + 'icad_cache', + 'asm3_import_links', + 'asm3_import_runs', + 'directory_contact_roles', + 'animals', + 'directory_contacts', + ]; + foreach ($tables as $table) { + $db->exec('DELETE FROM ' . $table); + } + return $before; + } + public static function clearMedia(): void + { + foreach ( + [dirname(__DIR__, 2) . '/public/media/animals', dirname(__DIR__, 2) . '/data/medical-documents'] + as $root + ) { + if (!is_dir($root)) { + continue; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $file) { + $path = $file->getPathname(); + if ($file->isLink()) { + unlink($path); + } elseif ($file->isDir()) { + rmdir($path); + } else { + unlink($path); + } + } + } + } +} diff --git a/app/Services/AssociationBrand.php b/app/Services/AssociationBrand.php new file mode 100644 index 0000000..2a094fd --- /dev/null +++ b/app/Services/AssociationBrand.php @@ -0,0 +1,69 @@ + 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp']; + + public static function path(): ?string + { + $files = glob(self::directory() . '/logo.*') ?: []; + foreach ($files as $file) { + if (is_file($file)) { + return $file; + } + } + return null; + } + + public static function store(array $upload): void + { + $error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE); + if ($error === UPLOAD_ERR_NO_FILE) { + return; + } + if ($error !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) { + throw new RuntimeException(t('service.logo.upload_failed')); + } + if ((int) ($upload['size'] ?? 0) > self::MAX_BYTES) { + throw new RuntimeException(t('service.logo.too_large')); + } + $tmp = (string) $upload['tmp_name']; + $mime = new finfo(FILEINFO_MIME_TYPE)->file($tmp); + $extension = self::MIME_EXTENSIONS[$mime] ?? null; + if (!$extension) { + throw new RuntimeException(t('service.logo.format')); + } + $dimensions = @getimagesize($tmp); + if (!$dimensions || $dimensions[0] * $dimensions[1] > 40_000_000) { + throw new RuntimeException(t('service.logo.dimensions')); + } + $dir = self::directory(); + $stored = ImageService::storeUploaded($tmp, $dir, 'association', 'logo'); + $target = $dir . '/logo.' . $stored['extension']; + foreach (glob($dir . '/logo.*') ?: [] as $old) { + if ($old !== $stored['path']) { + @unlink($old); + } + } + if (!rename($stored['path'], $target)) { + @unlink($stored['path']); + throw new RuntimeException(t('service.logo.finalize_failed')); + } + } + + public static function delete(): void + { + foreach (glob(self::directory() . '/logo.*') ?: [] as $file) { + if (is_file($file)) { + @unlink($file); + } + } + } + private static function directory(): string + { + return dirname(__DIR__, 2) . '/data/association'; + } +} diff --git a/app/Services/AuditService.php b/app/Services/AuditService.php new file mode 100644 index 0000000..08379a8 --- /dev/null +++ b/app/Services/AuditService.php @@ -0,0 +1,40 @@ + $value) { + if (in_array(strtolower((string) $key), ['password', 'password_confirm', 'csrf'], true)) { + continue; + } + if (is_scalar($value) || $value === null) { + $safe[$key] = $value; + } + } + DB::pdo() + ->prepare( + 'INSERT INTO audit_log(user_id,action,method,path,entity_type,entity_id,summary,metadata,ip_address) VALUES(:user,:action,:method,:path,:type,:entity,:summary,:meta,:ip)', + ) + ->execute([ + ':user' => Auth::id(), + ':action' => $action, + ':method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI', + ':path' => $path, + ':type' => $entityType, + ':entity' => $entityId, + ':summary' => $summary, + ':meta' => $safe ? json_encode($safe, JSON_UNESCAPED_UNICODE) : null, + ':ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); + } +} diff --git a/app/Services/Auth.php b/app/Services/Auth.php new file mode 100644 index 0000000..2812863 --- /dev/null +++ b/app/Services/Auth.php @@ -0,0 +1,201 @@ + true, + 'secure' => self::isSecureRequest(), + 'samesite' => 'Strict', + 'path' => '/', + ]); + session_start(); + $now = time(); + $idle = max(300, (int) (getenv('GLOBINOURS_SESSION_IDLE') ?: 1800)); + $absolute = max($idle, (int) (getenv('GLOBINOURS_SESSION_ABSOLUTE') ?: 43200)); + if ( + !empty($_SESSION['user_id']) && + ((isset($_SESSION['last_activity']) && $now - (int) $_SESSION['last_activity'] > $idle) || + (isset($_SESSION['authenticated_at']) && $now - (int) $_SESSION['authenticated_at'] > $absolute)) + ) { + self::logout(false); + session_regenerate_id(true); + } + if ( + empty($_SESSION['user_id']) && + class_exists(TrustedDeviceService::class) && + ($id = TrustedDeviceService::authenticate()) + ) { + session_regenerate_id(true); + $_SESSION['user_id'] = $id; + $_SESSION['authenticated_at'] = $now; + } + if (!empty($_SESSION['user_id'])) { + $_SESSION['last_activity'] = $now; + } + } + public static function countUsers(): int + { + return (int) DB::pdo()->query('SELECT COUNT(*) FROM users')->fetchColumn(); + } + public static function user(): ?array + { + if (self::$user !== null) { + return self::$user; + } + $id = (int) ($_SESSION['user_id'] ?? 0); + if (!$id) { + return null; + } + $s = DB::pdo()->prepare( + 'SELECT id,username,display_name,role,active,last_login_at,animal_list_view,agenda_view FROM users WHERE id=:id AND active=1', + ); + $s->execute([':id' => $id]); + $u = $s->fetch(PDO::FETCH_ASSOC); + if (!$u) { + self::logout(false); + return null; + } + return self::$user = $u; + } + public static function id(): ?int + { + return ($u = self::user()) ? (int) $u['id'] : null; + } + public static function login( + string $username, + string $password, + bool $remember = false, + string $deviceName = '', + ): bool { + $db = DB::pdo(); + $username = trim($username); + $ip = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64); + $db->exec("DELETE FROM login_attempts WHERE attempted_atprepare( + "SELECT COUNT(*) FROM login_attempts WHERE succeeded=0 AND ip_address=? AND attempted_at>=datetime('now','-15 minutes')", + ); + $rate->execute([$ip ?: null]); + if ((int) $rate->fetchColumn() >= 20) { + self::recordAttempt($username, $ip, false); + return false; + } + $s = $db->prepare('SELECT * FROM users WHERE lower(username)=lower(:u) LIMIT 1'); + $s->execute([':u' => $username]); + $u = $s->fetch(PDO::FETCH_ASSOC); + $valid = + $u && + (int) $u['active'] === 1 && + (empty($u['locked_until']) || strtotime($u['locked_until']) <= time()) && + password_verify($password, (string) $u['password_hash']); + if (!$u) { + password_verify($password, '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'); + } + if (!$valid) { + if ($u) { + $failed = (int) $u['failed_attempts'] + 1; + $lock = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null; + $db->prepare('UPDATE users SET failed_attempts=:f,locked_until=:l WHERE id=:id')->execute([ + ':f' => $failed, + ':l' => $lock, + ':id' => $u['id'], + ]); + } + self::recordAttempt($username, $ip, false); + return false; + } + session_regenerate_id(true); + $_SESSION['user_id'] = (int) $u['id']; + $_SESSION['authenticated_at'] = time(); + $_SESSION['last_activity'] = time(); + self::$user = null; + $db->prepare( + "UPDATE users SET failed_attempts=0,locked_until=NULL,last_login_at=datetime('now') WHERE id=:id", + )->execute([':id' => $u['id']]); + self::recordAttempt($username, $ip, true); + if ($remember && class_exists(TrustedDeviceService::class)) { + TrustedDeviceService::create((int) $u['id'], $deviceName); + } + return true; + } + public static function logout(bool $destroy = true): void + { + if ($destroy && class_exists(TrustedDeviceService::class)) { + TrustedDeviceService::revokeCurrent(); + } + self::$user = null; + unset($_SESSION['user_id']); + if ($destroy) { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $p['path'], + $p['domain'] ?? '', + (bool) $p['secure'], + (bool) $p['httponly'], + ); + } + session_destroy(); + } + } + public static function csrf(): string + { + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + } + return (string) $_SESSION['csrf']; + } + public static function validCsrf(?string $token): bool + { + return is_string($token) && hash_equals(self::csrf(), $token); + } + public static function is(string $role): bool + { + return (self::user()['role'] ?? '') === $role; + } + public static function canWrite(string $path): bool + { + $module = PermissionService::moduleForPath($path); + return $module === null || PermissionService::can($module, 'edit'); + } + public static function isSecureRequest(): bool + { + if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') { + return true; + } + if (str_starts_with(strtolower((string) getenv('APP_URL')), 'https://')) { + return true; + } + $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $trusted = array_filter(array_map('trim', explode(',', (string) getenv('GLOBINOURS_TRUSTED_PROXIES')))); + if (in_array($remote, ['127.0.0.1', '::1'], true) || in_array($remote, $trusted, true)) { + return strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')) === + 'https'; + } + return false; + } + private static function recordAttempt(string $username, string $ip, bool $success): void + { + try { + DB::pdo() + ->prepare('INSERT INTO login_attempts(username_hash,ip_address,succeeded) VALUES(?,?,?)') + ->execute([hash('sha256', mb_strtolower($username)), $ip ?: null, $success ? 1 : 0]); + } catch (Throwable) { + } + } +} diff --git a/app/Services/BackupService.php b/app/Services/BackupService.php new file mode 100644 index 0000000..9c888f9 --- /dev/null +++ b/app/Services/BackupService.php @@ -0,0 +1,467 @@ +format('Ymd-His'); + $suffix = $reason === 'pre-restore' ? '-avant-restauration' : ''; + $target = $backupDir . '/globinours-' . $stamp . $suffix . '.zip'; + // Certains volumes synchronisés acceptent les ZIP mais SQLite ne peut pas + // y créer de base avec VACUUM INTO. Le snapshot transitoire appartient au + // répertoire temporaire système ; seule l'archive finale va dans backups/. + $snapshot = tempnam(sys_get_temp_dir(), 'globinours-snapshot-'); + if ($snapshot === false) { + throw new RuntimeException(t('service.backup.prepare_failed')); + } + @unlink($snapshot); // VACUUM INTO exige que la destination n'existe pas. + + $pdo = DB::pdo(); + $pdo->exec('PRAGMA wal_checkpoint(FULL)'); + $pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot)); + + $zip = new ZipArchive(); + if ($zip->open($target, ZipArchive::CREATE | ZipArchive::EXCL) !== true) { + @unlink($snapshot); + throw new RuntimeException(t('service.backup.create_failed')); + } + $files = []; + try { + self::addFile($zip, $snapshot, 'database/refuge.sqlite', $files); + if (getenv('GLOBINOURS_BACKUP_DATA_ONLY') !== '1') { + self::addTree($zip, $root . '/public/media', 'public/media', $files); + self::addTree($zip, $root . '/data/association', 'data/association', $files); + self::addTree($zip, $root . '/data/grants', 'data/grants', $files); + self::addTree($zip, $root . '/data/medical-documents', 'data/medical-documents', $files); + self::addTree($zip, $root . '/data/private-media', 'data/private-media', $files); + } + $manifest = [ + 'application' => 'Globinours', + 'format_version' => self::FORMAT_VERSION, + 'created_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format( + DateTimeInterface::ATOM, + ), + 'reason' => $reason, + 'php_version' => PHP_VERSION, + 'files' => $files, + ]; + $zip->addFromString( + 'manifest.json', + json_encode( + $manifest, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ), + ); + } finally { + $zip->close(); + @unlink($snapshot); + } + @chmod($target, 0600); + self::prune($backupDir); + return self::describe($target); + } + + public static function list(): array + { + $dir = dirname(__DIR__, 2) . '/storage/backups'; + $files = glob($dir . '/globinours-*.zip') ?: []; + usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a)); + return array_map([self::class, 'describe'], $files); + } + + public static function path(string $name): ?string + { + if ( + $name !== basename($name) || + !preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-restauration)?\.zip$/', $name) + ) { + return null; + } + $path = dirname(__DIR__, 2) . '/storage/backups/' . $name; + return is_file($path) ? $path : null; + } + + public static function delete(string $name): void + { + $path = self::path($name); + if (!$path) { + throw new RuntimeException(t('service.backup.not_found')); + } + if (!unlink($path)) { + throw new RuntimeException(t('service.backup.delete_failed')); + } + } + + public static function verify(string $name): array + { + $path = self::path($name); + if (!$path) { + throw new RuntimeException(t('service.backup.not_found')); + } + $zip = new ZipArchive(); + if ($zip->open($path) !== true) { + throw new RuntimeException(t('service.backup.unreadable')); + } + try { + $raw = $zip->getFromName('manifest.json'); + if ($raw === false) { + throw new RuntimeException(t('service.backup.manifest_missing')); + } + $manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + if ( + ($manifest['application'] ?? '') !== 'Globinours' || + (int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION + ) { + throw new RuntimeException(t('service.backup.incompatible')); + } + foreach ($manifest['files'] ?? [] as $file => $meta) { + $stream = $zip->getStream((string) $file); + if (!$stream) { + throw new RuntimeException(t('service.backup.file_altered', ['name' => $file])); + } + $context = hash_init('sha256'); + hash_update_stream($context, $stream); + fclose($stream); + if (!hash_equals((string) ($meta['sha256'] ?? ''), hash_final($context))) { + throw new RuntimeException(t('service.backup.file_altered', ['name' => $file])); + } + } + $tmp = tempnam(sys_get_temp_dir(), 'globinours-verify-'); + if ($tmp === false) { + throw new RuntimeException(t('service.backup.prepare_failed')); + } + $dbBytes = $zip->getFromName('database/refuge.sqlite'); + if ($dbBytes === false) { + @unlink($tmp); + throw new RuntimeException(t('service.backup.database_missing')); + } + file_put_contents($tmp, $dbBytes); + self::validateDatabase($tmp); + @unlink($tmp); + return [ + 'valid' => true, + 'name' => $name, + 'created_at' => (string) ($manifest['created_at'] ?? ''), + 'files' => count($manifest['files'] ?? []), + ]; + } finally { + $zip->close(); + } + } + + public static function runScheduled(bool $force = false): array + { + if (AppSettings::get('backup_schedule_enabled') !== '1' && !$force) { + return ['created' => false, 'reason' => 'disabled']; + } + $frequency = AppSettings::get('backup_schedule_frequency'); + $last = AppSettings::get('backup_last_run_at'); + $minimum = $frequency === 'weekly' ? 6 * 86400 : 20 * 3600; + if (!$force && $last !== '' && time() - (strtotime($last) ?: 0) < $minimum) { + return ['created' => false, 'reason' => 'not_due']; + } + $backup = self::create('scheduled'); + $verification = self::verify($backup['name']); + AppSettings::save( + ['backup_last_run_at' => date('Y-m-d H:i:s'), 'backup_last_verified_at' => date('Y-m-d H:i:s')], + null, + ); + return ['created' => true, 'backup' => $backup, 'verification' => $verification]; + } + + public static function restore(string $uploadedPath): array + { + if (!is_file($uploadedPath)) { + throw new RuntimeException(t('service.backup.restore_missing')); + } + $root = dirname(__DIR__, 2); + $stage = $root . '/storage/restore-stage-' . bin2hex(random_bytes(6)); + if (!mkdir($stage, 0700, true)) { + throw new RuntimeException(t('service.backup.prepare_failed')); + } + $zip = new ZipArchive(); + if ($zip->open($uploadedPath) !== true) { + self::removeTree($stage); + throw new RuntimeException(t('service.backup.unreadable')); + } + try { + if ($zip->numFiles > 10000) { + throw new RuntimeException(t('service.backup.too_many_files')); + } + $total = 0; + $seen = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $name = (string) ($stat['name'] ?? ''); + if (isset($seen[$name])) { + throw new RuntimeException(t('service.backup.unsafe_path')); + } + $seen[$name] = true; + $total += (int) ($stat['size'] ?? 0); + if ($total > 2_000_000_000) { + throw new RuntimeException(t('service.backup.too_large')); + } + if ( + $name === '' || + str_contains($name, "\0") || + str_starts_with($name, '/') || + preg_match('#(^|/)\.\.(/|$)#', $name) + ) { + throw new RuntimeException(t('service.backup.unsafe_path')); + } + } + $manifestRaw = $zip->getFromName('manifest.json'); + if ($manifestRaw === false) { + throw new RuntimeException(t('service.backup.manifest_missing')); + } + $manifest = json_decode($manifestRaw, true, 512, JSON_THROW_ON_ERROR); + if ( + ($manifest['application'] ?? '') !== 'Globinours' || + (int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION + ) { + throw new RuntimeException(t('service.backup.incompatible')); + } + $manifestFiles = array_keys(is_array($manifest['files'] ?? null) ? $manifest['files'] : []); + $allowed = array_fill_keys(array_merge(['manifest.json'], $manifestFiles), true); + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $name = (string) ($stat['name'] ?? ''); + if (str_ends_with($name, '/')) { + continue; + } + if (!isset($allowed[$name])) { + throw new RuntimeException(t('service.backup.manifest_path')); + } + if (method_exists($zip, 'getExternalAttributesIndex')) { + $opsys = 0; + $attr = 0; + if ($zip->getExternalAttributesIndex($i, $opsys, $attr) && (($attr >> 16) & 0170000) === 0120000) { + throw new RuntimeException(t('service.backup.unsafe_path')); + } + } + } + $written = 0; + foreach ($manifestFiles as $name) { + $source = $zip->getStream($name); + if (!$source) { + throw new RuntimeException(t('service.backup.extract_failed')); + } + $target = $stage . '/' . $name; + $dir = dirname($target); + if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { + fclose($source); + throw new RuntimeException(t('service.backup.extract_failed')); + } + $dest = fopen($target, 'xb'); + if (!$dest) { + fclose($source); + throw new RuntimeException(t('service.backup.extract_failed')); + } + while (!feof($source)) { + $chunk = fread($source, 1048576); + if ($chunk === false) { + fclose($source); + fclose($dest); + throw new RuntimeException(t('service.backup.extract_failed')); + } + $written += strlen($chunk); + if ($written > 2_000_000_000) { + fclose($source); + fclose($dest); + throw new RuntimeException(t('service.backup.too_large')); + } + fwrite($dest, $chunk); + } + fclose($source); + fclose($dest); + @chmod($target, 0600); + } + } finally { + $zip->close(); + } + + try { + foreach ($manifest['files'] ?? [] as $name => $metadata) { + if ( + !is_string($name) || + !preg_match( + '#^(database/refuge\.sqlite|public/media/.+|data/grants/.+|data/medical-documents/.+|data/private-media/.+|data/documents/.+|data/association/logo\.(?:png|jpg|webp)|data/(?:Logo_Globinours_64\.png|Logo_Globinours_maxi\.png|favicon\.ico))$#', + $name, + ) + ) { + throw new RuntimeException(t('service.backup.manifest_path')); + } + $restored = $stage . '/' . $name; + $hash = (string) ($metadata['sha256'] ?? ''); + if (!is_file($restored) || $hash === '' || !hash_equals($hash, hash_file('sha256', $restored))) { + throw new RuntimeException(t('service.backup.file_altered', ['name' => $name])); + } + } + $database = $stage . '/database/refuge.sqlite'; + if (!is_file($database)) { + throw new RuntimeException(t('service.backup.database_missing')); + } + $expected = $manifest['files']['database/refuge.sqlite']['sha256'] ?? ''; + if ($expected === '' || !hash_equals($expected, hash_file('sha256', $database))) { + throw new RuntimeException(t('service.backup.database_hash')); + } + self::validateDatabase($database); + $emergency = self::create('pre-restore'); + self::replaceInstallation($stage, $root); + return ['emergency' => $emergency, 'created_at' => (string) ($manifest['created_at'] ?? '')]; + } finally { + self::removeTree($stage); + } + } + + private static function validateDatabase(string $path): void + { + $db = new SQLite3($path, SQLITE3_OPEN_READONLY); + try { + if ($db->querySingle('PRAGMA integrity_check') !== 'ok') { + throw new RuntimeException(t('service.backup.database_corrupt')); + } + foreach (['animals', 'users', 'animal_history'] as $table) { + $safe = SQLite3::escapeString($table); + if (!(int) $db->querySingle("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$safe'")) { + throw new RuntimeException(t('service.backup.table_missing', ['table' => $table])); + } + } + } finally { + $db->close(); + } + } + + private static function replaceInstallation(string $stage, string $root): void + { + $rollback = $root . '/storage/restore-rollback-' . bin2hex(random_bytes(5)); + mkdir($rollback, 0700, true); + $targets = ['public/media', 'data/association', 'data/grants', 'data/medical-documents', 'data/private-media']; + $moved = []; + try { + foreach ($targets as $relative) { + $live = $root . '/' . $relative; + $saved = $rollback . '/' . str_replace('/', '__', $relative); + $incoming = $stage . '/' . $relative; + if (is_dir($live) && !rename($live, $saved)) { + throw new RuntimeException(t('service.backup.secure_failed', ['path' => $relative])); + } + if (is_dir($saved)) { + $moved[$relative] = $saved; + } + if (is_dir($incoming) && !rename($incoming, $live)) { + throw new RuntimeException(t('service.backup.restore_path_failed', ['path' => $relative])); + } + if (!is_dir($live)) { + mkdir($live, 0775, true); + } + } + $pdo = DB::pdo(); + $pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)'); + unset($pdo); + DB::close(); + foreach (['-wal', '-shm'] as $suffix) { + @unlink($root . '/data/refuge.sqlite' . $suffix); + } + if (!rename($root . '/data/refuge.sqlite', $rollback . '/refuge.sqlite')) { + throw new RuntimeException(t('service.backup.secure_database_failed')); + } + if (!copy($stage . '/database/refuge.sqlite', $root . '/data/refuge.sqlite')) { + throw new RuntimeException(t('service.backup.restore_database_failed')); + } + @chmod($root . '/data/refuge.sqlite', 0600); + self::removeTree($rollback); + } catch (Throwable $e) { + DB::close(); + if (is_file($rollback . '/refuge.sqlite')) { + @unlink($root . '/data/refuge.sqlite'); + @rename($rollback . '/refuge.sqlite', $root . '/data/refuge.sqlite'); + } + foreach ($targets as $relative) { + $live = $root . '/' . $relative; + $saved = $rollback . '/' . str_replace('/', '__', $relative); + if (is_dir($saved)) { + self::removeTree($live); + @rename($saved, $live); + } + } + self::removeTree($rollback); + throw $e; + } + } + + private static function addTree(ZipArchive $zip, string $dir, string $prefix, array &$files): void + { + if (!is_dir($dir)) { + return; + } + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->isFile() && !$file->isLink()) { + self::addFile( + $zip, + $file->getPathname(), + $prefix . '/' . substr($file->getPathname(), strlen($dir) + 1), + $files, + ); + } + } + } + + private static function addFile(ZipArchive $zip, string $source, string $name, array &$files): void + { + if (!is_file($source)) { + return; + } + $name = str_replace('\\', '/', $name); + if (!$zip->addFile($source, $name)) { + throw new RuntimeException(t('service.backup.add_failed', ['name' => $name])); + } + $files[$name] = ['size' => filesize($source), 'sha256' => hash_file('sha256', $source)]; + } + + private static function describe(string $path): array + { + $modified = new DateTimeImmutable('@' . (filemtime($path) ?: time()))->setTimezone( + new DateTimeZone('Europe/Paris'), + ); + return ['name' => basename($path), 'size' => filesize($path) ?: 0, 'date' => $modified->format('d/m/Y H:i:s')]; + } + + private static function prune(string $dir): void + { + $files = glob($dir . '/globinours-*.zip') ?: []; + usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a)); + $keep = class_exists(AppSettings::class) ? AppSettings::int('backup_retention', 1, 50) : 10; + foreach (array_slice($files, $keep) as $file) { + @unlink($file); + } + } + + private static function removeTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($it as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($dir); + } +} diff --git a/app/Services/CalendarFeedService.php b/app/Services/CalendarFeedService.php new file mode 100644 index 0000000..4dea972 --- /dev/null +++ b/app/Services/CalendarFeedService.php @@ -0,0 +1,124 @@ +prepare( + "UPDATE users SET agenda_feed_token_hash=:hash,agenda_feed_created_at=datetime('now'),updated_at=datetime('now') WHERE id=:id AND active=1", + ) + ->execute([':hash' => hash('sha256', $token), ':id' => $userId]); + return $token; + } + public static function revoke(int $userId): void + { + DB::pdo() + ->prepare( + "UPDATE users SET agenda_feed_token_hash=NULL,agenda_feed_created_at=NULL,updated_at=datetime('now') WHERE id=?", + ) + ->execute([$userId]); + } + public static function userForToken(string $token): ?array + { + if (!preg_match('/^[a-f0-9]{64}$/', $token)) { + return null; + } + $s = DB::pdo()->prepare('SELECT id,role FROM users WHERE agenda_feed_token_hash=? AND active=1 LIMIT 1'); + $s->execute([hash('sha256', $token)]); + $user = $s->fetch(PDO::FETCH_ASSOC) ?: null; + if (!$user || !PermissionService::can('agenda', 'view', (string) $user['role'])) { + return null; + } + return $user; + } + public static function absoluteUrl(string $path): string + { + $configured = rtrim((string) getenv('APP_URL'), '/'); + if ($configured !== '' && preg_match('~^https?://~i', $configured)) { + return $configured . $path; + } + $proto = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''); + if (!in_array($proto, ['http', 'https'], true)) { + $proto = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http'; + } + $host = (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost')); + $host = trim(explode(',', $host)[0]); + if (!preg_match('/^[a-z0-9.:-]+$/i', $host)) { + $host = 'localhost'; + } + return $proto . '://' . $host . $path; + } + public static function render(array $items, string $name): string + { + $lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Globinours//Agenda refuge//FR', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:' . self::escape($name), + 'X-WR-TIMEZONE:Europe/Paris', + ]; + $stamp = gmdate('Ymd\THis\Z'); + foreach ($items as $item) { + $source = (string) ($item['source_type'] ?? 'agenda'); + $sourceId = (int) ($item['source_id'] ?? $item['id']); + $instance = (string) $item['instance_starts_at']; + $uid = $source . '-' . $sourceId . '-' . substr(hash('sha256', $instance), 0, 12) . '@globinours'; + $lines[] = 'BEGIN:VEVENT'; + $lines[] = 'UID:' . $uid; + $lines[] = 'DTSTAMP:' . $stamp; + if ((int) $item['all_day'] === 1) { + $start = new DateTimeImmutable(substr($instance, 0, 10)); + $lines[] = 'DTSTART;VALUE=DATE:' . $start->format('Ymd'); + $lines[] = 'DTEND;VALUE=DATE:' . $start->modify('+1 day')->format('Ymd'); + } else { + $start = new DateTimeImmutable($instance, new DateTimeZone('Europe/Paris')); + $end = !empty($item['instance_ends_at']) + ? new DateTimeImmutable($item['instance_ends_at'], new DateTimeZone('Europe/Paris')) + : $start->modify('+1 hour'); + $lines[] = 'DTSTART;TZID=Europe/Paris:' . $start->format('Ymd\THis'); + $lines[] = 'DTEND;TZID=Europe/Paris:' . $end->format('Ymd\THis'); + } + $lines[] = 'SUMMARY:' . self::escape((string) $item['title']); + $description = trim((string) ($item['description'] ?? '')); + if (!empty($item['animal_name'])) { + $description = trim($description . "\n" . t('animal.animal') . ' : ' . $item['animal_name']); + } + if ($description !== '') { + $lines[] = 'DESCRIPTION:' . self::escape($description); + } + if (!empty($item['location'])) { + $lines[] = 'LOCATION:' . self::escape((string) $item['location']); + } + if (!empty($item['animal_id'])) { + $lines[] = 'URL:' . self::escape(self::absoluteUrl('/animal?id=' . (int) $item['animal_id'])); + } + $lines[] = 'STATUS:' . (($item['status'] ?? 'planned') === 'completed' ? 'COMPLETED' : 'CONFIRMED'); + $lines[] = 'END:VEVENT'; + } + $lines[] = 'END:VCALENDAR'; + return implode("\r\n", array_map([self::class, 'fold'], $lines)) . "\r\n"; + } + private static function escape(string $value): string + { + return str_replace(['\\', "\r\n", "\n", "\r", ';', ','], ['\\\\', '\\n', '\\n', '\\n', '\\;', '\\,'], $value); + } + private static function fold(string $line): string + { + $out = ''; + while (strlen($line) > 73) { + $cut = 73; + while ($cut > 0 && (ord($line[$cut]) & 0xc0) === 0x80) { + $cut--; + } + $out .= substr($line, 0, $cut) . "\r\n "; + $line = substr($line, $cut); + } + return $out . $line; + } +} diff --git a/app/Services/DB.php b/app/Services/DB.php new file mode 100644 index 0000000..9995146 --- /dev/null +++ b/app/Services/DB.php @@ -0,0 +1,41 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + $pdo->exec('PRAGMA foreign_keys = ON;'); + $pdo->exec('PRAGMA journal_mode = WAL;'); + $pdo->exec('PRAGMA busy_timeout = 3000;'); + @chmod($dbPath, 0600); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + self::$pdo = $pdo; + return $pdo; + } + + public static function close(): void + { + self::$pdo = null; + } +} diff --git a/app/Services/DemoDataService.php b/app/Services/DemoDataService.php new file mode 100644 index 0000000..5ea91d7 --- /dev/null +++ b/app/Services/DemoDataService.php @@ -0,0 +1,536 @@ + (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(), + 'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(), + ]; + $sequence = $db->prepare('DELETE FROM sqlite_sequence WHERE name=?'); + $exists = $db->prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?"); + foreach (self::TABLES as $table) { + $exists->execute([$table]); + if (!$exists->fetchColumn()) { + continue; + } + $db->exec('DELETE FROM ' . $table); + $sequence->execute([$table]); + } + return $before; + } + + public static function seed(PDO $db, ?int $userId): array + { + $contacts = [ + 'fa' => [ + 'person', + 'Alice Martin', + '06 00 00 00 01', + 'alice.demo@example.org', + '12 rue des Lilas', + '29260', + 'Lesneven', + ], + 'adopter' => [ + 'person', + 'Marc Durand', + '06 00 00 00 02', + 'marc.demo@example.org', + '8 rue du Port', + '29200', + 'Brest', + ], + 'depositor' => [ + 'person', + 'Sophie Le Gall', + '06 00 00 00 03', + 'sophie.demo@example.org', + '4 place du Marché', + '29000', + 'Ville fictive', + ], + 'vet' => [ + 'person', + 'Dr Camille Morel', + '02 00 00 00 04', + 'veterinaire.demo@example.org', + '2 avenue des Chats', + '29260', + 'Lesneven', + ], + 'clinic' => [ + 'organization', + 'Clinique vétérinaire Démo', + '02 00 00 00 05', + 'clinique.demo@example.org', + '2 avenue des Chats', + '29260', + 'Lesneven', + ], + 'crematorium' => [ + 'organization', + 'Crématorium animalier Démo', + '02 00 00 00 06', + 'crematorium.demo@example.org', + '1 route du Souvenir', + '29400', + 'Landivisiau', + ], + ]; + $contactIds = []; + $insert = $db->prepare( + 'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,notes) VALUES(?,?,?,?,?,?,?,?)', + ); + $role = $db->prepare('INSERT INTO directory_contact_roles(contact_id,role) VALUES(?,?)'); + foreach ($contacts as $key => $c) { + $insert->execute([...$c, 'Donnée fictive de démonstration']); + $contactIds[$key] = (int) $db->lastInsertId(); + $roleName = match ($key) { + 'adopter' => 'adoptant', + 'vet' => 'veterinaire', + 'clinic' => 'cabinet', + 'crematorium' => 'crematorium', + 'depositor' => 'deposant', + default => 'fa', + }; + $role->execute([$contactIds[$key], $roleName]); + } + + $animals = [ + [ + 'DEMO-001', + 'Moka', + 'F', + '2019-05-14', + 'refuge', + 'silver', + 'Bicolore Noir / Blanc', + 'yes', + 'yes', + 'yes', + 'yes', + 'yes', + 'available', + ], + [ + 'DEMO-002', + 'Pixel', + 'M', + '2021-09-03', + 'refuge', + 'twix', + 'Roux', + 'yes', + 'yes', + 'yes', + 'yes', + 'no', + 'available', + ], + [ + 'DEMO-003', + 'Twix', + 'M', + '2019-04-15', + 'quarantaine', + null, + 'Roux', + 'yes', + 'yes', + 'yes', + 'yes', + 'yes', + 'not_available', + ], + [ + 'DEMO-004', + 'Nox', + 'M', + '2023-02-18', + 'soin', + null, + 'Noir', + 'no', + 'yes', + 'unknown', + 'yes', + 'no', + 'not_available', + ], + [ + 'DEMO-005', + 'Caramel', + 'F', + '2020-07-21', + 'fa', + null, + 'Écaille de tortue', + 'yes', + 'yes', + 'yes', + 'yes', + 'yes', + 'available', + ], + [ + 'DEMO-006', + 'Plume', + 'F', + '2022-04-08', + 'refuge', + 'silver', + 'Tricolore / Calico', + 'yes', + 'yes', + 'yes', + 'yes', + 'yes', + 'available', + ], + [ + 'DEMO-007', + 'Plume-Bébé-1', + 'F', + date('Y-m-d', strtotime('-4 months')), + 'refuge', + 'silver', + 'Bicolore Gris / Blanc', + 'unknown', + 'yes', + 'unknown', + 'unknown', + 'unknown', + 'not_available', + ], + [ + 'DEMO-008', + 'Plume-Bébé-2', + 'M', + date('Y-m-d', strtotime('-4 months')), + 'refuge', + 'silver', + 'Gris', + 'unknown', + 'yes', + 'unknown', + 'unknown', + 'unknown', + 'not_available', + ], + [ + 'DEMO-009', + 'Plume-Bébé-3', + 'U', + date('Y-m-d', strtotime('-4 months')), + 'refuge', + 'silver', + 'Tigré / Tabby', + 'unknown', + 'yes', + 'unknown', + 'unknown', + 'unknown', + 'not_available', + ], + [ + 'DEMO-010', + 'Lune', + 'F', + '2021-01-12', + 'refuge', + 'twix', + 'Blanc', + 'yes', + 'no', + 'yes', + 'yes', + 'yes', + 'available', + ], + [ + 'DEMO-011', + 'Soleil', + 'M', + '2021-01-12', + 'refuge', + 'twix', + 'Roux', + 'yes', + 'no', + 'yes', + 'yes', + 'yes', + 'available', + ], + [ + 'DEMO-012', + 'Étoile', + 'F', + '2012-06-01', + 'decede', + null, + 'Noir', + 'yes', + 'unknown', + 'unknown', + 'unknown', + 'unknown', + 'not_available', + ], + ]; + $animalIds = []; + $animal = $db->prepare( + "INSERT INTO animals(internal_code,name,species,sex,birth_date,status,refuge_room,care_box_key,color,sterilized,sterilization_status,compatibility_cats,compatibility_dogs,compatibility_children,house_trained,adoption_availability,intake_date,intake_type,intake_reason,rescue_location_name,current_address,chip_id,identification_type,identification_registration_status,archived_at,notes) VALUES(:code,:name,'chat',:sex,:birth,:status,:room,:box,:color,:sterilized,:sterilization,:cats,:dogs,:children,:clean,:availability,date('now','-'||:days||' days'),'found','stray_found','Commune fictive','5 rue de la Démonstration, 29260 Lesneven',:chip,:identification,:registration,:archived,'Donnée fictive de démonstration')", + ); + foreach ($animals as $i => $a) { + [ + $code, + $name, + $sex, + $birth, + $status, + $room, + $color, + $sterilization, + $cats, + $dogs, + $children, + $clean, + $availability, + ] = $a; + $sterilized = $sterilization === 'yes' ? 1 : 0; + $chip = $i < 6 ? '250269900' . str_pad((string) ($i + 1), 6, '0', STR_PAD_LEFT) : null; + $animal->execute([ + ':code' => $code, + ':name' => $name, + ':sex' => $sex, + ':birth' => $birth, + ':status' => $status, + ':room' => $room, + ':box' => $status === 'soin' ? 'care-top-1' : null, + ':color' => $color, + ':sterilized' => $sterilized, + ':sterilization' => $sterilization, + ':cats' => $cats, + ':dogs' => $dogs, + ':children' => $children, + ':clean' => $clean, + ':availability' => $availability, + ':days' => 90 + $i * 12, + ':chip' => $chip, + ':identification' => $chip ? 'microchip' : 'unknown', + ':registration' => $chip ? 'registered' : 'unknown', + ':archived' => $status === 'decede' ? date('Y-m-d H:i:s') : null, + ]); + $animalIds[$name] = (int) $db->lastInsertId(); + } + + $db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(?,?,?)')->execute([ + $animalIds['Plume'], + $animals[6][3], + 'Portée fictive de démonstration', + ]); + $litter = (int) $db->lastInsertId(); + $link = $db->prepare('INSERT INTO litter_kittens(litter_id,animal_id,position) VALUES(?,?,?)'); + foreach (['Plume-Bébé-1', 'Plume-Bébé-2', 'Plume-Bébé-3'] as $i => $name) { + $link->execute([$litter, $animalIds[$name], $i + 1]); + } + $db->prepare('INSERT INTO bonded_groups(name,notes) VALUES(?,?)')->execute([ + 'Duo Lune & Soleil', + 'Chats fictifs à adopter ensemble', + ]); + $group = (int) $db->lastInsertId(); + $member = $db->prepare('INSERT INTO bonded_group_members(group_id,animal_id,position) VALUES(?,?,?)'); + $member->execute([$group, $animalIds['Lune'], 1]); + $member->execute([$group, $animalIds['Soleil'], 2]); + + $twix = $animalIds['Twix']; + $db->prepare( + "UPDATE animals SET breed='Européen',birth_is_estimated=0,fiv=0,felv=0,quarantine_until=date('now','+10 days'),intake_circumstances='Trouvé seul, bilan sanitaire programmé à l’arrivée.',depositor_contact_id=?,rescue_location_name='Commune fictive',current_address='Quarantaine — box supérieur gauche',identification_date=date('now','-2 years'),notes='Fiche vitrine entièrement fictive : elle illustre un dossier animal complet.' WHERE id=?", + )->execute([$contactIds['depositor'], $twix]); + + $db->prepare( + "INSERT OR IGNORE INTO ref_medications(name,molecule,form,notes) VALUES('Doxybactin','doxycycline','comprimé','Référence de démonstration')", + )->execute(); + $med = (int) $db->query("SELECT id FROM ref_medications WHERE name='Doxybactin'")->fetchColumn(); + $db->prepare( + "INSERT INTO treatments(animal_id,medication_id,route,dose_text,start_date,end_date,ongoing,notes,created_by,give_morning,give_evening) VALUES(?,?,'PO','1/2 comprimé matin et soir',date('now','-2 days'),date('now','+5 days'),1,'Traitement fictif de démonstration',?,1,1)", + )->execute([$twix, $med, $userId]); + $db->prepare( + "INSERT INTO medical_notes(animal_id,noted_at,kind,reason,symptoms,exam,diagnosis,plan,weight_kg,temperature_c,created_by,vet_contact_id,clinic_contact_id) VALUES(?,datetime('now','-1 day'),'consult','Visite sanitaire d’entrée','Éternuements occasionnels','État général satisfaisant, auscultation sans anomalie','Coryza léger','Surveillance, traitement court et contrôle avant sortie de quarantaine',4.8,38.4,?,?,?)", + )->execute([$twix, $userId, $contactIds['vet'], $contactIds['clinic']]); + $weight = $db->prepare( + "INSERT INTO measurements(animal_id,type,value,unit,measured_at,notes,created_by) VALUES(?,'weight',?,'kg',datetime('now',?),'Mesure fictive',?)", + ); + foreach ([[4.6, '-30 days'], [4.7, '-14 days'], [4.8, '-1 day']] as $w) { + $weight->execute([$twix, $w[0], $w[1], $userId]); + } + $db->prepare( + "INSERT OR IGNORE INTO ref_vaccines(name,notes) VALUES('TCL','Vaccin de démonstration')", + )->execute(); + $vaccine = (int) $db->query("SELECT id FROM ref_vaccines WHERE name='TCL'")->fetchColumn(); + $db->prepare( + "INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,manufacturer,notes,created_by,vet_contact_id,clinic_contact_id) VALUES(?,?,date('now','-11 months'),date('now','+1 month'),'LOT-DEMO-1','Laboratoire Démo','Vaccination fictive à jour',?,?,?)", + )->execute([$twix, $vaccine, $userId, $contactIds['vet'], $contactIds['clinic']]); + $dewormer = (int) $db->query('SELECT id FROM ref_dewormers ORDER BY id LIMIT 1')->fetchColumn(); + $db->prepare( + "INSERT INTO dewormings(animal_id,dewormer_id,administered_on,dose_text,weight_kg,next_due_date,notes,created_by) VALUES(?,?,date('now','-10 days'),'1 pipette',4.7,date('now','+80 days'),'Administration fictive',?)", + )->execute([$twix, $dewormer, $userId]); + $db->prepare( + "INSERT INTO animal_health_conditions(animal_id,name,status,diagnosed_at,resolved_at,notes) VALUES(?,'Gingivite légère','resolved',date('now','-6 months'),date('now','-5 months'),'Antécédent fictif résolu')", + )->execute([$twix]); + $db->prepare( + "INSERT INTO lab_reports(animal_id,sampled_on,report_type,laboratory_name,veterinarian_contact_id,clinic_contact_id,notes,created_by) VALUES(?,date('now','-1 day'),'Bilan complet','Laboratoire Démo',?,?,'Prise de sang fictive — valeurs dans les intervalles usuels',?)", + )->execute([$twix, $contactIds['vet'], $contactIds['clinic'], $userId]); + $report = (int) $db->lastInsertId(); + $result = $db->prepare( + 'INSERT INTO lab_results(report_id,parameter_name,value,unit,reference_min,reference_max,notes,position) VALUES(?,?,?,?,?,?,?,?)', + ); + foreach ( + [ + ['Leucocytes', 8.4, '10⁹/L', 3.5, 20.7, 10], + ['Érythrocytes', 9.6, '10¹²/L', 7.7, 12.8, 20], + ['Hémoglobine', 13.8, 'g/dL', 10, 17, 30], + ['Hématocrite', 42.1, '%', 33.7, 55.4, 40], + ['Plaquettes', 312, '10⁹/L', 125, 618, 50], + ['Créatinine', 12, 'mg/L', 3, 21, 60], + ['Urée', 0.42, 'g/L', 0.214, 0.642, 70], + ] + as $r + ) { + $result->execute([$report, $r[0], $r[1], $r[2], $r[3], $r[4], 'Résultat fictif', $r[5]]); + } + $db->prepare( + "INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption) VALUES(?,'twix.webp','twix.jpg','image/webp',0,1,1,'Twix — photographie de démonstration')", + )->execute([$twix]); + $db->prepare( + "INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,location,status,priority,animal_id,contact_id,reminder_minutes,created_by) VALUES('veterinary','Contrôle de sortie de quarantaine','Rendez-vous fictif : contrôle clinique et validation de la sortie.',datetime('now','+7 days','09:00'),datetime('now','+7 days','09:30'),'Clinique vétérinaire Démo','planned','high',?,?,60,?)", + )->execute([$twix, $contactIds['clinic'], $userId]); + $db->prepare( + "INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(?,'foster',date('now','-30 days'),?,'Placement de démonstration','Donnée fictive',?)", + )->execute([$animalIds['Caramel'], $contactIds['fa'], $userId]); + $db->prepare( + "INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,occurred_in_care,place_type,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,created_by) VALUES(?,date('now','-60 days'),'old_age','Décès fictif',1,'veterinaire',1,?,?,'collective_cremation',?)", + )->execute([$animalIds['Étoile'], $contactIds['vet'], $contactIds['crematorium'], $userId]); + $history = $db->prepare( + "INSERT INTO animal_history(animal_id,action,field,new_value,user_id,created_at) VALUES(?,'created','demo','Donnée entièrement fictive',?,datetime('now'))", + ); + foreach ($animalIds as $id) { + $history->execute([$id, $userId]); + } + return [ + 'animals' => count($animalIds), + 'contacts' => count($contactIds), + 'litters' => 1, + 'bonded_groups' => 1, + 'treatments' => 1, + 'vaccinations' => 1, + 'lab_reports' => 1, + 'agenda_items' => 1, + 'photos' => 1, + ]; + } + + public static function clearFiles(): void + { + foreach ( + [ + dirname(__DIR__, 2) . '/public/media/animals', + dirname(__DIR__, 2) . '/data/grants', + dirname(__DIR__, 2) . '/data/medical-documents', + ] + as $root + ) { + if (!is_dir($root)) { + continue; + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($it as $file) { + $path = $file->getPathname(); + if ($file->isLink() || $file->isFile()) { + @unlink($path); + } elseif ($file->isDir()) { + @rmdir($path); + } + } + } + } + + public static function installFiles(PDO $db): void + { + $animalId = (int) $db->query("SELECT id FROM animals WHERE internal_code='DEMO-003' LIMIT 1")->fetchColumn(); + if ($animalId <= 0) { + return; + } + $source = dirname(__DIR__, 2) . '/resources/demo/twix.webp'; + $directory = dirname(__DIR__, 2) . '/public/media/animals/' . $animalId; + $target = $directory . '/twix.webp'; + if (!is_file($source)) { + throw new RuntimeException(t('service.image.invalid_upload')); + } + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new RuntimeException(t('service.image.directory_failed')); + } + if (!copy($source, $target)) { + throw new RuntimeException(t('service.image.save_failed')); + } + @chmod($target, 0644); + $db->prepare("UPDATE animal_photos SET size_bytes=? WHERE animal_id=? AND filename='twix.webp'")->execute([ + filesize($target), + $animalId, + ]); + } +} diff --git a/app/Services/DocumentsService.php b/app/Services/DocumentsService.php new file mode 100644 index 0000000..8f4cf98 --- /dev/null +++ b/app/Services/DocumentsService.php @@ -0,0 +1,537 @@ + self::ADOPTION_TEMPLATE, + 'abandon' => __DIR__ . '/../../resources/documents/ABANDON V1.0.odt', + 'benevole' => __DIR__ . '/../../resources/documents/CONTRAT BENEVOLE V2.0.odt', + 'fa' => __DIR__ . '/../../resources/documents/PROPOSITION FA V1.1.odt', + 'pre_adoption' => __DIR__ . '/../../resources/documents/CERTIFICAT VISITE PRE-ADOPTION V1.1.odt', + ]; + + public static function adoptionTemplate(): string + { + if (!is_file(self::ADOPTION_TEMPLATE)) { + throw new RuntimeException(t('service.document.adoption_missing')); + } + return self::ADOPTION_TEMPLATE; + } + + public static function template(string $type): string + { + $path = self::TEMPLATES[$type] ?? null; + if (!$path || !is_file($path)) { + throw new RuntimeException(t('service.document.template_missing')); + } + return $path; + } + + public static function generateAbandon(array $animal): string + { + return self::transformTemplate('abandon', 'abandon-' . self::safeName((string) $animal['name']), function ( + DOMXPath $xp, + ) use ($animal): void { + $sex = match ($animal['sex'] ?? 'U') { + 'M' => 'Mâle', + 'F' => 'Femelle', + default => 'Non renseigné', + }; + $birth = trim((string) ($animal['birth_date'] ?? '')); + $age = '—'; + if ($birth !== '') { + $born = new DateTimeImmutable($birth); + $now = new DateTimeImmutable(); + $diff = $born->diff($now); + $age = $diff->y > 0 ? $diff->y . ' an' . ($diff->y > 1 ? 's' : '') : $diff->m . ' mois'; + } + $chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? '')); + self::replaceParagraph($xp, 'Nom de l’animal', [ + ['Nom de l’animal : ', 'label'], + [(string) $animal['name'], 'value'], + ['tab'], + ['Sexe : ', 'label'], + [$sex, 'value'], + ]); + self::replaceParagraph($xp, 'Race :', [ + ['Race : ', 'label'], + [(string) ($animal['breed'] ?? '—'), 'value'], + ['tab'], + ['Âge : ', 'label'], + [$age, 'value'], + ['break'], + ['Vaccination : ', 'label'], + ['À vérifier', 'value'], + ['tab'], + ['Vermifugation : ', 'label'], + ['À vérifier', 'value'], + ['break'], + ['Numéro de tatouage ou puce électronique : ', 'label'], + [$chip !== '' ? $chip : '—', 'value'], + ]); + }); + } + + public static function generateContact(string $type, array $contact): string + { + if (!in_array($type, ['benevole', 'fa'], true)) { + throw new InvalidArgumentException(t('service.document.invalid_type')); + } + return self::transformTemplate($type, $type . '-' . self::safeName((string) $contact['name']), function ( + DOMXPath $xp, + ) use ($type, $contact): void { + $name = (string) $contact['name']; + $address = (string) ($contact['address'] ?? ''); + $postal = (string) ($contact['postal_code'] ?? ''); + $city = (string) ($contact['city'] ?? ''); + $phone = (string) ($contact['phone'] ?? ''); + $email = (string) ($contact['email'] ?? ''); + if ($type === 'benevole') { + self::replaceParagraph( + $xp, + 'Nom :', + [['Nom : ', 'label'], [$name, 'value'], ['tab'], ['Prénom : ', 'label'], ['', 'value']], + 0, + ); + self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0); + self::replaceParagraph( + $xp, + 'Code Postal :', + [['Code Postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']], + 0, + ); + self::replaceParagraph( + $xp, + 'Numéro de téléphone', + [ + ['Numéro de téléphone : ', 'label'], + [$phone, 'value'], + ['tab'], + ['Adresse e-mail : ', 'label'], + [$email, 'value'], + ], + 0, + ); + } else { + self::replaceParagraph($xp, 'NOM / Prénom :', [['NOM / Prénom : ', 'label'], [$name, 'value']], 0); + self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0); + self::replaceParagraph( + $xp, + 'Code postal :', + [['Code postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']], + 0, + ); + self::replaceParagraph( + $xp, + 'Téléphone :', + [ + ['Téléphone : ', 'label'], + [$phone, 'value'], + ['tab'], + ['Adresse mail : ', 'label'], + [$email, 'value'], + ], + 0, + ); + } + }); + } + + public static function generateAdoption(array $animal, ?array $adoption, ?array $vaccine): string + { + $dir = self::tempDir(); + $name = self::safeName((string) $animal['name']); + $target = $dir . '/contrat-adoption-' . $name . '.odt'; + if (!copy(self::adoptionTemplate(), $target)) { + throw new RuntimeException(t('service.document.copy_failed')); + } + + $zip = new ZipArchive(); + if ($zip->open($target) !== true) { + throw new RuntimeException(t('service.document.open_failed')); + } + $xml = $zip->getFromName('content.xml'); + if ($xml === false) { + $zip->close(); + throw new RuntimeException(t('service.document.content_invalid')); + } + $dom = new DOMDocument(); + $dom->preserveWhiteSpace = true; + if (!$dom->loadXML($xml)) { + $zip->close(); + throw new RuntimeException(t('service.document.xml_invalid')); + } + $xp = new DOMXPath($dom); + $xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0'); + $xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0'); + self::addGeneratedFieldStyles($dom, $xp); + + $date = static fn(?string $value): string => $value ? date('d/m/Y', strtotime($value)) : ''; + $sex = match ($animal['sex'] ?? 'U') { + 'M' => '☒ Mâle ☐ Femelle', + 'F' => '☐ Mâle ☒ Femelle', + default => '☐ Mâle ☐ Femelle', + }; + $origin = trim((string) ($animal['breed'] ?? '')); + $chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? '')); + $testStatus = + 'FIV ' . + self::testLabel((string) ($animal['fiv_status'] ?? 'unknown')) . + ' / FeLV ' . + self::testLabel((string) ($animal['felv_status'] ?? 'unknown')); + $signatureDate = $date($adoption['adoption_date'] ?? date('Y-m-d')); + + self::setSpan($xp, 'T26', (string) $animal['name']); + self::setLabeledSpan($xp, 'T28', 'Sexe : ', $sex); + self::setSpan($xp, 'T31', ' ' . $date($animal['birth_date'] ?? null)); + self::setSpan($xp, 'T33', 'Robe : ', null, 'GeneratedField'); + self::setSpan($xp, 'T34', (string) ($animal['color'] ?? '')); + self::setSpan($xp, 'T41', $origin !== '' ? $origin : '—'); + self::setSpan($xp, 'T42', ''); + self::setLabeledSpan($xp, 'T45', 'Puce électronique N° : ', $chip !== '' ? $chip : '—'); + self::setLabeledSpan( + $xp, + 'T49', + ' : ', + (int) ($animal['sterilized'] ?? 0) === 1 + ? '☒ Effectuée ☐ À effectuer avant le :' + : '☐ Effectuée ☒ À effectuer avant le :', + ); + self::setLabeledSpan($xp, 'T53', 'Date du test FIV-FELV : ', $testStatus, 'tab'); + self::setLabeledSpan($xp, 'T55', 'Date de la dernière vaccination : ', $date($vaccine['done_date'] ?? null)); + self::setLabeledSpan($xp, 'T56', 'Rappel à effectuer le : ', $date($vaccine['due_date'] ?? null), 'tab'); + + self::setSpan($xp, 'T98', (string) ($adoption['adopter_name'] ?? '')); + self::setSpan($xp, 'T101', (string) ($adoption['adopter_address'] ?? '')); + self::setSpan($xp, 'T104', (string) ($adoption['adopter_postal_code'] ?? '')); + self::setSpan($xp, 'T107', (string) ($adoption['adopter_city'] ?? '')); + self::setSpan($xp, 'T110', ''); + self::setSpan($xp, 'T113', (string) ($adoption['adopter_phone'] ?? '')); + self::setSpan($xp, 'T116', (string) ($adoption['adopter_email'] ?? '')); + $signatureCity = AppSettings::get('association_city') ?: '—'; + self::setLabeledSpan($xp, 'T155', 'Fait à : ', $signatureCity, null, 'GeneratedSignature'); + self::setLabeledSpan($xp, 'T156', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature'); + self::setLabeledSpan($xp, 'T247', 'Fait à : ', $signatureCity, null, 'GeneratedSignature'); + self::setLabeledSpan($xp, 'T253', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature'); + self::setSpan($xp, 'T283', (string) $animal['name']); + self::setLabeledSpan($xp, 'T367', 'Fait à : ', $signatureCity, null, 'GeneratedSignature'); + self::setLabeledSpan($xp, 'T373', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature'); + foreach ( + [ + 'T25', + 'T30', + 'T33', + 'T36', + 'T40', + 'T45', + 'T48', + 'T52', + 'T53', + 'T55', + 'T56', + 'T97', + 'T100', + 'T103', + 'T106', + 'T109', + 'T112', + 'T115', + 'T281', + ] + as $labelStyle + ) { + self::applySpanStyle($xp, $labelStyle); + } + + $updated = $dom->saveXML(); + if ($updated === false || !$zip->addFromString('content.xml', $updated)) { + $zip->close(); + throw new RuntimeException(t('service.document.update_contract_failed')); + } + $zip->close(); + return $target; + } + + public static function toPdf(string $odtPath): string + { + $dir = dirname($odtPath); + $profile = $dir . '/lo-profile'; + mkdir($profile, 0775, true); + $cmd = [ + '/usr/bin/env', + '-u', + 'DISPLAY', + 'SAL_USE_VCLPLUGIN=svp', + '/usr/bin/libreoffice', + '-env:UserInstallation=' . self::fileUri($profile), + '--headless', + '--nologo', + '--nodefault', + '--nolockcheck', + '--nofirststartwizard', + '--convert-to', + 'pdf', + '--outdir', + $dir, + $odtPath, + ]; + $pipes = []; + $process = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new RuntimeException(t('service.document.libreoffice_failed')); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($process); + $pdf = $dir . '/' . pathinfo($odtPath, PATHINFO_FILENAME) . '.pdf'; + if ($code !== 0 || !is_file($pdf)) { + throw new RuntimeException(t('service.document.pdf_failed', ['details' => trim($stdout . ' ' . $stderr)])); + } + return $pdf; + } + + public static function cleanup(string $path): void + { + $dir = dirname($path); + if (!str_starts_with($dir, sys_get_temp_dir() . '/globinours-doc-')) { + return; + } + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); + } + + private static function setSpan( + DOMXPath $xp, + string $style, + string $value, + ?string $prefix = null, + string $generatedStyle = 'GeneratedValue', + ): void { + $nodes = $xp->query("//text:span[@text:style-name='$style']"); + if (!$nodes || $nodes->length === 0) { + return; + } + $node = $nodes->item(0); + while ($node->firstChild) { + $node->removeChild($node->firstChild); + } + $node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle); + if ($prefix === 'tab') { + $node->appendChild( + $node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'), + ); + } + $node->appendChild($node->ownerDocument->createTextNode($value)); + } + + private static function setLabeledSpan( + DOMXPath $xp, + string $style, + string $label, + string $value, + ?string $prefix = null, + string $labelStyle = 'GeneratedField', + ): void { + $nodes = $xp->query("//text:span[@text:style-name='$style']"); + if (!$nodes || $nodes->length === 0) { + return; + } + $node = $nodes->item(0); + while ($node->firstChild) { + $node->removeChild($node->firstChild); + } + $node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $labelStyle); + if ($prefix === 'tab') { + $node->appendChild( + $node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'), + ); + } + $node->appendChild($node->ownerDocument->createTextNode($label)); + $valueSpan = $node->ownerDocument->createElementNS( + 'urn:oasis:names:tc:opendocument:xmlns:text:1.0', + 'text:span', + ); + $valueSpan->setAttributeNS( + 'urn:oasis:names:tc:opendocument:xmlns:text:1.0', + 'text:style-name', + 'GeneratedValue', + ); + $valueSpan->appendChild($node->ownerDocument->createTextNode($value)); + $node->appendChild($valueSpan); + } + + private static function applySpanStyle(DOMXPath $xp, string $style, string $generatedStyle = 'GeneratedField'): void + { + $nodes = $xp->query("//text:span[@text:style-name='$style']"); + if (!$nodes || $nodes->length === 0) { + return; + } + $nodes + ->item(0) + ->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle); + } + + private static function addGeneratedFieldStyles(DOMDocument $dom, DOMXPath $xp): void + { + $automatic = $xp->query('//office:automatic-styles')->item(0); + if (!$automatic) { + return; + } + self::appendTextStyle($dom, $automatic, 'GeneratedField', '11pt'); + self::appendTextStyle($dom, $automatic, 'GeneratedValue', '11pt', true); + self::appendTextStyle($dom, $automatic, 'GeneratedSignature', '11pt'); + } + + private static function appendTextStyle( + DOMDocument $dom, + DOMNode $automatic, + string $name, + string $size, + bool $bold = false, + ): void { + $style = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:style'); + $style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:name', $name); + $style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:family', 'text'); + $props = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:text-properties'); + $props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name', 'Open Sans'); + $props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name-asian', 'Open Sans'); + $props->setAttributeNS( + 'urn:oasis:names:tc:opendocument:xmlns:style:1.0', + 'style:font-name-complex', + 'Open Sans', + ); + $props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0', 'fo:font-size', $size); + $weight = $bold ? 'bold' : 'normal'; + $props->setAttributeNS( + 'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0', + 'fo:font-weight', + $weight, + ); + $props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-asian', $size); + $props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-complex', $size); + $props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-asian', $weight); + $props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-complex', $weight); + $style->appendChild($props); + $automatic->appendChild($style); + } + + private static function transformTemplate(string $type, string $filename, callable $transform): string + { + $dir = self::tempDir(); + $target = $dir . '/' . $filename . '.odt'; + if (!copy(self::template($type), $target)) { + throw new RuntimeException(t('service.document.copy_failed')); + } + $zip = new ZipArchive(); + if ($zip->open($target) !== true) { + throw new RuntimeException(t('service.document.open_failed')); + } + $xml = $zip->getFromName('content.xml'); + if ($xml === false) { + $zip->close(); + throw new RuntimeException(t('service.document.content_invalid')); + } + $dom = new DOMDocument(); + $dom->preserveWhiteSpace = true; + if (!$dom->loadXML($xml)) { + $zip->close(); + throw new RuntimeException(t('service.document.xml_invalid')); + } + $xp = new DOMXPath($dom); + $xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0'); + $xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0'); + self::addGeneratedFieldStyles($dom, $xp); + $transform($xp); + $updated = $dom->saveXML(); + if ($updated === false || !$zip->addFromString('content.xml', $updated)) { + $zip->close(); + throw new RuntimeException(t('service.document.update_failed')); + } + $zip->close(); + return $target; + } + + private static function replaceParagraph(DOMXPath $xp, string $startsWith, array $parts, int $occurrence = 0): void + { + $matches = []; + foreach ($xp->query('//text:p') as $paragraph) { + $plain = preg_replace('/\s+/u', ' ', str_replace("\u{00A0}", ' ', $paragraph->textContent)); + if (str_starts_with(trim((string) $plain), $startsWith)) { + $matches[] = $paragraph; + } + } + $node = $matches[$occurrence] ?? null; + if (!$node) { + return; + } + while ($node->firstChild) { + $node->removeChild($node->firstChild); + } + foreach ($parts as $part) { + if ($part === ['tab']) { + $node->appendChild( + $node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'), + ); + continue; + } + if ($part === ['break']) { + $node->appendChild( + $node->ownerDocument->createElementNS( + 'urn:oasis:names:tc:opendocument:xmlns:text:1.0', + 'text:line-break', + ), + ); + continue; + } + [$text, $kind] = $part; + $span = $node->ownerDocument->createElementNS( + 'urn:oasis:names:tc:opendocument:xmlns:text:1.0', + 'text:span', + ); + $span->setAttributeNS( + 'urn:oasis:names:tc:opendocument:xmlns:text:1.0', + 'text:style-name', + $kind === 'value' ? 'GeneratedValue' : 'GeneratedField', + ); + $span->appendChild($node->ownerDocument->createTextNode($text)); + $node->appendChild($span); + } + } + + private static function testLabel(string $status): string + { + return match ($status) { + 'neg' => 'négatif', + 'pos' => 'positif', + default => 'non renseigné', + }; + } + private static function safeName(string $name): string + { + $name = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) ?: 'chat'; + return strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $name), '-')) ?: 'chat'; + } + private static function tempDir(): string + { + $dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8)); + if (!mkdir($dir, 0775, true)) { + throw new RuntimeException(t('service.document.temp_failed')); + } + return $dir; + } + private static function fileUri(string $path): string + { + return 'file://' . str_replace('%2F', '/', rawurlencode($path)); + } +} diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php new file mode 100644 index 0000000..df454b8 --- /dev/null +++ b/app/Services/ExportService.php @@ -0,0 +1,178 @@ + [ + 'Animaux', + 'animaux.csv', + "SELECT a.id,a.internal_code code_interne,a.name nom,a.species espece,a.sex sexe,a.birth_date date_naissance,a.birth_is_estimated naissance_estimee,a.chip_id numero_identification,a.identification_type type_identification,a.identification_date date_identification,a.identification_registration_status statut_enregistrement_icad,a.adoption_availability disponibilite_adoption,a.adoption_available_from disponible_a_partir_du,a.adoption_unavailability_reason motif_indisponibilite,a.breed race,a.color robe,a.hair_type type_poils,a.compatibility_dogs compatible_chiens,a.compatibility_cats compatible_chats,a.compatibility_children compatible_enfants,a.house_trained proprete,a.status statut,a.refuge_room salle_refuge,a.care_box_key box_soin,a.fiv_status statut_fiv,a.felv_status statut_felv,a.sterilization_status statut_sterilisation,a.sterilization_date date_sterilisation,sv.name veterinaire_sterilisation,sc.name cabinet_sterilisation,a.intake_date date_entree,a.intake_type mode_entree,CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE a.intake_reason END motif_entree,dep.name deposant,a.intake_circumstances circonstances_entree,a.quarantine_until fin_quarantaine,a.rescue_location_name commune_origine,a.rescue_address adresse_origine,a.notes,a.created_at cree_le,a.updated_at modifie_le,a.archived_at archive_le,a.deleted_at supprime_le,a.deletion_reason motif_suppression FROM animals a LEFT JOIN directory_contacts dep ON dep.id=a.depositor_contact_id LEFT JOIN directory_contacts sv ON sv.id=a.sterilization_vet_contact_id LEFT JOIN directory_contacts sc ON sc.id=a.sterilization_clinic_contact_id ORDER BY a.id", + ], + 'contacts' => [ + 'Annuaire', + 'annuaire.csv', + 'SELECT c.id,c.kind type,c.name nom,GROUP_CONCAT(DISTINCT r.role) roles,c.phone telephone,c.email,c.address adresse,c.postal_code code_postal,c.city ville,c.country pays,o.name structure_liee,c.notes,c.created_at cree_le,c.updated_at modifie_le,c.deleted_at supprime_le FROM directory_contacts c LEFT JOIN directory_contact_roles r ON r.contact_id=c.id LEFT JOIN directory_contacts o ON o.id=c.organization_id GROUP BY c.id ORDER BY c.name COLLATE NOCASE', + ], + 'adoptions' => [ + 'Adoptions', + 'adoptions.csv', + 'SELECT ad.id,a.internal_code code_animal,a.name animal,ad.adoption_date date_adoption,ad.adopter_name adoptant,ad.adopter_phone telephone,ad.adopter_email email,ad.adopter_address adresse,ad.adopter_postal_code code_postal,ad.adopter_city ville,ad.adopter_country pays,ad.notes,ad.created_at cree_le FROM adoptions ad JOIN animals a ON a.id=ad.animal_id ORDER BY ad.adoption_date,ad.id', + ], + 'placements' => [ + 'Parcours d’accueil et retours', + 'placements-retours.csv', + 'SELECT ap.id,a.internal_code code_animal,a.name animal,ap.event_type type_evenement,ap.event_date date_evenement,dc.name contact,dc.city ville,ap.reason motif,ap.notes,COALESCE(u.display_name,u.username) saisi_par,ap.created_at cree_le FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id LEFT JOIN users u ON u.id=ap.created_by ORDER BY ap.event_date,ap.id', + ], + 'medical' => [ + 'Notes médicales', + 'notes-medicales.csv', + 'SELECT mn.id,a.internal_code code_animal,a.name animal,mn.noted_at date_note,mn.kind type,mn.reason motif,mn.symptoms symptomes,mn.exam examen,mn.diagnosis,mn.plan conduite,mn.temperature_c temperature,mn.weight_kg poids,vet.name veterinaire,clinic.name cabinet,COALESCE(u.display_name,u.username) saisi_par,mn.created_at cree_le FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id LEFT JOIN directory_contacts vet ON vet.id=mn.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=mn.clinic_contact_id LEFT JOIN users u ON u.id=mn.created_by ORDER BY mn.noted_at,mn.id', + ], + 'treatments' => [ + 'Traitements', + 'traitements.csv', + 'SELECT t.id,a.internal_code code_animal,a.name animal,m.name medicament,t.dose_text dose,t.route voie,t.start_date debut,t.end_date fin,t.ongoing en_cours,t.give_morning matin,t.give_evening soir,t.give_as_needed si_besoin,vet.name veterinaire,clinic.name cabinet,t.notes FROM treatments t JOIN animals a ON a.id=t.animal_id JOIN ref_medications m ON m.id=t.medication_id LEFT JOIN directory_contacts vet ON vet.id=t.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=t.clinic_contact_id ORDER BY t.start_date,t.id', + ], + 'vaccinations' => [ + 'Vaccinations', + 'vaccinations.csv', + 'SELECT v.id,a.internal_code code_animal,a.name animal,rv.name vaccin,v.done_date date_realisation,v.due_date prochain_rappel,v.manufacturer fabricant,v.lot,v.batch_expires_on expiration_lot,COALESCE(v.administered_by_name,au.display_name,au.username) administre_par,COALESCE(cu.display_name,cu.username) saisi_par,vet.name veterinaire,clinic.name cabinet,v.notes FROM vaccinations v JOIN animals a ON a.id=v.animal_id JOIN ref_vaccines rv ON rv.id=v.vaccine_id LEFT JOIN directory_contacts vet ON vet.id=v.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=v.clinic_contact_id LEFT JOIN users au ON au.id=v.administered_by_user_id LEFT JOIN users cu ON cu.id=v.created_by ORDER BY v.done_date,v.id', + ], + 'deaths' => [ + 'Décès', + 'deces.csv', + 'SELECT d.animal_id,a.internal_code code_animal,a.name animal,d.deceased_date date_deces,d.date_precision precision_date,d.death_age_value age_deces_valeur,d.death_age_unit age_deces_unite,d.cause_code cause,d.cause_details details_cause,d.occurred_in_care sous_responsabilite,d.place_type type_lieu,d.place_details lieu,d.euthanized euthanasie,vet.name veterinaire,crem.name crematorium,d.body_disposition devenir_corps,d.cremation_date date_cremation,d.memorial_medal_color couleur_medaille,d.memorial_medal_count nombre_medailles,d.recovered_source source_recuperation,d.notes,COALESCE(u.display_name,u.username) enregistre_par,d.created_at cree_le,d.updated_at modifie_le FROM animal_deaths d JOIN animals a ON a.id=d.animal_id LEFT JOIN directory_contacts vet ON vet.id=d.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=d.crematorium_contact_id LEFT JOIN users u ON u.id=d.created_by ORDER BY d.deceased_date,d.animal_id', + ], + 'measurements' => [ + 'Mesures', + 'mesures.csv', + 'SELECT me.id,a.internal_code code_animal,a.name animal,me.measured_at date_mesure,me.type,me.value valeur,me.unit unite,me.notes,COALESCE(u.display_name,u.username) saisi_par FROM measurements me JOIN animals a ON a.id=me.animal_id LEFT JOIN users u ON u.id=me.created_by ORDER BY me.measured_at,me.id', + ], + 'litters' => [ + 'Portées', + 'portees.csv', + 'SELECT l.id,m.internal_code code_mere,m.name mere,f.internal_code code_pere,f.name pere,l.birth_date date_naissance,GROUP_CONCAT(k.internal_code) codes_chatons,GROUP_CONCAT(k.name) chatons,l.notes,l.created_at cree_le FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id LEFT JOIN litter_kittens lk ON lk.litter_id=l.id LEFT JOIN animals k ON k.id=lk.animal_id GROUP BY l.id ORDER BY l.birth_date,l.id', + ], + 'bonded' => [ + 'Groupes inséparables', + 'groupes-inseparables.csv', + 'SELECT g.id,g.name nom_groupe,g.active actif,GROUP_CONCAT(a.internal_code) codes_animaux,GROUP_CONCAT(a.name) animaux,g.notes,g.created_at cree_le,g.dissolved_at dissous_le FROM bonded_groups g LEFT JOIN bonded_group_members gm ON gm.group_id=g.id LEFT JOIN animals a ON a.id=gm.animal_id GROUP BY g.id ORDER BY g.id', + ], + 'care_rounds' => [ + 'Tournées', + 'tournees.csv', + 'SELECT r.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,o.food alimentation,o.water eau,o.urine,o.stool selles,o.general_state etat_general,o.comment,o.checked_at valide_le,COALESCE(u.display_name,u.username,r.performed_by) effectue_par FROM care_rounds r LEFT JOIN care_round_observations o ON o.round_id=r.id LEFT JOIN animals a ON a.id=o.animal_id LEFT JOIN users u ON u.id=r.performed_by_user_id ORDER BY r.round_date,r.period,o.checked_at', + ], + 'administrations' => [ + 'Administrations de traitements', + 'administrations-traitements.csv', + 'SELECT ta.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,m.name medicament,ta.status statut,ta.comment,ta.administered_at administre_le FROM treatment_administrations ta JOIN care_rounds r ON r.id=ta.round_id JOIN animals a ON a.id=ta.animal_id JOIN treatments t ON t.id=ta.treatment_id JOIN ref_medications m ON m.id=t.medication_id ORDER BY r.round_date,r.period,ta.id', + ], + 'media_index' => [ + 'Index des médias', + 'index-medias.csv', + "SELECT 'animal' type_media,p.id,a.internal_code code_animal,a.name animal,p.filename fichier,p.original_name nom_original,p.mime,p.size_bytes taille_octets,p.is_primary principale,p.is_public publique,p.caption legende,p.care_round_id tournee_id,NULL note_medicale_id,p.created_at ajoute_le FROM animal_photos p JOIN animals a ON a.id=p.animal_id UNION ALL SELECT 'medical',mp.id,a.internal_code,a.name,mp.filename,NULL,NULL,NULL,0,0,NULL,NULL,mp.medical_note_id,mp.created_at FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id ORDER BY 14,2", + ], + 'movements' => [ + 'Mouvements', + 'mouvements.csv', + 'SELECT m.id,a.internal_code code_animal,a.name animal,m.kind type,m.place emplacement,m.lieu motif,m.contact_name contact,m.contact_phone telephone,m.contact_email email,m.note,m.created_at date_mouvement FROM animal_movements m JOIN animals a ON a.id=m.animal_id ORDER BY m.created_at,m.id', + ], + 'locations' => [ + 'Historique des lieux de vie', + 'historique-emplacements.csv', + 'SELECT lh.id,a.internal_code code_animal,a.name animal,lh.from_status ancien_statut,lh.from_refuge_room ancienne_salle,lh.from_care_box_key ancien_box,lh.from_address ancienne_adresse,lh.to_status nouveau_statut,lh.to_refuge_room nouvelle_salle,lh.to_care_box_key nouveau_box,lh.to_address nouvelle_adresse,lh.reason motif,lh.source origine_action,COALESCE(u.display_name,u.username) utilisateur,lh.moved_at date_deplacement FROM animal_location_history lh JOIN animals a ON a.id=lh.animal_id LEFT JOIN users u ON u.id=lh.user_id ORDER BY lh.moved_at,lh.id', + ], + 'history' => [ + 'Historique', + 'historique.csv', + 'SELECT h.id,a.internal_code code_animal,a.name animal,h.type,h.label,h.details,COALESCE(u.display_name,u.username) utilisateur,h.created_at date_action FROM animal_history h JOIN animals a ON a.id=h.animal_id LEFT JOIN users u ON u.id=h.user_id ORDER BY h.created_at,h.id', + ], + ]; + + public static function catalog(): array + { + foreach (self::DATASETS as $key => [$label, $file]) { + $out[$key] = ['label' => t('export.' . $key), 'filename' => $file]; + } + return $out ?? []; + } + public static function downloadCsv(string $key): void + { + [$label, $filename] = self::definition($key); + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('X-Content-Type-Options: nosniff'); + $stream = fopen('php://output', 'wb'); + self::write($stream, $key); + fclose($stream); + } + public static function downloadZip(): void + { + if (!class_exists(ZipArchive::class)) { + throw new RuntimeException(t('service.export.zip_missing')); + } + $path = tempnam(sys_get_temp_dir(), 'globinours-export-'); + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException(t('service.export.prepare_failed')); + } + try { + foreach (self::DATASETS as $key => [, $filename]) { + $stream = fopen('php://temp', 'w+b'); + self::write($stream, $key); + rewind($stream); + $zip->addFromString($filename, stream_get_contents($stream)); + fclose($stream); + } + $zip->addFromString( + 'LISEZ-MOI.txt', + "Export de portabilité Globinours\r\nGénéré le " . + date('d/m/Y à H:i') . + "\r\nEncodage : UTF-8 avec séparateur point-virgule.\r\nLes comptes utilisateurs, mots de passe, médias et secrets ne sont pas inclus.\r\n", + ); + } finally { + $zip->close(); + } + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="globinours-export-' . date('Y-m-d-His') . '.zip"'); + header('Content-Length: ' . filesize($path)); + header('X-Content-Type-Options: nosniff'); + readfile($path); + @unlink($path); + } + private static function write($stream, string $key): void + { + [, , $sql] = self::definition($key); + fwrite($stream, "\xEF\xBB\xBF"); + $stmt = DB::pdo()->query($sql); + $headers = []; + for ($i = 0; $i < $stmt->columnCount(); $i++) { + $headers[] = (string) ($stmt->getColumnMeta($i)['name'] ?? 'colonne_' . $i); + } + fputcsv($stream, $headers, ';', '"', '\\', "\r\n"); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + fputcsv($stream, array_map([self::class, 'safeCell'], $row), ';', '"', '\\', "\r\n"); + } + } + private static function safeCell(mixed $value): string + { + if (is_int($value) || is_float($value)) { + return (string) $value; + } + $value = $value === null ? '' : (string) $value; + if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) { + $value = "'" . $value; + } + return $value; + } + private static function definition(string $key): array + { + if (!isset(self::DATASETS[$key])) { + throw new InvalidArgumentException(t('service.export.unknown_dataset')); + } + return self::DATASETS[$key]; + } +} diff --git a/app/Services/GeoService.php b/app/Services/GeoService.php new file mode 100644 index 0000000..bb708a9 --- /dev/null +++ b/app/Services/GeoService.php @@ -0,0 +1,62 @@ +prepare('SELECT lat,lng FROM geocode_cache WHERE q=:q LIMIT 1'); + $st->execute([':q' => $q]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if ($row && $row['lat'] !== null && $row['lng'] !== null) { + return ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']]; + } + + // Nominatim (OpenStreetMap) — respecter l'usage: User-Agent + requêtes raisonnables + $url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' . rawurlencode($q); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: Globinours/1.0 (contact: local-refuge)'], + ]); + + $body = curl_exec($ch); + if ($body === false) { + curl_close($ch); + return null; + } + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code < 200 || $code >= 300) { + return null; + } + + $json = json_decode($body, true); + if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) { + // Stocker "vide" en cache évite de spammer + $db->prepare( + "INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,NULL,NULL,:raw,datetime('now'))", + )->execute([':q' => $q, ':raw' => $body]); + return null; + } + + $lat = (float) $json[0]['lat']; + $lng = (float) $json[0]['lon']; + + $db->prepare( + "INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,:lat,:lng,:raw,datetime('now'))", + )->execute([':q' => $q, ':lat' => $lat, ':lng' => $lng, ':raw' => $body]); + + return ['lat' => $lat, 'lng' => $lng]; + } +} diff --git a/app/Services/GeocodeService.php b/app/Services/GeocodeService.php new file mode 100644 index 0000000..69829b6 --- /dev/null +++ b/app/Services/GeocodeService.php @@ -0,0 +1,70 @@ +float, 'lng'=>float, 'display_name'=>string] ou null + * + * IMPORTANT: + * - Respecte le fair use : pas de spam (cache côté DB chez toi, et TTL) + * - User-Agent explicite + */ + public static function geocode(string $query): ?array + { + $query = trim($query); + if ($query === '') { + return null; + } + + // Nominatim conseille d'envoyer un UA + un contact + $email = getenv('GEOCODER_EMAIL') ?: 'contact@localhost'; + $ua = 'TwixRefuge/1.0 (geocoding; contact: ' . $email . ')'; + + $url = + 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&addressdetails=0&q=' . + rawurlencode($query); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 12, + CURLOPT_CONNECTTIMEOUT => 6, + CURLOPT_HTTPHEADER => ['User-Agent: ' . $ua, 'Accept: application/json'], + CURLOPT_ENCODING => '', // gzip/br si dispo + ]); + + $body = curl_exec($ch); + if ($body === false) { + curl_close($ch); + return null; + } + + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status < 200 || $status >= 300) { + return null; + } + + $json = json_decode($body, true); + if (!is_array($json) || empty($json[0])) { + return null; + } + + $item = $json[0]; + + $lat = isset($item['lat']) ? (float) $item['lat'] : null; + $lng = isset($item['lon']) ? (float) $item['lon'] : null; + if ($lat === null || $lng === null) { + return null; + } + + return [ + 'lat' => $lat, + 'lng' => $lng, + 'display_name' => (string) ($item['display_name'] ?? ''), + ]; + } +} diff --git a/app/Services/GlobalSearchService.php b/app/Services/GlobalSearchService.php new file mode 100644 index 0000000..84a847a --- /dev/null +++ b/app/Services/GlobalSearchService.php @@ -0,0 +1,82 @@ + [ + 'title' => $r['name'], + 'detail' => implode(' · ', array_filter([$r['internal_code'], $r['species'], $r['status']])), + 'url' => '/animal?id=' . (int) $r['id'], + ], + ); + } + if (PermissionService::can('directory')) { + $out['directory'] = self::rows( + $db, + "SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR COALESCE(phone,'') LIKE :q ESCAPE '\\' OR COALESCE(email,'') LIKE :q ESCAPE '\\' OR COALESCE(city,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim", + $like, + $limit, + static fn($r) => [ + 'title' => $r['name'], + 'detail' => implode(' · ', array_filter([$r['city'], $r['phone'], $r['email']])), + 'url' => '/directory/view?id=' . (int) $r['id'], + ], + ); + } + if (PermissionService::can('agenda')) { + $out['agenda'] = self::rows( + $db, + "SELECT id,title,starts_at,location,status FROM agenda_items WHERE title LIKE :q ESCAPE '\\' OR COALESCE(description,'') LIKE :q ESCAPE '\\' OR COALESCE(location,'') LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim", + $like, + $limit, + static fn($r) => [ + 'title' => $r['title'], + 'detail' => implode( + ' · ', + array_filter([substr((string) $r['starts_at'], 0, 16), $r['location'], $r['status']]), + ), + 'url' => '/agenda?q=' . rawurlencode($r['title']), + ], + ); + } + if (PermissionService::can('accounting')) { + $out['accounting'] = self::rows( + $db, + "SELECT ai.id,ai.reference,ai.invoice_date,ai.status,dc.name vendor FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL AND (ai.reference LIKE :q ESCAPE '\\' OR COALESCE(ai.notes,'') LIKE :q ESCAPE '\\' OR COALESCE(dc.name,'') LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim", + $like, + $limit, + static fn($r) => [ + 'title' => $r['reference'], + 'detail' => implode(' · ', array_filter([$r['vendor'], $r['invoice_date'], $r['status']])), + 'url' => '/accounting/edit?id=' . (int) $r['id'], + ], + ); + } + return array_filter($out); + } + + private static function rows(PDO $db, string $sql, string $like, int $limit, callable $map): array + { + $s = $db->prepare($sql); + $s->bindValue(':q', $like); + $s->bindValue(':lim', max(1, min(20, $limit)), PDO::PARAM_INT); + $s->execute(); + return array_map($map, $s->fetchAll(PDO::FETCH_ASSOC)); + } +} diff --git a/app/Services/GrantService.php b/app/Services/GrantService.php new file mode 100644 index 0000000..dc6268a --- /dev/null +++ b/app/Services/GrantService.php @@ -0,0 +1,313 @@ + 'Brouillon', + 'ready' => 'Prête', + 'submitted' => 'Déposée', + 'awarded' => 'Accordée', + 'refused' => 'Refusée', + 'withdrawn' => 'Abandonnée', + ]; + public const CATEGORIES = [ + 'official_form' => 'Formulaire officiel de l’organisme', + 'statutes' => 'Statuts', + 'prefecture' => 'Récépissé de préfecture', + 'jo' => 'Publication au Journal officiel', + 'rib' => 'RIB', + 'insurance' => 'Attestation d’assurance', + 'ag' => 'Procès-verbal d’assemblée générale', + 'activity' => 'Rapport d’activité', + 'accounts' => 'Comptes approuvés', + 'budget' => 'Budget prévisionnel', + 'other' => 'Autre', + ]; + public const REQUIRED = [ + 'official_form', + 'statutes', + 'prefecture', + 'jo', + 'rib', + 'insurance', + 'ag', + 'activity', + 'accounts', + 'budget', + ]; + + public static function statusLabel(string $key): string + { + return t('grant.status.' . $key, [], self::STATUSES[$key] ?? $key); + } + public static function categoryLabel(string $key): string + { + return t('grant.category.' . $key, [], self::CATEGORIES[$key] ?? $key); + } + + public static function ensureYear(int $year): array + { + $db = DB::pdo(); + $s = $db->prepare('INSERT OR IGNORE INTO grant_years(year) VALUES(?)'); + $s->execute([$year]); + $s = $db->prepare('SELECT * FROM grant_years WHERE year=?'); + $s->execute([$year]); + return $s->fetch() ?: []; + } + + public static function statistics(int $year): array + { + $db = DB::pdo(); + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + $scalar = static function (string $sql, array $params = []) use ($db): float { + $s = $db->prepare($sql); + $s->execute($params); + return (float) ($s->fetchColumn() ?: 0); + }; + $p = [':start' => $start, ':end' => $end]; + $entries = $scalar( + "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end)", + $p, + ); + $exits = $scalar( + "SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end)+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id))", + $p, + ); + $adoptions = $scalar('SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end', $p); + $deaths = $scalar( + "SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end", + $p, + ); + $births = $scalar( + "SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end", + $p, + ); + $vaccinations = $scalar('SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end', $p); + $treatments = $scalar('SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end', $p); + $avg = $scalar( + "SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))", + $p, + ); + $current = $scalar( + "SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')", + ); + return compact( + 'entries', + 'exits', + 'adoptions', + 'deaths', + 'births', + 'vaccinations', + 'treatments', + 'avg', + 'current', + ); + } + + public static function detailedStatistics(int $year): array + { + $db = DB::pdo(); + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + $p = [':start' => $start, ':end' => $end]; + $rows = static function (string $sql, array $params) use ($db): array { + $s = $db->prepare($sql); + $s->execute($params); + return $s->fetchAll(PDO::FETCH_ASSOC); + }; + $map = static function (array $items, string $key = 'label'): array { + $out = []; + foreach ($items as $item) { + $out[(string) $item[$key]] = (int) $item['total']; + } + return $out; + }; + $base = + "WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) "; + $sex = $map( + $rows( + $base . + "SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC", + $p, + ), + ); + $ages = $map( + $rows( + $base . + "SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", + $p, + ), + ); + $originRows = $rows( + $base . 'SELECT a.rescue_location_name,a.rescue_address FROM entered e JOIN animals a ON a.id=e.animal_id', + $p, + ); + $origins = []; + foreach ($originRows as $originRow) { + $label = self::municipality((string) ($originRow['rescue_location_name'] ?: $originRow['rescue_address'])); + $origins[$label] = ($origins[$label] ?? 0) + 1; + } + arsort($origins, SORT_NUMERIC); + $origins = array_slice($origins, 0, 12, true); + $deathCauses = $map( + $rows( + "SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end GROUP BY cause_code ORDER BY total DESC", + $p, + ), + ); + $stays = $map( + $rows( + "SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at))) GROUP BY label ORDER BY total DESC", + $p, + ), + ); + return compact('sex', 'ages', 'origins', 'deathCauses', 'stays'); + } + + public static function comparison(int $year): array + { + $current = self::statistics($year); + $previous = self::statistics($year - 1); + $out = []; + foreach (['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'avg'] as $key) { + $a = (float) $current[$key]; + $b = (float) $previous[$key]; + $out[$key] = [ + 'current' => $a, + 'previous' => $b, + 'difference' => $a - $b, + 'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null, + ]; + } + return $out; + } + + public static function municipality(string $value): string + { + $value = trim(preg_replace('/\s+/u', ' ', $value) ?? ''); + if ($value === '') { + return 'Non renseignée'; + } + if (preg_match('/\b\d{5}\s+([^,;]+)(?:[,;]|$)/u', $value, $match)) { + $value = trim($match[1]); + } elseif (str_contains($value, ',')) { + $parts = array_values(array_filter(array_map('trim', explode(',', $value)))); + $value = (string) end($parts); + } + $value = preg_replace('/^(?:FRANCE|FR)\s*[-–—]?\s*/iu', '', $value) ?? $value; + return $value !== '' ? mb_convert_case($value, MB_CASE_TITLE, 'UTF-8') : 'Non renseignée'; + } + + public static function applications(int $year): array + { + $s = DB::pdo()->prepare( + 'SELECT * FROM grant_applications WHERE year=? ORDER BY COALESCE(deadline,\'9999-12-31\'), organization_name', + ); + $s->execute([$year]); + return $s->fetchAll(); + } + public static function documents(int $year): array + { + $s = DB::pdo()->prepare( + 'SELECT d.*,u.display_name uploader,ga.organization_name application_name FROM grant_documents d LEFT JOIN users u ON u.id=d.uploaded_by LEFT JOIN grant_applications ga ON ga.id=d.application_id WHERE d.year IS NULL OR d.year=? ORDER BY d.category,d.created_at DESC', + ); + $s->execute([$year]); + return $s->fetchAll(); + } + public static function checklist(int $year): array + { + $applications = self::applications($year); + $documents = self::documents($year); + $today = date('Y-m-d'); + $soon = date('Y-m-d', strtotime('+30 days')); + $result = []; + foreach ($applications as $application) { + $items = []; + $valid = 0; + $warnings = 0; + foreach (self::REQUIRED as $category) { + $matches = array_values( + array_filter($documents, static function (array $document) use ($category, $application): bool { + if ($document['category'] !== $category) { + return false; + } + if ($category === 'official_form') { + return (int) ($document['application_id'] ?? 0) === (int) $application['id']; + } + return empty($document['application_id']) || + (int) $document['application_id'] === (int) $application['id']; + }), + ); + usort( + $matches, + static fn(array $a, array $b): int => strcmp((string) $b['created_at'], (string) $a['created_at']), + ); + $document = $matches[0] ?? null; + $state = 'missing'; + if ($document) { + $expiry = (string) ($document['valid_until'] ?? ''); + if ($expiry !== '' && $expiry < $today) { + $state = 'expired'; + } elseif ($expiry !== '' && $expiry <= $soon) { + $state = 'expiring'; + } else { + $state = 'valid'; + } + } + if ($state === 'valid') { + $valid++; + } elseif ($state === 'expiring') { + $valid++; + $warnings++; + } else { + $warnings++; + } + $items[$category] = ['state' => $state, 'document' => $document]; + } + $total = count(self::REQUIRED); + $result[(int) $application['id']] = [ + 'items' => $items, + 'valid' => $valid, + 'total' => $total, + 'warnings' => $warnings, + 'complete' => $valid === $total, + ]; + } + return $result; + } + public static function alerts(int $year): array + { + $applications = self::applications($year); + $checklist = self::checklist($year); + $today = date('Y-m-d'); + $soon = date('Y-m-d', strtotime('+30 days')); + $deadlines = 0; + $overdue = 0; + $incomplete = 0; + $documents = 0; + foreach ($applications as $application) { + if (in_array($application['status'], ['awarded', 'refused', 'withdrawn'], true)) { + continue; + } + $deadline = (string) ($application['deadline'] ?? ''); + if ($deadline !== '' && $deadline < $today) { + $overdue++; + } elseif ($deadline !== '' && $deadline <= $soon) { + $deadlines++; + } + if (!($checklist[(int) $application['id']]['complete'] ?? false)) { + $incomplete++; + } + } + foreach (self::documents($year) as $document) { + $expiry = (string) ($document['valid_until'] ?? ''); + if ($expiry !== '' && $expiry <= $soon) { + $documents++; + } + } + return compact('deadlines', 'overdue', 'incomplete', 'documents'); + } +} diff --git a/app/Services/I18n.php b/app/Services/I18n.php new file mode 100644 index 0000000..96bc6e1 --- /dev/null +++ b/app/Services/I18n.php @@ -0,0 +1,51 @@ + 'Français', 'en' => 'English']; + private static string $locale = 'fr'; + private static array $catalogues = []; + public static function setLocale(string $locale): void + { + self::$locale = isset(self::LOCALES[$locale]) ? $locale : 'fr'; + } + public static function locale(): string + { + return self::$locale; + } + public static function translate(string $key, array $replace = []): string + { + $value = self::catalogue(self::$locale)[$key] ?? (self::catalogue('fr')[$key] ?? $key); + foreach ($replace as $name => $replacement) { + $value = str_replace(':' . $name, (string) $replacement, $value); + } + return $value; + } + public static function choice(string $singular, string $plural, int|float $count, array $replace = []): string + { + return self::translate($count == 1 ? $singular : $plural, ['count' => $count] + $replace); + } + private static function catalogue(string $locale): array + { + if (isset(self::$catalogues[$locale])) { + return self::$catalogues[$locale]; + } + $path = __DIR__ . '/../../resources/lang/' . $locale . '.php'; + $catalogue = is_file($path) ? require $path : []; + return self::$catalogues[$locale] = is_array($catalogue) ? $catalogue : []; + } +} +if (!function_exists('t')) { + function t(string $key, array $replace = []): string + { + return I18n::translate($key, $replace); + } +} +if (!function_exists('tn')) { + function tn(string $singular, string $plural, int|float $count, array $replace = []): string + { + return I18n::choice($singular, $plural, $count, $replace); + } +} diff --git a/app/Services/IcadService.php b/app/Services/IcadService.php new file mode 100644 index 0000000..621015c --- /dev/null +++ b/app/Services/IcadService.php @@ -0,0 +1,363 @@ +db = $db; + } + + // ========= CACHE ========= + + public function getCachedByAnimal(int $animalId): ?array + { + $st = $this->db->prepare('SELECT * FROM icad_cache WHERE animal_id=:id LIMIT 1'); + $st->execute([':id' => $animalId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + + $data = $row['data_json'] ? json_decode($row['data_json'], true) : null; + + return [ + 'animal_id' => (int) $row['animal_id'], + 'chip_id' => (string) ($row['chip_id'] ?? ''), + 'status' => (string) ($row['status'] ?? 'empty'), // ok|error|empty + 'error' => (string) ($row['error_msg'] ?? ''), + 'fetched_at' => (string) ($row['fetched_at'] ?? ''), + 'data' => is_array($data) ? $data : null, + ]; + } + + public function isStale(?array $cache, int $ttlSeconds = 86400): bool + { + if (!$cache || empty($cache['fetched_at'])) { + return true; + } + + // si en erreur → on retente + if (($cache['status'] ?? '') !== 'ok') { + return true; + } + + $t = strtotime($cache['fetched_at']); + if ($t === false) { + return true; + } + + return time() - $t > $ttlSeconds; + } + + private function saveCache(int $animalId, string $chipId, string $status, ?string $err, ?array $data): void + { + $st = $this->db->prepare(" + INSERT INTO icad_cache(animal_id, chip_id, data_json, fetched_at, status, error_msg) + VALUES(:aid,:chip,:json,datetime('now'),:st,:err) + ON CONFLICT(animal_id) DO UPDATE SET + chip_id = excluded.chip_id, + data_json = excluded.data_json, + fetched_at = excluded.fetched_at, + status = excluded.status, + error_msg = excluded.error_msg + "); + $st->execute([ + ':aid' => $animalId, + ':chip' => $chipId, + ':json' => $data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, + ':st' => $status, + ':err' => $err, + ]); + } + + // ========= PUBLIC API ========= + + public function refreshByChip(int $animalId, string $chipId): array + { + $chipId = preg_replace('/\D+/', '', $chipId); + if ($chipId === '') { + throw new RuntimeException(t('service.icad.invalid_chip')); + } + + $login = getenv('ICAD_LOGIN') ?: ''; + $pass = getenv('ICAD_PASSWORD') ?: ''; + if ($login === '' || $pass === '') { + throw new RuntimeException(t('service.icad.credentials_missing')); + } + + $cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie'; + + try { + // 1) GET HOME => token _token du formulaire login ACTEUR + $r1 = $this->req('https://www.i-cad.fr/', $cookie); + + $token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé + + // 2) POST login/check/acteur (XHR) + $post = http_build_query([ + '_token' => $token, + 'login' => $login, + 'password' => $pass, + ]); + + $r2 = $this->req('https://www.i-cad.fr/login/check/acteur', $cookie, 'POST', $post, [ + 'Accept: */*', + 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With: XMLHttpRequest', + 'Origin: https://www.i-cad.fr', + 'Referer: https://www.i-cad.fr/', + ]); + + // 3) Vérif session + $r2b = $this->req('https://www.i-cad.fr/pro', $cookie); + if (stripos($r2b['body'], 'Se connecter') !== false) { + throw new RuntimeException(t('service.icad.login_failed')); + } + + // 4) GET recherche. I-CAD a renommé le formulaire + // search_animal en animal_search en 2026 : on accepte les deux. + $r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie); + [$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']); + + // 5) POST recherche + $post2 = http_build_query([ + $searchForm . '[insert]' => $chipId, + $searchForm . '[tatou]' => '', + $searchForm . '[espece]' => '', + $searchForm . '[sexe]' => '', + $searchForm . '[robe]' => '', + $searchForm . '[_token]' => $searchToken, + ]); + + $r4 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie, 'POST', $post2, [ + 'Content-Type: application/x-www-form-urlencoded', + 'Origin: https://www.i-cad.fr', + 'Referer: https://www.i-cad.fr/identification/recherche', + ]); + + $animalPath = $this->extractAnimalPath($r4); + if (!$animalPath) { + throw new RuntimeException(t('service.icad.no_result')); + } + + // 6) GET fiche animal + $r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie); + $data = $this->parseAnimalHtml($r5['body'], $animalPath); + + $this->saveCache($animalId, $chipId, 'ok', null, $data); + return $data; + } catch (Throwable $e) { + $this->saveCache($animalId, $chipId, 'error', $e->getMessage(), null); + throw $e; + } finally { + @unlink($cookie); + } + } + + // ========= HTTP ========= + + private function req( + string $url, + string $cookieFile, + string $method = 'GET', + ?string $body = null, + array $headers = [], + ): array { + $ch = curl_init($url); + + $defaultHeaders = [ + 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language: fr,en-US;q=0.9,en;q=0.8', + 'Connection: keep-alive', + 'DNT: 1', + 'Sec-GPC: 1', + ]; + $allHeaders = array_merge($defaultHeaders, $headers); + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 10, + CURLOPT_HEADER => true, + CURLOPT_COOKIEJAR => $cookieFile, + CURLOPT_COOKIEFILE => $cookieFile, + CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0', + CURLOPT_TIMEOUT => 25, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_ENCODING => '', // gzip/br si dispo + CURLOPT_HTTPHEADER => $allHeaders, + ]); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); + } + + $raw = curl_exec($ch); + if ($raw === false) { + $err = curl_error($ch); + curl_close($ch); + throw new RuntimeException(t('service.icad.network_error', ['error' => $err])); + } + + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $hs = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $effUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + curl_close($ch); + + return [ + 'status' => $status, + 'headers' => substr($raw, 0, $hs), + 'body' => substr($raw, $hs), + 'effective_url' => $effUrl, + ]; + } + + // ========= TOKENS / PARSING ========= + + private function extractToken(string $html, string $fieldName): string + { + $field = preg_quote($fieldName, '/'); + + // 1) cas standard: name="X" value="Y" + $re = '/]*\bname=(["\'])' . $field . '\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is'; + if (preg_match($re, $html, $m)) { + return html_entity_decode($m[3], ENT_QUOTES); + } + + // 2) fallback: value avant name + $re2 = '/]*\bvalue=(["\'])(.*?)\1[^>]*\bname=(["\'])' . $field . '\3[^>]*>/is'; + if (preg_match($re2, $html, $m)) { + return html_entity_decode($m[2], ENT_QUOTES); + } + + // 3) si fieldName == _token : on prend le 1er _token trouvé + if ($fieldName === '_token') { + if (preg_match('/]*\bname=(["\'])_token\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is', $html, $m)) { + return html_entity_decode($m[3], ENT_QUOTES); + } + } + + throw new RuntimeException(t('service.icad.token_missing', ['field' => $fieldName])); + } + + private function extractAnimalSearchForm(string $html): array + { + foreach (['animal_search', 'search_animal'] as $formName) { + try { + return [$formName, $this->extractToken($html, $formName . '[_token]')]; + } catch (RuntimeException $e) { + // Essayer le nom historique suivant. + } + } + + throw new RuntimeException(t('service.icad.form_missing')); + } + + private function extractAnimalPath(array $resp): ?string + { + // souvent une redirection finale /animal/XXXX dans effective_url + $p = parse_url($resp['effective_url'] ?? '', PHP_URL_PATH); + if (is_string($p) && preg_match('~^/animal/\d+~', $p)) { + return $p; + } + + // fallback HTML + if (preg_match('~href=["\'](/animal/\d+)["\']~', $resp['body'], $m)) { + return $m[1]; + } + if (preg_match('~(/animal/\d+)~', $resp['body'], $m)) { + return $m[1]; + } + return null; + } + + private function parseAnimalHtml(string $html, string $animalPath): array + { + libxml_use_internal_errors(true); + + $dom = new DOMDocument(); + $dom->loadHTML($html); + $xp = new DOMXPath($dom); + + $txt = function (string $xpath) use ($xp): string { + $n = $xp->query($xpath); + if (!$n || $n->length === 0) { + return ''; + } + return trim(preg_replace('~\s+~', ' ', $n->item(0)->textContent)); + }; + + $name = $txt('//*[@id="animal_informations"]//*[contains(@class,"title")]//span[contains(@class,"emphasis")]'); + $insertRaw = $txt('//*[@id="animal_informations"]//*[contains(@class,"id")]//span[contains(@class,"number")]'); + $insert = preg_replace('~\s+~', '', $insertRaw); + + // infos label/value + $rows = $xp->query('//*[@id="animal_informations"]//*[contains(@class,"table-row")]'); + $map = []; + foreach ($rows as $row) { + $ln = $xp->query('.//*[contains(@class,"label")]', $row)->item(0); + $vn = $xp->query('.//*[contains(@class,"value")]', $row)->item(0); + if (!$ln || !$vn) { + continue; + } + $label = trim(preg_replace('~\s+~', ' ', $ln->textContent)); + $value = trim(preg_replace('~\s+~', ' ', $vn->textContent)); + if ($label !== '') { + $map[$label] = $value; + } + } + + // events + $events = []; + $trs = $xp->query('//*[contains(@class,"event-row")]'); + foreach ($trs as $tr) { + $tds = $xp->query('./td', $tr); + if (!$tds || $tds->length < 3) { + continue; + } + $events[] = [ + 'date' => trim($tds->item(0)->textContent), + 'label' => trim(preg_replace('~\s+~', ' ', $tds->item(1)->textContent)), + 'recorded_at' => trim($tds->item(2)->textContent), + ]; + } + + // Coordonnées du détenteur. + $ownerName = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"name")]'); + $ownerEmail = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"address")]'); + $ownerPhone = $txt( + '//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"phone_numbers")]', + ); + $ownerPhone = preg_replace('~\s+~', ' ', $ownerPhone); + + // Adresse postale: i-CAD ne la met pas toujours dans un bloc unique, mais on tente + $ownerPostal = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"postal")]'); + if ($ownerPostal === '') { + // fallback: parfois l’adresse est dans le même bloc coords mais pas taggée + $coordsText = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]'); + // on évite de tout recracher, juste si on détecte des numéros/rue + $ownerPostal = $coordsText; + } + + return [ + 'icad_url' => 'https://www.i-cad.fr' . $animalPath, + 'name' => $name, + 'insert' => $insert, + 'species' => $map['Espèce'] ?? '', + 'sex' => $map['Sexe'] ?? '', + 'birth_date' => $map['Né le'] ?? '', + 'coat_type' => $map['Type de robe'] ?? '', + 'appearance' => $map['Apparence raciale'] ?? '', + 'events' => $events, + 'owner' => [ + 'name' => $ownerName, + 'phone' => $ownerPhone, + 'email' => $ownerEmail, + 'postal' => $ownerPostal, + ], + ]; + } +} diff --git a/app/Services/Ids.php b/app/Services/Ids.php new file mode 100644 index 0000000..48b56bb --- /dev/null +++ b/app/Services/Ids.php @@ -0,0 +1,50 @@ + ascii (si dispo) + $t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s); + if ($t !== false) { + $s = $t; + } + + $s = strtoupper($s); + $s = preg_replace('/[^A-Z0-9]+/', '-', $s) ?? $s; + $s = trim($s, '-'); + + return $s !== '' ? $s : 'SANS-NOM'; + } + + public static function nextAnimalCode(string $name, ?int $year = null): string + { + $year = $year ?? (int) date('Y'); + $slug = self::slugifyUpper($name); + + $db = DB::pdo(); + $stmt = $db->prepare( + 'SELECT internal_code FROM animals WHERE internal_code LIKE :p ORDER BY internal_code DESC LIMIT 1', + ); + $stmt->execute([':p' => $year . '-%']); + $last = (string) ($stmt->fetchColumn() ?: ''); + + $n = 1; + if ($last !== '') { + // format attendu: YYYY-NNN-... + $parts = explode('-', $last); + if (count($parts) >= 2 && ctype_digit($parts[1])) { + $n = (int) $parts[1] + 1; + } + } + + return sprintf('%d-%03d-%s', $year, $n, $slug); + } +} diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php new file mode 100644 index 0000000..98571a0 --- /dev/null +++ b/app/Services/ImageService.php @@ -0,0 +1,131 @@ + 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; + + public static function inspect(string $path, int $maxBytes = 20_000_000): array + { + if (!is_file($path) || filesize($path) > $maxBytes) { + throw new RuntimeException(t('service.image.missing_large')); + } + $info = @getimagesize($path); + $mime = (string) ($info['mime'] ?? ''); + if (!$info || !isset(self::ALLOWED[$mime])) { + throw new RuntimeException(t('service.image.format')); + } + if ((int) $info[0] * (int) $info[1] > 60_000_000) { + throw new RuntimeException(t('service.image.dimensions')); + } + return [ + 'mime' => $mime, + 'extension' => self::ALLOWED[$mime], + 'width' => (int) $info[0], + 'height' => (int) $info[1], + 'size' => (int) filesize($path), + ]; + } + + public static function storeUploaded( + string $tmp, + string $directory, + string $prefix = 'photo', + string $profile = 'photo', + ): array { + if (!is_uploaded_file($tmp)) { + throw new RuntimeException(t('service.image.invalid_upload')); + } + return self::store($tmp, $directory, $prefix, $profile, true); + } + + public static function storeLocal( + string $source, + string $directory, + string $prefix = 'photo', + string $profile = 'photo', + ): array { + return self::store($source, $directory, $prefix, $profile, false); + } + + private static function store( + string $tmp, + string $directory, + string $prefix, + string $profile, + bool $moveSource, + ): array { + $source = self::inspect($tmp); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException(t('service.image.directory_failed')); + } + $prefix = preg_replace('/[^a-z0-9_-]+/i', '_', trim($prefix)) ?: 'photo'; + $useWebp = $profile !== 'logo'; + $extension = $useWebp ? 'webp' : $source['extension']; + $filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension; + $target = rtrim($directory, '/') . '/' . $filename; + $stage = rtrim($directory, '/') . '/.' . $filename . '.part.' . $extension; + $optimized = false; + $binary = self::magickBinary(); + if ($binary !== null) { + $max = $profile === 'logo' ? '1800x1800>' : '2560x2560>'; + $quality = $profile === 'medical' ? '88' : '82'; + $command = [$binary, $tmp . '[0]', '-auto-orient', '-strip', '-resize', $max]; + if ($extension === 'png') { + $command = array_merge($command, ['-define', 'png:compression-level=9']); + } else { + $command = array_merge($command, ['-quality', (string) $quality]); + } + $command[] = $stage; + $process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (is_resource($process)) { + fclose($pipes[0]); + stream_get_contents($pipes[1]); + fclose($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[2]); + $code = proc_close($process); + $optimized = $code === 0 && is_file($stage) && filesize($stage) > 0 && @getimagesize($stage) !== false; + } + } + if ($optimized) { + if (!rename($stage, $target)) { + @unlink($stage); + throw new RuntimeException(t('service.image.optimized_failed')); + } + } else { + if (is_file($stage)) { + @unlink($stage); + } + $extension = $source['extension']; + $filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension; + $target = rtrim($directory, '/') . '/' . $filename; + $saved = $moveSource ? move_uploaded_file($tmp, $target) : copy($tmp, $target); + if (!$saved) { + throw new RuntimeException(t('service.image.save_failed')); + } + } + @chmod($target, 0644); + $stored = self::inspect($target); + return array_merge($stored, [ + 'filename' => $filename, + 'path' => $target, + 'original_size' => $source['size'], + 'optimized' => $optimized, + ]); + } + + private static function magickBinary(): ?string + { + foreach ( + ['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert'] + as $binary + ) { + if (is_executable($binary)) { + return $binary; + } + } + return null; + } +} diff --git a/app/Services/InstallationService.php b/app/Services/InstallationService.php new file mode 100644 index 0000000..71f2185 --- /dev/null +++ b/app/Services/InstallationService.php @@ -0,0 +1,137 @@ +='), 'required', 'PHP 8.1+', PHP_VERSION); + self::add( + $checks, + 'pdo', + extension_loaded('pdo'), + 'required', + 'PDO', + extension_loaded('pdo') ? 'Disponible' : 'Extension absente', + ); + self::add( + $checks, + 'sqlite', + extension_loaded('pdo_sqlite'), + 'required', + 'PDO SQLite', + extension_loaded('pdo_sqlite') ? 'Disponible' : 'Installez l’extension PHP SQLite', + ); + foreach ( + ['mbstring' => 'mbstring', 'json' => 'JSON', 'fileinfo' => 'Fileinfo', 'session' => 'Sessions'] + as $extension => $label + ) { + self::add( + $checks, + $extension, + extension_loaded($extension), + 'required', + $label, + extension_loaded($extension) ? 'Disponible' : 'Extension absente', + ); + } + self::add( + $checks, + 'zip', + class_exists('ZipArchive'), + 'recommended', + 'ZIP', + class_exists('ZipArchive') + ? 'Sauvegardes ZIP disponibles' + : 'Extension ZIP absente : sauvegardes indisponibles', + ); + self::add( + $checks, + 'curl', + extension_loaded('curl'), + 'recommended', + 'cURL', + extension_loaded('curl') + ? 'Connecteurs externes disponibles' + : 'Extension cURL absente : I-CAD et géocodage indisponibles', + ); + self::add( + $checks, + 'image', + extension_loaded('imagick') || extension_loaded('gd'), + 'recommended', + 'Traitement des images', + extension_loaded('imagick') + ? 'ImageMagick disponible' + : (extension_loaded('gd') + ? 'GD disponible' + : 'ImageMagick ou GD conseillé'), + ); + if ($withFilesystem) { + foreach (self::directories() as $key => $path) { + if (!is_dir($path)) { + @mkdir($path, 0775, true); + } + self::add( + $checks, + 'dir_' . $key, + is_dir($path) && is_writable($path), + 'required', + 'Écriture · ' . self::relative($path), + is_dir($path) && is_writable($path) ? 'Accessible en écriture' : 'Droits d’écriture insuffisants', + ); + } + } + return $checks; + } + public static function blockingRuntimeIssues(): array + { + return array_values( + array_filter( + self::runtimeChecks(true), + static fn(array $c): bool => $c['level'] === 'required' && !$c['ok'], + ), + ); + } + public static function canInstall(array $checks): bool + { + foreach ($checks as $check) { + if ($check['level'] === 'required' && !$check['ok']) { + return false; + } + } + return true; + } + public static function publicPath(): string + { + return dirname(__DIR__, 2) . '/public'; + } + public static function directories(): array + { + $root = dirname(__DIR__, 2); + return [ + 'data' => $root . '/data', + 'storage' => $root . '/storage', + 'logs' => $root . '/storage/logs', + 'backups' => $root . '/storage/backups', + 'media' => $root . '/public/media', + 'association' => $root . '/data/association', + ]; + } + private static function add( + array &$checks, + string $key, + bool $ok, + string $level, + string $label, + string $detail, + ): void { + $checks[] = ['key' => $key, 'ok' => $ok, 'level' => $level, 'label' => $label, 'detail' => $detail]; + } + private static function relative(string $path): string + { + return ltrim(str_replace(dirname(__DIR__, 2), '', $path), '/'); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 0000000..b50a101 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,211 @@ +query( + 'SELECT p.*,COALESCE(SUM(b.quantity),0) stock,(SELECT MIN(expires_on) FROM inventory_batches x WHERE x.product_id=p.id AND x.quantity>0 AND x.expires_on IS NOT NULL) next_expiry FROM inventory_products p LEFT JOIN inventory_batches b ON b.product_id=p.id GROUP BY p.id ORDER BY p.category,p.name COLLATE NOCASE', + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + public static function batches(): array + { + return DB::pdo() + ->query( + 'SELECT b.*,p.name product_name,p.unit,p.category,dc.name supplier_name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id LEFT JOIN directory_contacts dc ON dc.id=b.supplier_contact_id ORDER BY b.quantity>0 DESC,date(b.expires_on),p.name COLLATE NOCASE', + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + public static function movements(int $limit = 150): array + { + $limit = max(1, min(500, $limit)); + return DB::pdo() + ->query( + "SELECT m.*,p.name product_name,p.unit,b.batch_number,a.name animal_name,COALESCE(u.display_name,u.username) actor FROM inventory_movements m JOIN inventory_products p ON p.id=m.product_id LEFT JOIN inventory_batches b ON b.id=m.batch_id LEFT JOIN animals a ON a.id=m.animal_id LEFT JOIN users u ON u.id=m.created_by ORDER BY datetime(m.occurred_at) DESC,m.id DESC LIMIT $limit", + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + public static function saveProduct(array $data): int + { + $category = (string) ($data['category'] ?? ''); + $name = trim((string) ($data['name'] ?? '')); + $unit = trim((string) ($data['unit'] ?? '')); + if (!in_array($category, ['medication', 'dewormer', 'vaccine'], true) || $name === '' || $unit === '') { + throw new RuntimeException(t('inventory.invalid_product')); + } + $reference = (int) ($data['reference_id'] ?? 0) ?: null; + $db = DB::pdo(); + if (!$reference) { + $table = match ($category) { + 'medication' => 'ref_medications', + 'dewormer' => 'ref_dewormers', + 'vaccine' => 'ref_vaccines', + }; + $lookup = $db->prepare("SELECT id FROM $table WHERE name=? COLLATE NOCASE LIMIT 1"); + $lookup->execute([$name]); + $reference = (int) $lookup->fetchColumn() ?: null; + } + $minimum = max(0, (float) str_replace(',', '.', (string) ($data['minimum_quantity'] ?? 0))); + $s = $db->prepare( + 'INSERT INTO inventory_products(category,reference_id,name,unit,minimum_quantity) VALUES(?,?,?,?,?)', + ); + $s->execute([$category, $reference, mb_substr($name, 0, 150), mb_substr($unit, 0, 40), $minimum]); + return (int) $db->lastInsertId(); + } + public static function receive(array $data): int + { + $product = (int) ($data['product_id'] ?? 0); + $quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0)); + if (!$product || $quantity <= 0) { + throw new RuntimeException(t('inventory.invalid_quantity')); + } + $expiry = trim((string) ($data['expires_on'] ?? '')); + if ($expiry !== '' && !self::date($expiry)) { + throw new RuntimeException(t('inventory.invalid_date')); + } + $cost = trim((string) ($data['unit_cost'] ?? '')); + $cents = $cost === '' ? null : (int) round((float) str_replace(',', '.', $cost) * 100); + $db = DB::pdo(); + $db->beginTransaction(); + try { + $s = $db->prepare( + 'INSERT INTO inventory_batches(product_id,batch_number,expires_on,supplier_contact_id,unit_cost_cents,quantity,notes) VALUES(?,?,?,?,?,?,?)', + ); + $s->execute([ + $product, + trim((string) ($data['batch_number'] ?? '')) ?: null, + $expiry ?: null, + (int) ($data['supplier_contact_id'] ?? 0) ?: null, + $cents, + $quantity, + trim((string) ($data['notes'] ?? '')) ?: null, + ]); + $batch = (int) $db->lastInsertId(); + $db->prepare( + "INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,reason,created_by) VALUES(?,?,'entry',?,?,?)", + )->execute([$product, $batch, $quantity, t('inventory.receipt'), Auth::id()]); + $db->commit(); + return $batch; + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } + } + public static function move(array $data): void + { + $batch = (int) ($data['batch_id'] ?? 0); + $type = (string) ($data['movement_type'] ?? ''); + $quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0)); + if (!$batch || !in_array($type, ['administration', 'loss', 'correction'], true) || $quantity <= 0) { + throw new RuntimeException(t('inventory.invalid_movement')); + } + $db = DB::pdo(); + $s = $db->prepare( + 'SELECT b.*,p.id product_id FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?', + ); + $s->execute([$batch]); + $row = $s->fetch(PDO::FETCH_ASSOC); + if (!$row) { + throw new RuntimeException(t('inventory.batch_not_found')); + } + $delta = + $type === 'correction' + ? (float) str_replace(',', '.', (string) ($data['signed_quantity'] ?? 0)) + : -$quantity; + if ($delta == 0 || (float) $row['quantity'] + $delta < 0) { + throw new RuntimeException(t('inventory.insufficient_stock')); + } + $db->beginTransaction(); + try { + $db->prepare('UPDATE inventory_batches SET quantity=quantity+? WHERE id=?')->execute([$delta, $batch]); + $db->prepare( + 'INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,?,?,?,?,?)', + )->execute([ + (int) $row['product_id'], + $batch, + $type, + $delta, + (int) ($data['animal_id'] ?? 0) ?: null, + trim((string) ($data['reason'] ?? '')) ?: null, + Auth::id(), + ]); + $db->commit(); + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } + } + public static function alerts(): array + { + $low = []; + $expired = []; + foreach (self::products() as $p) { + if ((float) $p['stock'] <= (float) $p['minimum_quantity']) { + $low[] = $p; + } + } + foreach (self::batches() as $b) { + if ( + (float) $b['quantity'] > 0 && + $b['expires_on'] && + $b['expires_on'] <= date('Y-m-d', strtotime('+30 days')) + ) { + $expired[] = $b; + } + } + return compact('low', 'expired'); + } + public static function consumeBatch(int $batchId, float $quantity, ?int $animalId, string $reason): void + { + if ($batchId <= 0 || $quantity <= 0) { + return; + } + $db = DB::pdo(); + $s = $db->prepare( + 'SELECT b.product_id,b.quantity,b.unit_cost_cents,b.supplier_contact_id,p.name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?', + ); + $s->execute([$batchId]); + $batch = $s->fetch(PDO::FETCH_ASSOC); + if (!$batch) { + throw new RuntimeException(t('inventory.batch_not_found')); + } + if ((float) $batch['quantity'] < $quantity) { + throw new RuntimeException(t('inventory.insufficient_stock')); + } + $u = $db->prepare('UPDATE inventory_batches SET quantity=quantity-? WHERE id=? AND quantity>=?'); + $u->execute([$quantity, $batchId, $quantity]); + if ($u->rowCount() !== 1) { + throw new RuntimeException(t('inventory.insufficient_stock')); + } + $movement = $db->prepare( + "INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,'administration',?,?,?,?)", + ); + $movement->execute([(int) $batch['product_id'], $batchId, -$quantity, $animalId, $reason, Auth::id()]); + $movementId = (int) $db->lastInsertId(); + if ($animalId && !is_null($batch['unit_cost_cents'])) { + $unit = (int) $batch['unit_cost_cents']; + $db->prepare( + "INSERT INTO animal_expenses(animal_id,clinic_contact_id,source_type,source_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?, 'inventory',?,?,date('now'),?,?,0,?,?,?)", + )->execute([ + $animalId, + (int) $batch['supplier_contact_id'] ?: null, + $movementId, + (string) $batch['name'], + $quantity, + $unit, + (int) round($unit * $quantity), + $reason, + Auth::id(), + ]); + } + } + private static function date(string $v): bool + { + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $v); + return $d && $d->format('Y-m-d') === $v; + } +} diff --git a/app/Services/LabReferenceService.php b/app/Services/LabReferenceService.php new file mode 100644 index 0000000..99fb2ca --- /dev/null +++ b/app/Services/LabReferenceService.php @@ -0,0 +1,36 @@ +query( + 'SELECT * FROM lab_reference_ranges ORDER BY analyzer_name COLLATE NOCASE,species_code,sort_order,parameter_code COLLATE NOCASE', + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + public static function forAnimal(string $species): array + { + $species = SpeciesService::normalize($species); + $s = DB::pdo()->prepare( + 'SELECT analyzer_code,analyzer_name,parameter_code,parameter_name,unit,reference_min,reference_max FROM lab_reference_ranges WHERE species_code=? AND active=1 ORDER BY analyzer_name COLLATE NOCASE,sort_order,parameter_code COLLATE NOCASE', + ); + $s->execute([$species]); + $out = []; + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) { + $key = $r['analyzer_code']; + $out[$key]['label'] = $r['analyzer_name']; + $out[$key]['parameters'][] = [ + 'code' => $r['parameter_code'], + 'name' => $r['parameter_name'], + 'unit' => $r['unit'], + 'min' => $r['reference_min'], + 'max' => $r['reference_max'], + ]; + } + return $out; + } +} diff --git a/app/Services/LocationHistoryService.php b/app/Services/LocationHistoryService.php new file mode 100644 index 0000000..912a588 --- /dev/null +++ b/app/Services/LocationHistoryService.php @@ -0,0 +1,115 @@ +prepare('INSERT INTO animal_location_history ( + animal_id,from_status,from_refuge_room,from_care_box_key,from_address, + to_status,to_refuge_room,to_care_box_key,to_address,reason,source,moved_at,user_id + ) VALUES ( + :animal,:from_status,:from_room,:from_box,:from_address, + :to_status,:to_room,:to_box,:to_address,:reason,:source,:moved_at,:user + )'); + $stmt->execute([ + ':animal' => $animalId, + ':from_status' => $from['status'], + ':from_room' => $from['refuge_room'], + ':from_box' => $from['care_box_key'], + ':from_address' => $from['current_address'], + ':to_status' => $to['status'], + ':to_room' => $to['refuge_room'], + ':to_box' => $to['care_box_key'], + ':to_address' => $to['current_address'], + ':reason' => ($reason = trim((string) $reason)) !== '' ? $reason : null, + ':source' => $source, + ':moved_at' => $movedAt ?: date('Y-m-d H:i:s'), + ':user' => Auth::id(), + ]); + return true; + } + + public static function label(array $row, string $prefix): string + { + $status = trim((string) ($row[$prefix . '_status'] ?? '')); + $room = trim((string) ($row[$prefix . '_refuge_room'] ?? '')); + $box = trim((string) ($row[$prefix . '_care_box_key'] ?? '')); + $address = trim((string) ($row[$prefix . '_address'] ?? '')); + + $label = match ($status) { + 'refuge' => $room !== '' ? 'Refuge · ' . self::roomName($room) : 'Refuge · Salle non attribuée', + 'soin' => $room !== '' ? self::roomName($room) : 'Infirmerie', + 'quarantaine' => $room !== '' ? self::roomName($room) : 'Salle de quarantaine', + 'fa' => 'Famille d’accueil', + 'fa_permanente' => 'FA permanente', + 'reserve', 'réservé' => 'Réservé', + 'adopte', 'adopté' => 'Adopté', + 'hospitalise', 'hospitalisé' => 'Hospitalisé', + 'isolation' => 'Isolation', + 'decede', 'décédé' => 'Décédé', + 'enfui', 'fugue' => 'Enfui', + '' => 'Emplacement antérieur inconnu', + default => ucfirst(str_replace('_', ' ', $status)), + }; + if ($box !== '' && in_array($status, ['soin', 'quarantaine'], true)) { + $label .= ' · Box ' . self::boxLabel($box); + } + if ( + $address !== '' && + in_array($status, ['fa', 'fa_permanente', 'adopte', 'adopté', 'hospitalise', 'hospitalisé'], true) + ) { + $label .= ' · ' . $address; + } + return $label; + } + + private static function boxLabel(string $box): string + { + return match ($box) { + 'top_a' => 'haut gauche', + 'top_b' => 'haut centre', + 'top_c' => 'haut droite', + 'top_ab' => 'haut gauche double', + 'top_bc' => 'haut droite double', + 'top_all' => 'haut complet', + 'bottom_a' => 'bas gauche', + 'bottom_b' => 'bas droite', + 'bottom_all' => 'bas complet', + default => str_replace('_', ' ', $box), + }; + } + + private static function roomName(string $code): string + { + static $rooms = null; + $rooms ??= ShelterRoomService::byCode(false); + return (string) ($rooms[$code]['name'] ?? $code); + } +} diff --git a/app/Services/Migrations.php b/app/Services/Migrations.php new file mode 100644 index 0000000..b026bc1 --- /dev/null +++ b/app/Services/Migrations.php @@ -0,0 +1,62 @@ +exec('CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);'); + + $files = glob(rtrim($migrationsDir, '/') . '/*.sql') ?: []; + sort($files); + + foreach ($files as $file) { + $name = basename($file); + + $stmt = $db->prepare('SELECT 1 FROM _migrations WHERE name = :n'); + $stmt->execute([':n' => $name]); + if ($stmt->fetchColumn()) { + continue; + } + + $sql = file_get_contents($file); + if ($sql === false) { + throw new RuntimeException("Cannot read migration: $name"); + } + + // Les toutes premières installations utilisaient déjà deleted_at avant + // que cette colonne ne soit correctement inscrite dans une migration. + if ($name === '025_animal_archive_and_trash.sql') { + $columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name'); + if (!in_array('deleted_at', $columns, true)) { + $db->exec('ALTER TABLE animals ADD COLUMN deleted_at TEXT'); + } + } + + // Répare les bases créées par une candidate rc.3 incomplète, dans + // laquelle les médias privés étaient utilisés avant d'être migrés. + if ($name === '089_media_schema_repair.sql') { + $columns = array_column( + $db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC), + 'name', + ); + if (!in_array('is_public', $columns, true)) { + $db->exec('ALTER TABLE animal_photos ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1'); + } + } + + $db->beginTransaction(); + try { + $db->exec($sql); + $ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))"); + $ins->execute([':n' => $name]); + $db->commit(); + } catch (Throwable $e) { + $db->rollBack(); + throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e); + } + } + } +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..b1ca82a --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,142 @@ +date(d.administered_on))", + '/dashboard', + ], + ]; + foreach ($queries as [$key, $severity, $label, $sql, $url]) { + try { + $count = (int) $db->query($sql)->fetchColumn(); + if ($count) { + $alerts[] = compact('key', 'severity', 'label', 'count', 'url'); + } + } catch (Throwable) { + } + } + if (class_exists(InventoryService::class)) { + try { + $stock = InventoryService::alerts(); + if ($stock['low']) { + $alerts[] = [ + 'key' => 'inventory-low', + 'severity' => 'warning', + 'label' => t('notification.inventory_low'), + 'count' => count($stock['low']), + 'url' => '/settings/inventory', + ]; + } + if ($stock['expired']) { + $alerts[] = [ + 'key' => 'inventory-expiry', + 'severity' => 'danger', + 'label' => t('notification.inventory_expiry'), + 'count' => count($stock['expired']), + 'url' => '/settings/inventory', + ]; + } + } catch (Throwable) { + } + } + if (class_exists(AgendaService::class)) { + try { + $due = AgendaService::due(); + if ($due) { + $alerts[] = [ + 'key' => 'agenda', + 'severity' => 'warning', + 'label' => t('notification.agenda_due'), + 'count' => count($due), + 'url' => '/agenda', + ]; + } + } catch (Throwable) { + } + } + $backups = BackupService::list(); + $days = 999; + if ( + $backups && + ($date = DateTimeImmutable::createFromFormat( + 'd/m/Y H:i:s', + (string) $backups[0]['date'], + new DateTimeZone('Europe/Paris'), + )) + ) { + $days = (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a'); + } + if ($days > 7) { + $alerts[] = [ + 'key' => 'backup', + 'severity' => $days > 30 ? 'danger' : 'warning', + 'label' => t('notification.backup_old'), + 'count' => $days, + 'url' => '/settings/backups', + ]; + } + return $alerts; + } + public static function send(bool $force = false): array + { + if (AppSettings::get('notification_email_enabled') !== '1' && !$force) { + return ['sent' => false, 'reason' => 'disabled']; + } + $recipient = AppSettings::get('notification_email_recipient') ?: AppSettings::get('association_email'); + if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) { + return ['sent' => false, 'reason' => 'recipient']; + } + $frequency = AppSettings::get('notification_email_frequency'); + $last = AppSettings::get('notification_last_sent_at'); + if (!$force && $last !== '') { + $elapsed = time() - (strtotime($last) ?: 0); + $minimum = $frequency === 'daily' ? 20 * 3600 : 6 * 86400; + if ($elapsed < $minimum) { + return ['sent' => false, 'reason' => 'not_due']; + } + } + $alerts = self::alerts(); + $association = AppSettings::get('association_name') ?: 'Globinours'; + $subject = '[' . $association . '] ' . t('notification.email_subject'); + $lines = [t('notification.email_intro', ['association' => $association]), '']; + if (!$alerts) { + $lines[] = t('notification.no_alert'); + } else { + foreach ($alerts as $a) { + $lines[] = '- ' . $a['label'] . ' : ' . $a['count']; + } + } + $lines[] = ''; + $lines[] = t('notification.email_footer'); + $sent = @mail($recipient, $subject, implode("\n", $lines), 'Content-Type: text/plain; charset=UTF-8'); + if ($sent) { + AppSettings::save(['notification_last_sent_at' => date('Y-m-d H:i:s')], Auth::id()); + } + return ['sent' => $sent, 'reason' => $sent ? 'sent' : 'mail_failed', 'count' => count($alerts)]; + } +} diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php new file mode 100644 index 0000000..9dd5577 --- /dev/null +++ b/app/Services/PermissionService.php @@ -0,0 +1,258 @@ + 'Administrateur', + 'responsable' => 'Responsable', + 'benevole' => 'Bénévole', + 'lecture' => 'Lecture seule', + ]; + public const MODULES = [ + 'dashboard' => ['Dashboard', 'Priorités et situation quotidienne'], + 'animals' => ['Animaux', 'Fiches, portées, liens, médias et adoptions'], + 'medical' => ['Médical', 'Notes, traitements, vaccinations et décès'], + 'care' => ['Tournées', 'Passages tablette et configuration des box'], + 'directory' => ['Annuaire', 'Personnes, structures et coordonnées'], + 'agenda' => ['Agenda', 'Rendez-vous, transports, visites et tâches partagées'], + 'statistics' => ['Statistiques', 'Analyses annuelles et tendances'], + 'administrative' => ['Administratif', 'Registre, documents et exports'], + 'accounting' => ['Comptabilité', 'Factures, avoirs et règlements'], + 'grants' => ['Subventions', 'Bilans, demandes et justificatifs'], + ]; + public static function roleLabel(string $key): string + { + return t('permission.role.' . $key, [], self::ROLES[$key] ?? $key); + } + public static function moduleLabel(string $key): string + { + return t('permission.module.' . $key, [], self::MODULES[$key][0] ?? $key); + } + public static function moduleDescription(string $key): string + { + return t('permission.module_help.' . $key, [], self::MODULES[$key][1] ?? ''); + } + + private static array $cache = []; + public static function can(string $module, string $action = 'view', ?string $role = null): bool + { + $role ??= (string) (Auth::user()['role'] ?? ''); + if ($role === 'admin') { + return true; + } + if (!isset(self::MODULES[$module], self::ROLES[$role])) { + return false; + } + if (!isset(self::$cache[$role])) { + $s = DB::pdo()->prepare('SELECT module,can_view,can_edit FROM role_permissions WHERE role=?'); + $s->execute([$role]); + self::$cache[$role] = []; + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) { + self::$cache[$role][$row['module']] = [ + 'view' => (bool) $row['can_view'], + 'edit' => (bool) $row['can_edit'], + ]; + } + } + return (bool) (self::$cache[$role][$module][$action === 'edit' ? 'edit' : 'view'] ?? false); + } + public static function matrix(): array + { + $matrix = []; + foreach (array_keys(self::ROLES) as $role) { + foreach (array_keys(self::MODULES) as $module) { + $matrix[$role][$module] = [ + 'view' => self::can($module, 'view', $role), + 'edit' => self::can($module, 'edit', $role), + ]; + } + } + return $matrix; + } + public static function moduleForPath(string $path): ?string + { + if ($path === '/') { + return 'dashboard'; + } + if (str_starts_with($path, '/animal/documents')) { + return 'medical'; + } + if ( + str_starts_with($path, '/animals') || + str_starts_with($path, '/animal') || + str_starts_with($path, '/litter') || + str_starts_with($path, '/bonded') + ) { + foreach ( + [ + '/animal/add-medical', + '/animal/medical-status', + '/animal/add-treatment', + '/animal/add-vaccine', + '/animal/death', + '/animal/prescription', + '/animal/lab-report', + '/animal/medical-document', + '/animal/surgery', + ] + as $medical + ) { + if (str_starts_with($path, $medical)) { + return 'medical'; + } + } + return 'animals'; + } + if (str_starts_with($path, '/dashboard')) { + return 'dashboard'; + } + if (str_starts_with($path, '/care-round')) { + return 'care'; + } + if (str_starts_with($path, '/directory')) { + return 'directory'; + } + if (str_starts_with($path, '/agenda')) { + return 'agenda'; + } + if (str_starts_with($path, '/statistics')) { + return 'statistics'; + } + if (str_starts_with($path, '/accounting')) { + return 'accounting'; + } + if (str_starts_with($path, '/admin/grants')) { + return 'grants'; + } + if (str_starts_with($path, '/admin') || str_starts_with($path, '/documents')) { + return 'administrative'; + } + return null; + } + public static function enforce(string $path, string $method): void + { + if ( + str_starts_with($path, '/settings') || + str_starts_with($path, '/dev/') || + in_array($path, ['/logout', '/evolutions'], true) + ) { + return; + } + $module = self::moduleForPath($path); + if (!$module) { + return; + } + $action = strtoupper($method) === 'POST' || self::isEditPage($path) ? 'edit' : 'view'; + $allowed = self::can($module, $action) && ($module !== 'medical' || self::can('animals', 'view')); + if (!$allowed) { + AuditService::log('authorization_denied', $path, 'Accès refusé au module ' . $module, null, null, [ + 'module' => $module, + 'requested_action' => $action, + ]); + http_response_code(403); + if (function_exists('render')) { + render('access_denied.php', [ + 'title' => t('access.denied'), + 'deniedAction' => $action, + 'deniedModule' => self::moduleLabel($module), + ]); + } else { + echo t('access.denied'); + } + exit(); + } + } + public static function firstAllowedPath(): string + { + foreach ( + [ + 'dashboard' => '/dashboard', + 'animals' => '/animals', + 'care' => '/care-round', + 'directory' => '/directory', + 'agenda' => '/agenda', + 'statistics' => '/statistics', + 'accounting' => '/accounting', + 'administrative' => '/admin/register', + 'grants' => '/admin/grants', + ] + as $module => $path + ) { + if (self::can($module)) { + return $path; + } + } + return '/evolutions'; + } + public static function canOpenPath(string $path): bool + { + $module = self::moduleForPath($path); + return $module === null || + (self::can($module, self::isEditPage($path) ? 'edit' : 'view') && + ($module !== 'medical' || self::can('animals'))); + } + public static function currentPermissions(): array + { + $out = []; + foreach (self::MODULES as $module => $unused) { + $out[$module] = ['view' => self::can($module), 'edit' => self::can($module, 'edit')]; + } + return $out; + } + private static function isEditPage(string $path): bool + { + return in_array( + $path, + [ + '/animal/new', + '/animal/edit', + '/animal/death', + '/animal/surgery/new', + '/litter/new', + '/litter/add-existing', + '/litter/add-existing/save', + '/bonded/new', + '/directory/new', + '/directory/edit', + '/care-round/setup', + ], + true, + ); + } + public static function save(array $input, ?int $userId): void + { + $db = DB::pdo(); + $stmt = $db->prepare( + "INSERT INTO role_permissions(role,module,can_view,can_edit,updated_by,updated_at) VALUES(:role,:module,:view,:edit,:user,datetime('now')) ON CONFLICT(role,module) DO UPDATE SET can_view=excluded.can_view,can_edit=excluded.can_edit,updated_by=excluded.updated_by,updated_at=excluded.updated_at", + ); + $db->beginTransaction(); + try { + foreach (self::ROLES as $role => $unused) { + if ($role === 'admin') { + continue; + } + foreach (self::MODULES as $module => $unusedModule) { + $view = isset($input[$role][$module]['view']) ? 1 : 0; + if ($module === 'medical' && !isset($input[$role]['animals']['view'])) { + $view = 0; + } + $edit = $view && isset($input[$role][$module]['edit']) ? 1 : 0; + $stmt->execute([ + ':role' => $role, + ':module' => $module, + ':view' => $view, + ':edit' => $edit, + ':user' => $userId, + ]); + } + } + $db->commit(); + self::$cache = []; + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } + } +} diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php new file mode 100644 index 0000000..a556caa --- /dev/null +++ b/app/Services/PricingService.php @@ -0,0 +1,78 @@ + 'Consultation', + 'surgery' => 'Chirurgie', + 'identification' => 'Identification', + 'test' => 'Test / analyse', + 'vaccine' => 'Vaccin', + 'euthanasia' => 'Euthanasie', + 'cremation' => 'Crémation', + 'medication' => 'Médicament', + 'dewormer' => 'Vermifuge', + 'other' => 'Autre', + ]; + public const GROUPS = [ + 'act' => 'Acte', + 'surgery_discount' => 'Actes chirurgicaux', + 'medication' => 'Médicament hors antibiotique', + 'antibiotic' => 'Antibiotique', + 'ape_api' => 'APE / API', + 'analysis' => 'Analyse', + 'fixed' => 'Tarif fixe', + 'none' => 'Sans remise', + ]; + public static function categoryLabel(string $key): string + { + return t('pricing.category.' . $key, [], self::CATEGORIES[$key] ?? $key); + } + public static function groupLabel(string $key): string + { + return t('pricing.group.' . $key, [], self::GROUPS[$key] ?? $key); + } + public static function cents(string $v): int + { + return max(0, (int) round((float) str_replace(',', '.', trim($v)) * 100)); + } + public static function tariffs(bool $active = false): array + { + $w = $active ? 'WHERE ct.active=1 AND dc.deleted_at IS NULL' : ''; + return DB::pdo() + ->query( + "SELECT ct.*,dc.name clinic_name,COALESCE(dr.percent,0) discount_percent,ROUND(ct.amount_cents*(1-COALESCE(dr.percent,0)/100.0)) effective_cents FROM clinic_tariffs ct JOIN directory_contacts dc ON dc.id=ct.clinic_contact_id LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group $w ORDER BY dc.name COLLATE NOCASE,ct.active DESC,ct.label COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + public static function record(PDO $db, int $animal, int $tariff, float $qty, string $date, string $notes = ''): int + { + $s = $db->prepare( + 'SELECT ct.*,COALESCE(dr.percent,0) discount FROM clinic_tariffs ct LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group WHERE ct.id=? AND ct.active=1', + ); + $s->execute([$tariff]); + $t = $s->fetch(PDO::FETCH_ASSOC); + if (!$t) { + throw new RuntimeException(t('error.tariff_not_found')); + } + $qty = max(0.01, $qty); + $total = (int) round($t['amount_cents'] * $qty * (1 - (float) $t['discount'] / 100)); + $db->prepare( + 'INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + )->execute([ + $animal, + $t['clinic_contact_id'], + $tariff, + $t['label'], + $date, + $qty, + $t['amount_cents'], + $t['discount'], + $total, + $notes ?: null, + Auth::id(), + ]); + return $total; + } +} diff --git a/app/Services/PrivacyService.php b/app/Services/PrivacyService.php new file mode 100644 index 0000000..698ed42 --- /dev/null +++ b/app/Services/PrivacyService.php @@ -0,0 +1,188 @@ +prepare( + 'INSERT INTO private_file_access_log(user_id,resource_type,resource_id,animal_id,action,original_name,ip_address,user_agent) VALUES(?,?,?,?,?,?,?,?)', + ) + ->execute([ + Auth::id(), + $type, + $id, + $animalId, + $action, + mb_substr($name, 0, 240), + $_SERVER['REMOTE_ADDR'] ?? null, + mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null, + ]); + } + public static function accessLog(int $limit = 500): array + { + return DB::pdo() + ->query( + 'SELECT l.*,COALESCE(u.display_name,u.username) actor,a.name animal_name FROM private_file_access_log l LEFT JOIN users u ON u.id=l.user_id LEFT JOIN animals a ON a.id=l.animal_id ORDER BY l.id DESC LIMIT ' . + max(1, min(2000, $limit)), + ) + ->fetchAll(PDO::FETCH_ASSOC); + } + public static function retentionReview(int $years): array + { + $rows = DB::pdo() + ->query( + "SELECT dc.id,dc.name,dc.email,dc.phone,dc.updated_at,group_concat(DISTINCT r.role) roles,MAX(COALESCE(ad.adoption_date,ap.event_date,dc.updated_at)) last_activity,(SELECT COUNT(*) FROM animal_placements active JOIN animals a ON a.id=active.animal_id WHERE active.contact_id=dc.id AND active.event_type='foster' AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status)='fa') active_links FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN adoptions ad ON ad.adopter_contact_id=dc.id LEFT JOIN animal_placements ap ON ap.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='person' AND dc.anonymized_at IS NULL GROUP BY dc.id ORDER BY date(last_activity),dc.name COLLATE NOCASE", + ) + ->fetchAll(PDO::FETCH_ASSOC); + $out = []; + foreach ($rows as $row) { + $roles = array_filter(explode(',', (string) $row['roles'])); + $limits = []; + foreach ($roles as $role) { + $limits[] = match ($role) { + 'adoptant' => AppSettings::int('privacy_retention_adopter_years', 1, 30), + 'fa' => AppSettings::int('privacy_retention_foster_years', 1, 30), + 'benevole' => AppSettings::int('privacy_retention_volunteer_years', 1, 30), + default => $years, + }; + } + $limit = $limits ? max($limits) : $years; + $last = strtotime((string) $row['last_activity']) ?: time(); + if ($last > strtotime('-' . $limit . ' years')) { + continue; + } + $row['policy_years'] = $limit; + $row['blocked'] = (int) $row['active_links'] > 0; + $out[] = $row; + } + return $out; + } + public static function exportContact(int $id): string + { + $db = DB::pdo(); + $s = $db->prepare( + "SELECT dc.*,group_concat(r.role,',') roles,org.name organization_name FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN directory_contacts org ON org.id=dc.organization_id WHERE dc.id=? AND dc.deleted_at IS NULL GROUP BY dc.id", + ); + $s->execute([$id]); + $contact = $s->fetch(PDO::FETCH_ASSOC); + if (!$contact) { + throw new RuntimeException(t('privacy.contact_not_found')); + } + $queries = [ + 'adoptions' => + 'SELECT a.internal_code,a.name animal,ad.adoption_date,ad.notes FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE ad.adopter_contact_id=? ORDER BY ad.adoption_date', + 'placements' => + 'SELECT a.internal_code,a.name animal,ap.event_type,ap.event_date,ap.reason,ap.notes FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=? ORDER BY ap.event_date,ap.id', + 'deposited_animals' => + 'SELECT internal_code,name,intake_date,intake_type,intake_reason,intake_circumstances FROM animals WHERE depositor_contact_id=? ORDER BY intake_date', + ]; + $payload = ['generated_at' => new DateTimeImmutable()->format(DATE_ATOM), 'contact' => $contact]; + foreach ($queries as $key => $sql) { + $q = $db->prepare($sql); + $q->execute([$id]); + $payload[$key] = $q->fetchAll(PDO::FETCH_ASSOC); + } + $tmp = tempnam(sys_get_temp_dir(), 'globinours-contact-'); + if ($tmp === false) { + throw new RuntimeException(t('privacy.export_failed')); + } + @unlink($tmp); + $path = $tmp . '.zip'; + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException(t('privacy.export_failed')); + } + $zip->addFromString( + 'contact.json', + json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ); + foreach (['adoptions', 'placements', 'deposited_animals'] as $key) { + $zip->addFromString($key . '.csv', self::csv($payload[$key])); + } + $zip->addFromString( + 'README.txt', + "Export de portabilité Globinours\nGénéré le " . + date('d/m/Y H:i') . + "\nDonnées à conserver dans un emplacement sécurisé.\n", + ); + $zip->close(); + return $path; + } + public static function anonymize(int $id, string $reason): void + { + $reason = trim($reason); + if ($reason === '') { + throw new RuntimeException(t('privacy.reason_required')); + } + $db = DB::pdo(); + $s = $db->prepare( + "SELECT * FROM directory_contacts WHERE id=? AND deleted_at IS NULL AND kind='person' AND anonymized_at IS NULL", + ); + $s->execute([$id]); + $contact = $s->fetch(PDO::FETCH_ASSOC); + if (!$contact) { + throw new RuntimeException(t('privacy.contact_not_found')); + } + $backup = BackupService::create('avant-anonymisation-contact-' . $id); + $label = t('privacy.anonymous_contact') . ' #' . $id; + $db->beginTransaction(); + try { + $db->prepare( + "UPDATE directory_contacts SET name=?,phone=NULL,email=NULL,address=NULL,postal_code=NULL,city=NULL,country='France',organization_id=NULL,notes=NULL,anonymized_at=datetime('now'),anonymization_reason=?,updated_at=datetime('now') WHERE id=?", + )->execute([$label, $reason, $id]); + $db->prepare( + 'UPDATE adoptions SET adopter_name=?,adopter_phone=NULL,adopter_email=NULL,adopter_address=NULL,adopter_postal_code=NULL,adopter_city=NULL WHERE adopter_contact_id=?', + )->execute([$label, $id]); + foreach ([(string) $contact['name']] as $old) { + $db->prepare( + 'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))', + )->execute([$label, $old]); + } + $db->commit(); + AuditService::log( + 'contact_anonymized', + '/settings/privacy/anonymize', + 'Contact anonymisé', + 'contact', + $id, + ['reason' => $reason, 'backup' => $backup['name'] ?? null], + ); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } + } + public static function maintenance(): array + { + $months = AppSettings::int('privacy_access_log_months', 1, 120); + $s = DB::pdo()->prepare("DELETE FROM private_file_access_log WHERE datetime(accessed_at)execute(['-' . $months . ' months']); + return ['access_logs_removed' => $s->rowCount(), 'retention_months' => $months]; + } + private static function csv(array $rows): string + { + if (!$rows) { + return "\xEF\xBB\xBF"; + } + $stream = fopen('php://temp', 'r+'); + fwrite($stream, "\xEF\xBB\xBF"); + fputcsv($stream, array_keys($rows[0]), ';'); + foreach ($rows as $row) { + fputcsv($stream, array_values($row), ';'); + } + rewind($stream); + $out = stream_get_contents($stream); + fclose($stream); + return (string) $out; + } +} diff --git a/app/Services/PrivateMediaService.php b/app/Services/PrivateMediaService.php new file mode 100644 index 0000000..8881194 --- /dev/null +++ b/app/Services/PrivateMediaService.php @@ -0,0 +1,148 @@ +query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0') + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($private as $row) { + self::moveIfPresent( + self::publicDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']), + self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']), + ); + } + $medical = $db->query('SELECT mp.animal_id,mp.filename FROM medical_photos mp')->fetchAll(PDO::FETCH_ASSOC); + foreach ($medical as $row) { + self::moveIfPresent( + self::publicDir((int) $row['animal_id']) . '/meds/' . basename((string) $row['filename']), + self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']), + ); + } + } + + private static function moveIfPresent(string $source, string $target): void + { + if (!is_file($source) || is_file($target)) { + return; + } + self::ensure(dirname($target)); + if (rename($source, $target)) { + @chmod($target, 0600); + } + } + + public static function send(): void + { + $kind = (string) ($_GET['kind'] ?? 'photo'); + $id = (int) ($_GET['id'] ?? 0); + if ($id <= 0) { + http_response_code(404); + return; + } + $db = DB::pdo(); + if ($kind === 'medical') { + if (!PermissionService::can('medical', 'view')) { + http_response_code(403); + return; + } + $s = $db->prepare( + 'SELECT mp.id,mp.animal_id,mp.filename FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id WHERE mp.id=? AND a.deleted_at IS NULL', + ); + $s->execute([$id]); + $row = $s->fetch(PDO::FETCH_ASSOC); + $path = $row ? self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : ''; + } else { + if (!PermissionService::can('animals', 'view')) { + http_response_code(403); + return; + } + $s = $db->prepare( + 'SELECT ap.id,ap.animal_id,ap.filename,ap.care_round_id FROM animal_photos ap JOIN animals a ON a.id=ap.animal_id WHERE ap.id=? AND ap.is_public=0 AND a.deleted_at IS NULL', + ); + $s->execute([$id]); + $row = $s->fetch(PDO::FETCH_ASSOC); + if ($row && !empty($row['care_round_id']) && !PermissionService::can('medical', 'view')) { + http_response_code(403); + return; + } + $path = $row ? self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : ''; + } + if (empty($row) || !is_file($path)) { + http_response_code(404); + return; + } + $mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream'; + PrivacyService::logAccess( + 'private_' . $kind, + $id, + (int) $row['animal_id'], + basename((string) $row['filename']), + ); + AuditService::log( + 'private_file_viewed', + '/private-media', + 'Média privé consulté', + 'animal', + (int) $row['animal_id'], + ['kind' => $kind, 'media_id' => $id], + ); + header('Content-Type: ' . $mime); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: private,no-store'); + header('X-Content-Type-Options: nosniff'); + readfile($path); + } +} diff --git a/app/Services/SecurityService.php b/app/Services/SecurityService.php new file mode 100644 index 0000000..69c0ce5 --- /dev/null +++ b/app/Services/SecurityService.php @@ -0,0 +1,97 @@ +getMessage() . + ' in ' . + $e->getFile() . + ':' . + $e->getLine(), + ); + return t('error.unexpected_reference', ['reference' => $reference]); + } + public static function hardenRuntimeFiles(): void + { + $root = dirname(__DIR__, 2); + $marker = $root . '/storage/.security-hardened'; + if (is_file($marker) && filemtime($marker) > time() - 86400) { + return; + } + foreach ( + [ + $root . '/data', + $root . '/storage', + $root . '/data/private-media', + $root . '/data/medical-documents', + $root . '/data/grants', + $root . '/storage/backups', + $root . '/storage/logs', + $root . '/storage/asm3-imports', + ] + as $dir + ) { + if (is_dir($dir)) { + @chmod($dir, 0700); + } + } + foreach ( + [ + $root . '/.env', + $root . '/data/refuge.sqlite', + $root . '/data/refuge.sqlite-wal', + $root . '/data/refuge.sqlite-shm', + ] + as $file + ) { + if (is_file($file)) { + @chmod($file, 0600); + } + } + foreach ( + [ + 'storage/backups/*', + 'storage/logs/*', + 'storage/reports/*', + 'data/private-media/*', + 'data/medical-documents/*', + 'data/grants/*', + ] + as $pattern + ) { + foreach (glob($root . '/' . $pattern) ?: [] as $file) { + self::hardenTree($file); + } + } + @touch($marker); + @chmod($marker, 0600); + } + private static function hardenTree(string $path): void + { + if (is_file($path)) { + @chmod($path, 0600); + return; + } + if (!is_dir($path)) { + return; + } + @chmod($path, 0700); + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($it as $item) { + @chmod($item->getPathname(), $item->isDir() ? 0700 : 0600); + } + } +} diff --git a/app/Services/ShelterRoomService.php b/app/Services/ShelterRoomService.php new file mode 100644 index 0000000..5fb3279 --- /dev/null +++ b/app/Services/ShelterRoomService.php @@ -0,0 +1,59 @@ +query($sql)->fetchAll(PDO::FETCH_ASSOC); + $boxes = DB::pdo() + ->query('SELECT * FROM shelter_boxes ORDER BY sort_order,name COLLATE NOCASE') + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($rooms as &$room) { + $room['boxes'] = array_values( + array_filter( + $boxes, + static fn($box) => (int) $box['room_id'] === (int) $room['id'] && + (!$activeOnly || (int) $box['active'] === 1), + ), + ); + } + return $rooms; + } + + public static function byCode(bool $activeOnly = true): array + { + $result = []; + foreach (self::all($activeOnly) as $room) { + $result[(string) $room['code']] = $room; + } + return $result; + } + + public static function resolveAnimal(array $animal, array $rooms): string + { + $stored = (string) ($animal['refuge_room'] ?? ''); + if ($stored !== '' && isset($rooms[$stored])) { + return $stored; + } + $status = (string) ($animal['status'] ?? 'refuge'); + foreach ($rooms as $code => $room) { + if ((string) $room['status_code'] === $status) { + return (string) $code; + } + } + return array_key_first($rooms) ?? ''; + } + + public static function normalizeCode(string $value): string + { + $value = mb_strtolower(trim($value)); + $value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value; + return trim(preg_replace('/[^a-z0-9]+/', '-', $value) ?? '', '-'); + } +} diff --git a/app/Services/SpeciesService.php b/app/Services/SpeciesService.php new file mode 100644 index 0000000..f2aeb25 --- /dev/null +++ b/app/Services/SpeciesService.php @@ -0,0 +1,79 @@ + 'Animaux de compagnie', + 'nac' => 'NAC', + 'animal_ferme' => 'Animaux de ferme', + 'faune_sauvage' => 'Faune sauvage', + 'autre' => 'Autre', + ]; + public static function categoryLabel(string $key): string + { + return t('species.category.' . $key, [], self::CATEGORIES[$key] ?? $key); + } + + public static function all(bool $activeOnly = false): array + { + $sql = + 'SELECT s.*,(SELECT COUNT(*) FROM animals a WHERE lower(trim(a.species))=lower(s.code)) animal_count FROM ref_species s'; + if ($activeOnly) { + $sql .= ' WHERE s.active=1'; + } + $sql .= ' ORDER BY s.sort_order,s.name COLLATE NOCASE'; + return DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + public static function activeWithCurrent(?string $current): array + { + $rows = self::all(true); + $current = self::normalize((string) $current); + if ($current !== '' && !array_filter($rows, static fn(array $r): bool => $r['code'] === $current)) { + $stmt = DB::pdo()->prepare('SELECT * FROM ref_species WHERE code=:code'); + $stmt->execute([':code' => $current]); + if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $rows[] = $row; + } + } + return $rows; + } + + public static function normalize(string $value): string + { + $value = mb_strtolower(trim($value)); + return match ($value) { + 'cat' => 'chat', + 'dog' => 'chien', + default => $value, + }; + } + + public static function exists(string $code, bool $activeOnly = true): bool + { + $sql = 'SELECT 1 FROM ref_species WHERE code=:code' . ($activeOnly ? ' AND active=1' : ''); + $stmt = DB::pdo()->prepare($sql); + $stmt->execute([':code' => self::normalize($code)]); + return (bool) $stmt->fetchColumn(); + } + + public static function isCat(?string $code): bool + { + return self::normalize((string) $code) === 'chat'; + } + + public static function label(?string $code): string + { + $code = self::normalize((string) $code); + if (self::$labels === null) { + self::$labels = []; + foreach (self::all(false) as $row) { + self::$labels[$row['code']] = $row['name']; + } + } + return self::$labels[$code] ?? ($code !== '' ? ucfirst(str_replace('-', ' ', $code)) : 'Espèce inconnue'); + } +} diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php new file mode 100644 index 0000000..3dc79d0 --- /dev/null +++ b/app/Services/StatisticsService.php @@ -0,0 +1,705 @@ + 0, + 'animals' => 0, + 'scope_animals' => count($scopeIds), + 'average_all_cents' => 0, + 'average_expense_cents' => 0, + 'median_expense_cents' => 0, + 'by_clinic' => [], + 'by_category' => [], + ]; + if (!$scopeIds) { + return $empty; + } + $where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')']; + $params = []; + if ($year !== null) { + $where[] = 'date(ae.occurred_on) BETWEEN :start AND :end'; + $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + } + $base = + ' FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id LEFT JOIN directory_contacts dc ON dc.id=ae.clinic_contact_id LEFT JOIN clinic_tariffs ct ON ct.id=ae.tariff_id WHERE ' . + implode(' AND ', $where); + $map = static function (string $sql) use ($db, $params): array { + $s = $db->prepare($sql); + $s->execute($params); + return $s->fetchAll(PDO::FETCH_ASSOC); + }; + $perAnimal = $map( + 'SELECT ae.animal_id,SUM(ae.total_cents) total_cents' . + $base . + ' GROUP BY ae.animal_id ORDER BY total_cents', + ); + $totals = array_map(static fn($row) => (int) $row['total_cents'], $perAnimal); + $total = array_sum($totals); + $animals = count($totals); + return [ + 'total_cents' => $total, + 'animals' => $animals, + 'scope_animals' => count($scopeIds), + 'average_all_cents' => count($scopeIds) ? (int) round($total / count($scopeIds)) : 0, + 'average_expense_cents' => $animals ? (int) round($total / $animals) : 0, + 'median_expense_cents' => self::median($totals), + 'by_clinic' => $map( + "SELECT dc.id contact_id,COALESCE(dc.name,'—') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" . + $base . + ' GROUP BY dc.id,dc.name ORDER BY total_cents DESC', + ), + 'by_category' => $map( + "SELECT COALESCE(ct.category,'other') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" . + $base . + ' GROUP BY ct.category ORDER BY total_cents DESC', + ), + ]; + } + + public static function costsByMunicipality(?int $year): array + { + $db = DB::pdo(); + $scopeIds = self::costScopeIds($year, null); + if (!$scopeIds) { + return []; + } + $animals = $db + ->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL') + ->fetchAll(PDO::FETCH_ASSOC); + $cities = []; + $animalCity = []; + foreach ($animals as $row) { + $id = (int) $row['id']; + if (!in_array($id, $scopeIds, true)) { + continue; + } + $city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])); + if ($city === 'Non renseignée') { + continue; + } + $animalCity[$id] = $city; + $cities[$city]['animal_ids'][$id] = true; + } + if (!$animalCity) { + return []; + } + $params = []; + $dateSql = ''; + if ($year !== null) { + $dateSql = ' AND date(occurred_on) BETWEEN :start AND :end'; + $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + } + $s = $db->prepare( + 'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' . + implode(',', array_keys($animalCity)) . + ')' . + $dateSql . + ' GROUP BY animal_id', + ); + $s->execute($params); + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) { + $id = (int) $row['animal_id']; + $city = $animalCity[$id]; + $cities[$city]['expense_totals'][$id] = (int) $row['total_cents']; + } + $out = []; + foreach ($cities as $city => $data) { + $totals = array_values($data['expense_totals'] ?? []); + $total = array_sum($totals); + $scope = count($data['animal_ids']); + $with = count($totals); + $out[] = [ + 'municipality' => $city, + 'animals' => $scope, + 'animals_with_expense' => $with, + 'total_cents' => $total, + 'average_all_cents' => $scope ? (int) round($total / $scope) : 0, + 'average_expense_cents' => $with ? (int) round($total / $with) : 0, + 'median_expense_cents' => self::median($totals), + ]; + } + usort( + $out, + static fn($a, $b) => $b['total_cents'] <=> $a['total_cents'] ?: + strnatcasecmp($a['municipality'], $b['municipality']), + ); + return $out; + } + + private static function costScopeIds(?int $year, ?array $ids): array + { + $db = DB::pdo(); + $limit = $ids === null ? '' : self::idSql($ids, 'a.id'); + if ($year === null) { + $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit; + } else { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + $sql = + 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . + $limit . + " AND (date(a.intake_date) BETWEEN :start AND :end OR EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end) OR EXISTS(SELECT 1 FROM animal_expenses ae WHERE ae.animal_id=a.id AND date(ae.occurred_on) BETWEEN :start AND :end))"; + } + $s = $db->prepare($sql); + $s->execute($year === null ? [] : [':start' => $start, ':end' => $end]); + return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN)); + } + + private static function median(array $values): int + { + if (!$values) { + return 0; + } + sort($values, SORT_NUMERIC); + $count = count($values); + $middle = intdiv($count, 2); + return $count % 2 ? (int) $values[$middle] : (int) round(($values[$middle - 1] + $values[$middle]) / 2); + } + private static function idSql(?array $ids, string $column = 'animal_id'): string + { + if ($ids === null) { + return ''; + } + if (!$ids) { + return ' AND 1=0'; + } + return ' AND ' . $column . ' IN (' . implode(',', array_map('intval', $ids)) . ')'; + } + + public static function municipalities(): array + { + $rows = DB::pdo() + ->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL') + ->fetchAll(PDO::FETCH_ASSOC); + $items = []; + foreach ($rows as $row) { + $city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])); + if ($city === 'Non renseignée') { + continue; + } + $items[$city] = ($items[$city] ?? 0) + 1; + } + uksort($items, static fn($a, $b) => strnatcasecmp($a, $b)); + return $items; + } + + public static function animalIdsForMunicipality(string $municipality): array + { + if ($municipality === '') { + return []; + } + $rows = DB::pdo() + ->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL') + ->fetchAll(PDO::FETCH_ASSOC); + $ids = []; + foreach ($rows as $row) { + if ( + GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])) === + $municipality + ) { + $ids[] = (int) $row['id']; + } + } + return $ids; + } + + public static function municipalityCoverage(): array + { + $row = + DB::pdo() + ->query( + "SELECT COUNT(*) total,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'' THEN 1 ELSE 0 END) known,SUM(CASE WHEN (TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'') AND COALESCE(rescue_location_confidence,'')<>'inferred_family' THEN 1 ELSE 0 END) documented,SUM(CASE WHEN rescue_location_confidence='inferred_family' THEN 1 ELSE 0 END) inferred,SUM(CASE WHEN intake_type='born_in_care' OR intake_reason='birth' THEN 1 ELSE 0 END) born_in_care,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))='' AND TRIM(COALESCE(rescue_address,''))='' AND intake_type<>'born_in_care' AND intake_reason<>'birth' THEN 1 ELSE 0 END) unknown FROM animals WHERE deleted_at IS NULL", + ) + ->fetch(PDO::FETCH_ASSOC) ?: + []; + return array_map('intval', $row); + } + + public static function annual(int $year, ?array $ids = null): array + { + if ($ids === null) { + $stats = GrantService::statistics($year); + } else { + $db = DB::pdo(); + $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $in = self::idSql($ids); + $scalar = static function (string $sql) use ($db, $p): float { + $s = $db->prepare($sql); + $s->execute($p); + return (float) ($s->fetchColumn() ?: 0); + }; + $stats = []; + $stats['entries'] = $scalar( + "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . + self::idSql($ids, 'm.animal_id') . + ' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' . + self::idSql($ids, 'a.id') . + ')', + ); + $stats['exits'] = $scalar( + 'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end' . + self::idSql($ids, 'ad.animal_id') . + ")+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" . + self::idSql($ids, 'd.animal_id') . + ')', + ); + $stats['adoptions'] = $scalar( + 'SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in, + ); + $stats['deaths'] = $scalar( + "SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" . + $in, + ); + $stats['births'] = $scalar( + "SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end" . + self::idSql($ids, 'lk.animal_id'), + ); + $stats['vaccinations'] = $scalar( + 'SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end' . $in, + ); + $stats['treatments'] = $scalar( + 'SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end' . $in, + ); + $stats['avg'] = $scalar( + "SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . + self::idSql($ids, 'a.id'), + ); + $stats['current'] = + (float) ($db + ->query( + "SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" . + self::idSql($ids, 'id'), + ) + ->fetchColumn() ?: + 0); + } + $db = DB::pdo(); + $range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $filter = self::idSql($ids, 'a.id'); + $s = $db->prepare( + "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . + $filter . + ' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' . + $filter . + " UNION SELECT a.id FROM animals a JOIN animal_deaths d ON d.animal_id=a.id WHERE a.deleted_at IS NULL AND NULLIF(TRIM(a.intake_date),'') IS NULL AND NOT EXISTS(SELECT 1 FROM animal_movements em WHERE em.animal_id=a.id AND em.kind='entry') AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end" . + $filter . + ')', + ); + $s->execute($range); + $stats['handled'] = (int) $s->fetchColumn(); + $s = $db->prepare( + 'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE date(mn.noted_at) BETWEEN :start AND :end' . + $filter, + ); + $s->execute($range); + $stats['medical_notes'] = (int) $s->fetchColumn(); + return $stats; + } + + public static function comparison(int $year, ?array $ids = null): array + { + $current = self::annual($year, $ids); + $previous = self::annual($year - 1, $ids); + $out = []; + foreach ( + [ + 'handled', + 'entries', + 'exits', + 'adoptions', + 'deaths', + 'births', + 'vaccinations', + 'treatments', + 'medical_notes', + 'avg', + ] + as $key + ) { + $a = (float) $current[$key]; + $b = (float) $previous[$key]; + $out[$key] = [ + 'current' => $a, + 'previous' => $b, + 'difference' => $a - $b, + 'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null, + ]; + } + return $out; + } + + public static function years(): array + { + $db = DB::pdo(); + $found = []; + $queries = [ + 'SELECT intake_date d FROM animals WHERE deleted_at IS NULL AND intake_date IS NOT NULL', + "SELECT created_at d FROM animal_movements WHERE kind IN ('entry','exit')", + 'SELECT adoption_date d FROM adoptions', + "SELECT COALESCE(deceased_date,printf('%04d-01-01',death_year)) d FROM animal_deaths WHERE deceased_date IS NOT NULL OR death_year IS NOT NULL", + 'SELECT birth_date d FROM litters WHERE birth_date IS NOT NULL', + 'SELECT done_date d FROM vaccinations', + 'SELECT start_date d FROM treatments', + 'SELECT noted_at d FROM medical_notes', + 'SELECT occurred_on d FROM animal_expenses', + ]; + foreach ($queries as $sql) { + foreach ($db->query($sql)->fetchAll(PDO::FETCH_COLUMN) as $date) { + $y = (int) substr((string) $date, 0, 4); + if ($y >= 2000 && $y <= 2200) { + $found[$y] = true; + } + } + } + if (!$found) { + $found[(int) date('Y')] = true; + } + $first = min(array_keys($found)); + $last = max((int) date('Y'), max(array_keys($found))); + return range($first, $last); + } + + public static function timeline(?array $years = null, ?array $ids = null): array + { + $out = []; + foreach ($years ?? self::years() as $year) { + $out[(int) $year] = self::annual((int) $year, $ids); + } + return $out; + } + + public static function all(?array $ids = null): array + { + $stats = array_fill_keys( + ['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'medical_notes'], + 0.0, + ); + foreach (self::timeline(null, $ids) as $annual) { + foreach (array_keys($stats) as $key) { + $stats[$key] += (float) $annual[$key]; + } + } + $db = DB::pdo(); + $stats['deaths'] = + (float) ($db + ->query( + 'SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'd.animal_id'), + ) + ->fetchColumn() ?: + 0); + if ($ids === null) { + $stats['handled'] = + (float) ($db->query('SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL')->fetchColumn() ?: 0); + $stats['avg'] = + (float) ($db + ->query( + "SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))", + ) + ->fetchColumn() ?: + 0); + } else { + $stats['handled'] = (float) count($ids); + $stats['avg'] = + (float) ($db + ->query( + "SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . + self::idSql($ids, 'a.id'), + ) + ->fetchColumn() ?: + 0); + } + $stats['entries'] = $stats['handled']; + $stats['exits'] = + (float) ($db + ->query( + 'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'ad.animal_id') . + ')+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)' . + self::idSql($ids, 'd.animal_id') . + ")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" . + self::idSql($ids, 'a.id') . + ')', + ) + ->fetchColumn() ?: + 0); + $stats['adoptions'] = + (float) ($db + ->query( + 'SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'ad.animal_id'), + ) + ->fetchColumn() ?: + 0); + $stats['births'] = + (float) ($db + ->query( + 'SELECT COUNT(*) FROM litter_kittens lk JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'lk.animal_id'), + ) + ->fetchColumn() ?: + 0); + $stats['vaccinations'] = + (float) ($db + ->query( + 'SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'v.animal_id'), + ) + ->fetchColumn() ?: + 0); + $stats['medical_notes'] = + (float) ($db + ->query( + 'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'mn.animal_id'), + ) + ->fetchColumn() ?: + 0); + $stats['current'] = (float) (self::currentHealth($ids)['total'] ?? 0); + return $stats; + } + + public static function undatedHandled(?array $ids = null): int + { + return (int) (DB::pdo() + ->query( + "SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND (a.intake_date IS NULL OR TRIM(a.intake_date)='') AND NOT EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry')" . + self::idSql($ids, 'a.id'), + ) + ->fetchColumn() ?: + 0); + } + + public static function undatedDeaths(?array $ids = null): int + { + return (int) (DB::pdo() + ->query( + "SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL" . + self::idSql($ids, 'd.animal_id'), + ) + ->fetchColumn() ?: + 0); + } + + public static function undatedBirths(?array $ids = null): int + { + return (int) (DB::pdo() + ->query( + "SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,'')) IS NULL" . + self::idSql($ids, 'lk.animal_id'), + ) + ->fetchColumn() ?: + 0); + } + + public static function undatedExits(?array $ids = null): int + { + return (int) (DB::pdo() + ->query( + "SELECT (SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" . + self::idSql($ids, 'd.animal_id') . + ")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" . + self::idSql($ids, 'a.id') . + ')', + ) + ->fetchColumn() ?: + 0); + } + + public static function scopeCounts(?array $ids = null): array + { + $db = DB::pdo(); + return [ + 'vaccinated_animals' => (int) $db + ->query( + 'SELECT COUNT(DISTINCT v.animal_id) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'v.animal_id'), + ) + ->fetchColumn(), + 'medical_animals' => (int) $db + ->query( + 'SELECT COUNT(DISTINCT mn.animal_id) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' . + self::idSql($ids, 'mn.animal_id'), + ) + ->fetchColumn(), + ]; + } + + private static function details(int $year, array $ids): array + { + $db = DB::pdo(); + $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $base = + "WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . + self::idSql($ids, 'm.animal_id') . + ' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end' . + self::idSql($ids, 'id') . + '), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) '; + $map = static function (string $sql) use ($db, $p): array { + $s = $db->prepare($sql); + $s->execute($p); + $out = []; + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) { + $out[(string) $r['label']] = (int) $r['total']; + } + return $out; + }; + $sex = $map( + $base . + "SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC", + ); + $ages = $map( + $base . + "SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", + ); + $deathCauses = $map( + "SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" . + $in . + ' GROUP BY cause_code ORDER BY total DESC', + ); + $stays = $map( + "SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . + self::idSql($ids, 'a.id') . + ' GROUP BY label ORDER BY total DESC', + ); + return compact('sex', 'ages', 'deathCauses', 'stays'); + } + + public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array + { + $db = DB::pdo(); + $p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $rows = static function (string $sql, array $params = []) use ($db): array { + $s = $db->prepare($sql); + $s->execute($params); + $out = []; + foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) { + $out[(string) $r['label']] = (int) $r['total']; + } + return $out; + }; + $movementDate = $year === null ? '' : ' AND date(m.created_at) BETWEEN :start AND :end'; + $animalDate = $year === null ? '' : ' AND date(intake_date) BETWEEN :start AND :end'; + $datedAnimal = $year === null ? '' : " AND NULLIF(TRIM(intake_date),'') IS NOT NULL"; + $entered = + "WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry'" . + $movementDate . + self::idSql($ids, 'm.animal_id') . + ' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL' . + $datedAnimal . + $animalDate . + self::idSql($ids, 'id') . + '), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) '; + $colors = $rows( + $entered . + "SELECT COALESCE(NULLIF(TRIM(a.color),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label", + $p, + ); + $breeds = $rows( + $entered . + "SELECT COALESCE(NULLIF(TRIM(a.breed),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label", + $p, + ); + $species = $rows( + $entered . + "SELECT CASE lower(a.species) WHEN 'cat' THEN 'Chats' WHEN 'chat' THEN 'Chats' WHEN 'dog' THEN 'Chiens' WHEN 'chien' THEN 'Chiens' ELSE COALESCE(NULLIF(TRIM(a.species),''),'Inconnue') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", + $p, + ); + $intakeTypes = $rows( + $entered . + "SELECT COALESCE(NULLIF(a.intake_type,''),'unknown') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", + $p, + ); + $intakeReasons = $rows( + $entered . + "SELECT CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE COALESCE(NULLIF(a.intake_reason,''),'unknown') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", + $p, + ); + if ($year === null) { + $animalFilter = self::idSql($ids, 'a.id'); + $details = []; + $details['sex'] = $rows( + "SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" . + $animalFilter . + ' GROUP BY a.sex ORDER BY total DESC', + ); + $details['ages'] = $rows( + "SELECT CASE WHEN a.birth_date IS NULL OR NULLIF(TRIM(a.intake_date),'') IS NULL THEN 'Non renseigné' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" . + $animalFilter . + ' GROUP BY label ORDER BY total DESC', + ); + $details['deathCauses'] = $rows( + "SELECT COALESCE(NULLIF(d.cause_code,''),'unknown') label,COUNT(*) total FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL" . + self::idSql($ids, 'd.animal_id') . + ' GROUP BY label ORDER BY total DESC', + ); + $details['stays'] = $rows( + "SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND ad.adoption_date IS NOT NULL AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . + $animalFilter . + ' GROUP BY label ORDER BY total DESC', + ); + $origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : self::municipalities(); + } elseif ($ids !== null) { + $details = self::details($year, $ids); + $origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : []; + } else { + $details = GrantService::detailedStatistics($year); + $origins = $details['origins']; + } + foreach ($details as &$items) { + arsort($items, SORT_NUMERIC); + } + unset($items); + $filter = self::idSql($ids, 'id'); + $currentStatus = $rows( + "SELECT CASE status WHEN 'refuge' THEN 'Refuge' WHEN 'fa' THEN 'Famille d’accueil' WHEN 'fa_permanente' THEN 'Accueil permanent' WHEN 'chat_libre' THEN 'Chats libres suivis' WHEN 'quarantaine' THEN 'Quarantaine' WHEN 'soin' THEN 'Infirmerie' WHEN 'reserve' THEN 'Réservé' ELSE status END label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" . + $filter . + ' GROUP BY status ORDER BY total DESC', + ); + $freeCatSites = $rows( + "SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status='chat_libre'" . + $filter . + ' GROUP BY label ORDER BY total DESC,label', + ); + $releaseSql = + $year === null + ? "SELECT substr(free_cat_released_at,1,4) label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND free_cat_released_at IS NOT NULL AND trim(free_cat_released_at)<>''" . + $filter . + ' GROUP BY label ORDER BY label DESC' + : "SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND date(free_cat_released_at) BETWEEN :start AND :end" . + $filter . + ' GROUP BY label ORDER BY total DESC,label'; + $freeCatReleases = $rows($releaseSql, $p); + return [ + 'colors' => $colors, + 'breeds' => $breeds, + 'species' => $species, + 'intakeTypes' => $intakeTypes, + 'intakeReasons' => $intakeReasons, + 'sex' => $details['sex'], + 'ages' => $details['ages'], + 'origins' => $origins, + 'deathCauses' => $details['deathCauses'], + 'stays' => $details['stays'], + 'currentStatus' => $currentStatus, + 'freeCatSites' => $freeCatSites, + 'freeCatReleases' => $freeCatReleases, + ]; + } + + public static function currentHealth(?array $ids = null): array + { + $sql = + "SELECT COUNT(*) total,SUM(CASE WHEN status='chat_libre' THEN 1 ELSE 0 END) free_cats,SUM(CASE WHEN sterilization_status='yes' THEN 1 ELSE 0 END) sterilized,SUM(CASE WHEN sterilization_status='no' THEN 1 ELSE 0 END) unsterilized,SUM(CASE WHEN COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) sterilization_unknown,SUM(CASE WHEN COALESCE(NULLIF(TRIM(chip_id),''),NULLIF(TRIM(chip),'')) IS NOT NULL THEN 1 ELSE 0 END) chipped,SUM(CASE WHEN fiv_status='positive' THEN 1 ELSE 0 END) fiv_positive,SUM(CASE WHEN felv_status='positive' THEN 1 ELSE 0 END) felv_positive FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" . + self::idSql($ids, 'id'); + $row = DB::pdo()->query($sql)->fetch(PDO::FETCH_ASSOC) ?: []; + return array_map('intval', $row); + } +} diff --git a/app/Services/SystemHealthService.php b/app/Services/SystemHealthService.php new file mode 100644 index 0000000..e380309 --- /dev/null +++ b/app/Services/SystemHealthService.php @@ -0,0 +1,207 @@ +query('PRAGMA integrity_check')->fetchColumn(); + $checks[] = self::check( + $integrity === 'ok' ? 'ok' : 'error', + 'database', + t('health.sqlite_integrity'), + $integrity === 'ok' ? t('health.sqlite_ok') : $integrity, + ); + } catch (Throwable $e) { + $checks[] = self::check('error', 'database', t('health.sqlite_integrity'), $e->getMessage()); + } + try { + $fk = $db->query('PRAGMA foreign_key_check')->fetchAll(PDO::FETCH_ASSOC); + $checks[] = self::check( + !$fk ? 'ok' : 'error', + 'database', + t('health.foreign_keys'), + !$fk ? t('health.no_fk_error') : t('health.fk_errors', ['count' => count($fk)]), + ); + } catch (Throwable $e) { + $checks[] = self::check('error', 'database', t('health.foreign_keys'), $e->getMessage()); + } + $migrationFiles = array_map('basename', glob($root . '/migrations/*.sql') ?: []); + $applied = $db->query('SELECT name FROM _migrations')->fetchAll(PDO::FETCH_COLUMN); + $pending = array_values(array_diff($migrationFiles, $applied)); + $checks[] = self::check( + !$pending ? 'ok' : 'warning', + 'database', + t('health.migrations'), + !$pending + ? t('health.migrations_ok', ['count' => count($applied)]) + : t('health.migrations_pending', ['count' => count($pending)]), + ); + $dbFile = $root . '/data/refuge.sqlite'; + $checks[] = self::check( + is_file($dbFile) && is_readable($dbFile) ? 'ok' : 'error', + 'storage', + t('health.database_file'), + is_file($dbFile) ? self::bytes((int) filesize($dbFile)) : t('health.file_missing'), + ); + $free = @disk_free_space($root); + $status = + $free === false + ? 'warning' + : ($free >= 1_073_741_824 + ? 'ok' + : ($free >= 268_435_456 + ? 'warning' + : 'error')); + $checks[] = self::check( + $status, + 'storage', + t('health.disk_space'), + $free === false ? t('health.unknown') : self::bytes((int) $free), + ); + $backups = BackupService::list(); + if (!$backups) { + $checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup')); + } else { + $latest = $backups[0]; + $date = DateTimeImmutable::createFromFormat( + 'd/m/Y H:i:s', + (string) $latest['date'], + new DateTimeZone('Europe/Paris'), + ); + $days = $date + ? (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a') + : 999; + $checks[] = self::check( + $days <= 7 ? 'ok' : ($days <= 30 ? 'warning' : 'error'), + 'continuity', + t('health.last_backup'), + t('health.backup_age', ['date' => $latest['date'], 'days' => $days]), + ); + } + [$missing, $orphans] = self::photoConsistency($db, $root); + $checks[] = self::check( + $missing === 0 ? 'ok' : 'warning', + 'files', + t('health.missing_photos'), + t('health.file_count', ['count' => $missing]), + ); + $checks[] = self::check( + $orphans === 0 ? 'ok' : 'warning', + 'files', + t('health.orphan_photos'), + t('health.file_count', ['count' => $orphans]), + ); + $display = (string) ini_get('display_errors'); + $production = getenv('APP_ENV') !== 'development'; + $checks[] = self::check( + !$production || !in_array(strtolower($display), ['1', 'on', 'yes', 'true'], true) ? 'ok' : 'warning', + 'security', + t('health.display_errors'), + $production ? t('health.production_value', ['value' => $display ?: 'Off']) : t('health.development_mode'), + ); + $logErrors = filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOL); + $errorLog = trim((string) ini_get('error_log')); + $checks[] = self::check( + $logErrors ? 'ok' : 'warning', + 'security', + t('health.error_log'), + $logErrors ? ($errorLog ?: t('health.system_log')) : t('health.logging_disabled'), + ); + $applicationLog = $root . '/storage/logs/php-errors.log'; + $recent = self::recentLogLines($applicationLog, 7); + $checks[] = self::check( + $recent === 0 ? 'ok' : 'warning', + 'security', + t('health.recent_errors'), + $recent ? t('health.recent_error_count', ['count' => $recent]) : t('health.no_recent_error'), + ); + $summary = ['ok' => 0, 'warning' => 0, 'error' => 0]; + foreach ($checks as $c) { + $summary[$c['status']]++; + } + return [ + 'checks' => $checks, + 'summary' => $summary, + 'generated_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('d/m/Y H:i:s'), + 'php' => PHP_VERSION, + 'sqlite' => (string) $db->query('SELECT sqlite_version()')->fetchColumn(), + ]; + } + private static function photoConsistency(PDO $db, string $root): array + { + $expected = []; + $missing = 0; + try { + foreach ($db->query('SELECT animal_id,filename FROM animal_photos')->fetchAll(PDO::FETCH_ASSOC) as $row) { + $path = + $root . + '/public/media/animals/' . + (int) $row['animal_id'] . + '/' . + basename((string) $row['filename']); + $expected[$path] = true; + if (!is_file($path)) { + $missing++; + } + } + } catch (Throwable) { + return [0, 0]; + } + $orphans = 0; + foreach (glob($root . '/public/media/animals/*/*') ?: [] as $file) { + if (is_file($file) && !isset($expected[$file]) && !str_starts_with(basename($file), '.')) { + $orphans++; + } + } + return [$missing, $orphans]; + } + private static function check(string $status, string $group, string $title, string $detail): array + { + return compact('status', 'group', 'title', 'detail'); + } + private static function recentLogLines(string $path, int $days): int + { + if (!is_file($path) || filesize($path) === 0) { + return 0; + } + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $dates = []; + for ($i = 0; $i < $days; $i++) { + $dates[] = new DateTimeImmutable("-$i days")->format('d-M-Y'); + } + $count = 0; + foreach (array_slice($lines, -1000) as $line) { + foreach ($dates as $date) { + if (str_contains($line, $date)) { + $count++; + break; + } + } + } + return $count; + } + private static function bytes(int $bytes): string + { + foreach (['o', 'Ko', 'Mo', 'Go', 'To'] as $unit) { + if ($bytes < 1024 || $unit === 'To') { + return number_format($bytes, $unit === 'o' ? 0 : 1, ',', ' ') . ' ' . $unit; + } + $bytes = (int) round($bytes / 1024); + } + return ''; + } +} diff --git a/app/Services/TrustedDeviceService.php b/app/Services/TrustedDeviceService.php new file mode 100644 index 0000000..b8d17cc --- /dev/null +++ b/app/Services/TrustedDeviceService.php @@ -0,0 +1,144 @@ +prepare( + 'INSERT INTO trusted_devices(user_id,selector,token_hash,device_name,user_agent,ip_address,expires_at) VALUES(?,?,?,?,?,?,?)', + ) + ->execute([ + $userId, + $selector, + hash('sha256', $token), + mb_substr($name, 0, 100), + mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null, + $_SERVER['REMOTE_ADDR'] ?? null, + date('Y-m-d H:i:s', $expires), + ]); + self::cookie($selector . '.' . bin2hex($token), $expires); + } + public static function authenticate(): ?int + { + $raw = (string) ($_COOKIE[self::COOKIE] ?? ''); + if (!preg_match('/^([a-f0-9]{18})\.([a-f0-9]{64})$/', $raw, $m)) { + return null; + } + try { + $s = DB::pdo()->prepare( + "SELECT td.*,u.active FROM trusted_devices td JOIN users u ON u.id=td.user_id WHERE td.selector=? AND td.revoked_at IS NULL AND datetime(td.expires_at)>datetime('now') AND (td.last_used_at IS NULL OR datetime(td.last_used_at)>datetime('now','-30 days'))", + ); + $s->execute([$m[1]]); + $row = $s->fetch(PDO::FETCH_ASSOC); + if ( + !$row || + !hash_equals((string) $row['token_hash'], hash('sha256', hex2bin($m[2]) ?: '')) || + (int) $row['active'] !== 1 + ) { + self::forget(); + return null; + } + $new = random_bytes(32); + DB::pdo() + ->prepare( + "UPDATE trusted_devices SET token_hash=?,last_used_at=datetime('now'),ip_address=? WHERE id=?", + ) + ->execute([hash('sha256', $new), $_SERVER['REMOTE_ADDR'] ?? null, $row['id']]); + self::cookie($m[1] . '.' . bin2hex($new), strtotime((string) $row['expires_at'])); + return (int) $row['user_id']; + } catch (Throwable) { + return null; + } + } + public static function currentSelector(): ?string + { + $raw = (string) ($_COOKIE[self::COOKIE] ?? ''); + return preg_match('/^([a-f0-9]{18})\./', $raw, $m) ? $m[1] : null; + } + public static function revokeCurrent(): void + { + $selector = self::currentSelector(); + if ($selector) { + try { + DB::pdo() + ->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE selector=?") + ->execute([$selector]); + } catch (Throwable) { + } + } + self::forget(); + } + public static function devices(?int $userId = null): array + { + $userId ??= Auth::id(); + $s = DB::pdo()->prepare( + 'SELECT * FROM trusted_devices WHERE user_id=? ORDER BY revoked_at IS NULL DESC,last_used_at DESC', + ); + $s->execute([$userId]); + return $s->fetchAll(PDO::FETCH_ASSOC); + } + public static function revoke(int $id, int $userId, bool $admin = false): void + { + $sql = + 'UPDATE trusted_devices SET revoked_at=datetime(\'now\') WHERE id=:id' . + ($admin ? '' : ' AND user_id=:uid'); + $params = [':id' => $id]; + if (!$admin) { + $params[':uid'] = $userId; + } + DB::pdo()->prepare($sql)->execute($params); + if ( + self::currentSelector() && + self::currentSelector() === + (string) DB::pdo() + ->query('SELECT selector FROM trusted_devices WHERE id=' . (int) $id) + ->fetchColumn() + ) { + self::forget(); + } + } + private static function deviceLabel(): string + { + $ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + if (str_contains($ua, 'globinoursandroid')) { + return 'Application Android'; + } + if (str_contains($ua, 'android')) { + return 'Smartphone ou tablette Android'; + } + if (str_contains($ua, 'iphone') || str_contains($ua, 'ipad')) { + return 'iPhone ou iPad'; + } + return 'Navigateur web'; + } + private static function cookie(string $value, int $expires): void + { + setcookie(self::COOKIE, $value, [ + 'expires' => $expires, + 'path' => '/', + 'secure' => Auth::isSecureRequest(), + 'httponly' => true, + 'samesite' => 'Strict', + ]); + $_COOKIE[self::COOKIE] = $value; + } + private static function forget(): void + { + setcookie(self::COOKIE, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'secure' => Auth::isSecureRequest(), + 'httponly' => true, + 'samesite' => 'Strict', + ]); + unset($_COOKIE[self::COOKIE]); + } +} diff --git a/app/Views/_animal_show_activity.php b/app/Views/_animal_show_activity.php new file mode 100644 index 0000000..27c54f6 --- /dev/null +++ b/app/Views/_animal_show_activity.php @@ -0,0 +1,82 @@ + + +
+ + +
+ + + ['label' => t('animal.system'), 'iconBg' => 'bg-blue', 'icon' => 'history', 'badge' => 'bg-blue-lt'], +'medical' => ['label' => t('animal.tab.medical'), 'iconBg' => 'bg-red', 'icon' => 'heart', 'badge' => 'bg-red-lt'], +'vaccine' => ['label' => t('animal.vaccine'), 'iconBg' => 'bg-yellow', 'icon' => 'shield-check', 'badge' => 'bg-yellow-lt'], +'deworming' => ['label' => t('animals.dewormer'), 'iconBg' => 'bg-lime', 'icon' => 'pill', 'badge' => 'bg-lime-lt'], +'treatment' => ['label' => t('animal.treatment'), 'iconBg' => 'bg-orange', 'icon' => 'pill', 'badge' => 'bg-orange-lt'], +'weight' => ['label' => t('common.weight'), 'iconBg' => 'bg-indigo', 'icon' => 'scale', 'badge' => 'bg-indigo-lt'], +'care_round' => ['label' => t('animal.care_round'), 'iconBg' => 'bg-red', 'icon' => 'alert-triangle','badge' => 'bg-red-lt'], +'death' => ['label' => t('status.decede'), 'iconBg' => 'bg-dark', 'icon' => 'rainbow', 'badge' => 'bg-dark-lt'], +]; + ?> + +
    + ucfirst($kind), 'iconBg' => 'bg-muted', 'icon' => 'circle', 'badge' => 'bg-muted-lt']; + + $lbl = (string)($a['label'] ?? ''); + $details = (string)($a['details'] ?? ''); + $at = (string)($a['at'] ?? ''); + ?> +
  • + +
    + +
    + +
    +
    + +
    + + +
    + + + +
    + + +
    + +
    + +
    + + +
    + +
    + +
    + + + + + + + + + +
    +
  • + +
+ + +
diff --git a/app/Views/_animal_show_documents.php b/app/Views/_animal_show_documents.php new file mode 100644 index 0000000..1f32613 --- /dev/null +++ b/app/Views/_animal_show_documents.php @@ -0,0 +1,48 @@ + + + +
+
+
+
+

+
+
+
+
+
+
+
+
+
✓ + $adoption['adopter_name'] ?? ''])) ?>
+
+ +
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+ diff --git a/app/Views/_animal_show_history.php b/app/Views/_animal_show_history.php new file mode 100644 index 0000000..bd7a3c8 --- /dev/null +++ b/app/Views/_animal_show_history.php @@ -0,0 +1,31 @@ + + +
+ !in_array((string)($row['type'] ?? ''), ['medical','treatment','vaccine','deworming','death'], true))); ?> + +
+ +
    + 'bg-muted','icon' => 'circle']; + ?> +
  • +
    + +
    +
    +
    +
    +
    + $h['actor_name']])) ?>
    + +
    + +
    +
  • + +
+ +
diff --git a/app/Views/_animal_show_identity.php b/app/Views/_animal_show_identity.php new file mode 100644 index 0000000..36381cd --- /dev/null +++ b/app/Views/_animal_show_identity.php @@ -0,0 +1,459 @@ + + +
+ + + DeathController::causeLabel($key), array_keys(DeathController::CAUSES))); + $deathPlaceLabels = array_combine(array_keys(DeathController::PLACES), array_map(static fn ($key) => DeathController::placeLabel($key), array_keys(DeathController::PLACES))); + $deathDispositionLabels = array_combine(array_keys(DeathController::DISPOSITIONS), array_map(static fn ($key) => DeathController::dispositionLabel($key), array_keys(DeathController::DISPOSITIONS))); + $deathDate = DateTimeImmutable::createFromFormat('Y-m-d', (string)$death['deceased_date']); + $deathDateLabel = !empty($death['death_year']) ? t('animal.year', ['year' => (int)$death['death_year']]) : ($deathDate ? $deathDate->format('d/m/Y') : t('animal.date_unknown')); + ?> +
+
+
+
+

🌈

+
· +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ : +
: +
+
+ t('animal.age_days'),'week' => t('animal.age_weeks'),'month' => t('animal.age_months'),'year' => t('animal.age_years'),default => '' + }) ?>
+
+
+
+ + + +
+
+
+
+

🔗

+
+
+
+ + + +
+
+
+

+

+
+ + + + + +
+
+
+ + + [t('common.yes'),'bg-green-lt text-green','✓'], + 'no' => [t('common.no'),'bg-red-lt text-red','✕'], + 'unknown' => [t('common.unknown'),'bg-secondary-lt text-secondary','—'], + ]; +?> +
+
+
+

+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
· +
+
+
+
+
+
+
+
+ +
+
+
+

+
+
+
+
+
+ ['🐶',t('animals.dogs')], + 'compatibility_cats' => ['🐱',t('animals.cats')], + 'compatibility_children' => ['🧒',t('animals.children')], + 'house_trained' => ['🧼',t('animals.cleanliness')], +] as $field => $profile): [$profileLabel,$profileClass,$profileSymbol] = $lifeProfileMeta[$animal[$field] ?? 'unknown'] ?? $lifeProfileMeta['unknown']; ?> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+

+
+
+
+ + + + + +
+
+

+
+ + +
+
+ +
+
+
+
+
+ +
+ +
+ +
+
+
+ + +
+ +
+ +
+ + + +
+ :
+ +
+ + + +
+ + + + +
+ $animal['name'],'distance' => number_format($distanceKm, 2)])) ?>
+ +
+ +
+
+ + + + + ..., 'stale'=>bool] comme ton endpoint */ +$cache = $icad['cached'] ?? null; +$data = $cache['data'] ?? null; +$status = $cache['status'] ?? 'empty'; +$fetched = $cache['fetched_at'] ?? ''; +$icadUrl = $data['icad_url'] ?? null; + +$badge = match ($status) { + 'ok' => 'bg-success-lt', + 'error' => 'bg-danger-lt', + default => 'bg-muted-lt', +}; + +$label = match ($status) { + 'ok' => 'i-CAD : OK', + 'error' => 'i-CAD : Erreur', + default => t('animal.icad_not_checked'), +}; + +$err = (string)($cache['error'] ?? ''); +?> + +
+
+
+ + + $fetched])) ?> + +
+ +
+ + + + + + + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+
+
+
+
+
Puce
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + $data['holder_name'] ?? '', + 'phone' => $data['holder_phone'] ?? '', + 'email' => $data['holder_email'] ?? '', + 'type' => $data['holder_type'] ?? '', + 'address' => $data['holder_address'] ?? '', + ]; + } + ?> + + trim((string)$v) !== '')): ?> +
+ +
+
+
+
+ +
+ +
+ +
+
Type
+
+
+ + + +
+
+
+
+ + + +
+
+
+
+ + + +
+
+
+
+ + + +
+
+
+
+ +
+
+
+ +
+ +
+
+ + +
+ +
    + +
  • +
    + +
    +
    +
    +
    + : — + : +
    +
    +
  • + +
+ +
+
+ + +
+
+ +
diff --git a/app/Views/_animal_show_litters.php b/app/Views/_animal_show_litters.php new file mode 100644 index 0000000..82414cc --- /dev/null +++ b/app/Views/_animal_show_litters.php @@ -0,0 +1,68 @@ + + +
+
+
+

+
+
+ + + +
+ +
+
+
+ +
+ + format('d/m/Y'); + } + } + ?> +
+
+
+
+

$litterDateLabel])) ?>

+
+ : + ' . h($litter['mother_name']) . '' : h(t('animal.unknown_female')) ?> + · : + ' . h($litter['father_name']) . '' : h(t('animal.unknown_male')) ?> +
+
+ + + +
+
+ $kitten): ?> + + + + + + + + + + +
+
+
+ +
+ +
diff --git a/app/Views/_animal_show_locations.php b/app/Views/_animal_show_locations.php new file mode 100644 index 0000000..67d2963 --- /dev/null +++ b/app/Views/_animal_show_locations.php @@ -0,0 +1,71 @@ + + +
+
+
+

+
+
+
+ t('animal.reservation'),'foster' => t('animal.form.foster'),'adoption' => t('animal.final_adoption'),'return' => t('animal.return_shelter'),'cancellation' => t('animal.cancellation')]; +$placementColors = ['reservation' => 'purple','foster' => 'cyan','adoption' => 'green','return' => 'orange','cancellation' => 'secondary']; ?> +
  • +
    +
    +
    +
    +
    +
    +
    +
    + :
    +
    +
    +
    + $placement['actor_name']])) ?>
    +
    +
+
+ +
+
+
+

+
+
+ +
+ +
+ +
    + +
  • +
    +
    +
    +
    + → ' . h($toLabel) ?> +
    +
    +
    +
    +
    +
    + $location['actor_name']])) ?>
    +
    +
  • + +
+ +
diff --git a/app/Views/_animal_show_medical.php b/app/Views/_animal_show_medical.php new file mode 100644 index 0000000..4bb698e --- /dev/null +++ b/app/Views/_animal_show_medical.php @@ -0,0 +1,436 @@ + + + +
+
+
+
+

+
+
+ +
+
+
+
+
FIV
+ +
+
+
FeLV
+ +
+
+
+ ($condition['status'] ?? '') === 'active')); ?> + + + +
+ + + +
+ +
+
+
+
+ +
+
+
+
+
+

📄

+
+
+
+
+
+
+ +
+
+
+ format('d/m/Y')) ?> + +
+
+ +
+ 💊 + +
+
+
+
+
+
+
+
+
+
+

🧪

+
+
+
+
+
+
+ $report): $results = $labResultsByReport[$report['id']] ?? []; ?> +
+

+

+
+
+ +
+ + + + + + + + + (float)$result['reference_max']; ?> + + + + +
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+

+
+
+
+
+
$series): ?>
+
+

+ (' . h($series['unit']) . ')' : '' ?> +

+
+
+
+
+
+
+ +
+
+
+

🏥 Chirurgies

+
Interventions planifiées et réalisées
+
+
+
Aucune chirurgie + enregistrée.
+ + + + + + + + + + + + + + + + + +
DateInterventionStructure / vétérinaireStatutCoût
+ 'Réalisée','planned' => 'Planifiée','cancelled' => 'Annulée'][$s['status']] ?? $s['status']) ?> + + +
+
+
+ +
+
+
+

+
+
+
+
+ (new DateTimeImmutable($lastDeworming['administered_on']))->format('d/m/Y')])) ?> $due->format('d/m/Y')])) ?> +
+
+ + + + + + + + + + + + + + + + + +
format('d/m/Y')) ?> +
+
+
+
+ +
+
+

+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+
+ 'bg-blue-lt', + 'controle' => 'bg-green-lt', + 'symptome' => 'bg-yellow-lt', + 'diagnostic' => 'bg-purple-lt', + 'acte' => 'bg-orange-lt', + 'hosp' => 'bg-red-lt', + default => 'bg-secondary-lt', + }; + + $stmtPhotos = DB::pdo()->prepare(' +SELECT * +FROM medical_photos +WHERE medical_note_id = ? +ORDER BY created_at ASC +'); + $stmtPhotos->execute([(int)$n['id']]); + $notePhotos = $stmtPhotos->fetchAll(PDO::FETCH_ASSOC); + ?> + + + + + +
+
+
+ + +
+ +
+ ' . h($n['weight_kg']) . ' kg') : '' ?> + + ' . h($n['temperature_c']) . ' °C') : '' ?> +
+ + +
+ + + +
+ + + + +
: +
+ + +
:
+ + +
: +
+ + +
:
+ +
+ + +
+
:
+ +
+ + +
+ + + +
+ +
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+ +
+
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +

+
+ date('d/m/Y', strtotime((string)$v['batch_expires_on']))])) ?> +
+
+ +
+ diff --git a/app/Views/_animal_show_modals.php b/app/Views/_animal_show_modals.php new file mode 100644 index 0000000..e3e17eb --- /dev/null +++ b/app/Views/_animal_show_modals.php @@ -0,0 +1,1105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/Views/_animal_show_photos.php b/app/Views/_animal_show_photos.php new file mode 100644 index 0000000..ca96288 --- /dev/null +++ b/app/Views/_animal_show_photos.php @@ -0,0 +1,205 @@ + + +
+ + + (int)($p['is_public'] ?? 1) === 1)); +$privatePhotos = array_values(array_filter($photos ?? [], fn ($p) => (int)($p['is_public'] ?? 1) === 0)); +?> + +
+ + +
+
+
+
+

+
()
+
+ +
+ + + + + +
+
+ +
+ +
+ +
+ + +
+
+ + + + +
+ ' . h(t('animal.primary')) . '' + : '' . h(t('animal.public')) . '' ?> + + + + +
+ + + + +
+ + +
+ +
+ + + + + +
+ +
+ + + + +
+ + +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+
+

+
()
+
+ +
+ + + + + +
+
+ +
+ +
+ +
+ + +
+
+ + + + +
+ +
+
+
📋 +
+ +
+
+ + + + + +
+ +
+ + + + +
+
+ +
+
+
+ +
+ +
+
+
+ +
+
+ + + + + + + diff --git a/app/Views/_deworming_modal.php b/app/Views/_deworming_modal.php new file mode 100644 index 0000000..7ea6bfb --- /dev/null +++ b/app/Views/_deworming_modal.php @@ -0,0 +1,67 @@ + + diff --git a/app/Views/_directory_medical_fields.php b/app/Views/_directory_medical_fields.php new file mode 100644 index 0000000..bc404b0 --- /dev/null +++ b/app/Views/_directory_medical_fields.php @@ -0,0 +1,29 @@ + +
+ + + +
+
+ + + +
+
+
+
diff --git a/app/Views/_expense_modal.php b/app/Views/_expense_modal.php new file mode 100644 index 0000000..a4fb192 --- /dev/null +++ b/app/Views/_expense_modal.php @@ -0,0 +1,63 @@ + + + + diff --git a/app/Views/_expense_summary.php b/app/Views/_expense_summary.php new file mode 100644 index 0000000..f77258a --- /dev/null +++ b/app/Views/_expense_summary.php @@ -0,0 +1,37 @@ + +
+
+
+
+
$expenseCount])) ?>
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
× %
+
+
+
+ diff --git a/app/Views/_expense_tariff_field.php b/app/Views/_expense_tariff_field.php new file mode 100644 index 0000000..dfaca5f --- /dev/null +++ b/app/Views/_expense_tariff_field.php @@ -0,0 +1,15 @@ +
+
+
diff --git a/app/Views/_helpers.php b/app/Views/_helpers.php new file mode 100644 index 0000000..03bd8d4 --- /dev/null +++ b/app/Views/_helpers.php @@ -0,0 +1,99 @@ + ['bg-blue-lt',t('status.refuge')], + 'fa' => ['bg-cyan-lt',t('status.fa')], + 'fa_permanente' => ['bg-indigo-lt',t('status.fa_permanente')], + 'chat_libre' => ['bg-green-lt text-green','🌿 ' . t('status.chat_libre')], + 'quarantaine' => ['bg-yellow-lt',t('status.quarantaine')], + 'soin' => ['bg-red-lt',t('status.soin')], + 'isolation' => ['bg-orange-lt',t('status.isolation')], + 'hospitalise','hospitalisé' => ['bg-red-lt',t('status.hospitalise')], + 'reserve','réservé' => ['bg-purple-lt',t('status.reserve')], + 'adopte','adopté' => ['bg-green-lt',t('status.adopte')], + 'decede','décédé' => ['bg-dark-lt',t('status.decede')], + 'enfui' => ['bg-secondary-lt',t('status.enfui')], + default => ['bg-secondary-lt', $st !== '' ? $st : '—'], + }; +} + +function species_label(?string $species): string +{ + if (class_exists('SpeciesService')) { + return SpeciesService::label($species); + } + return match (strtolower(trim((string)$species))) { + 'cat', 'chat' => t('species.cat'), + 'dog', 'chien' => t('species.dog'), + 'nac' => 'NAC', + '' => '—', + default => ucfirst(trim((string)$species)), + }; +} + +function coat_swatch_style(?string $coat): string +{ + return match (mb_strtolower(trim((string)$coat), 'UTF-8')) { + 'bicolore noir / blanc' => 'background:linear-gradient(135deg,#252525 0 50%,#fff 50% 100%)', + 'bicolore gris / blanc' => 'background:linear-gradient(135deg,#8b95a1 0 50%,#fff 50% 100%)', + 'bicolore roux / blanc' => 'background:linear-gradient(135deg,#d9822b 0 50%,#fff 50% 100%)', + 'bicolore tigré et blanc' => 'background:linear-gradient(90deg,transparent 0 55%,#fff 55% 100%),repeating-linear-gradient(135deg,#8a6848 0 5px,#3f3328 5px 8px)', + 'gris' => 'background:#8b95a1', + 'roux' => 'background:#d9822b', + 'noir' => 'background:#252525', + 'chocolat' => 'background:#6f4e37', + 'blanc' => 'background:#fff', + 'tricolore / calico' => 'background:conic-gradient(#252525 0 33%,#fff 33% 66%,#d9822b 66% 100%)', + 'écaille de tortue' => 'background:conic-gradient(#2d241f 0 28%,#b86326 28% 48%,#171513 48% 72%,#d18a45 72% 100%)', + 'colourpoint / siamois' => 'background:radial-gradient(circle at center,#f2dfbb 0 48%,#654b3c 52% 100%)', + 'tigré / tabby' => 'background:repeating-linear-gradient(135deg,#8a6848 0 5px,#3f3328 5px 8px)', + default => 'background:#d9dde3', + }; +} + +function sort_th(string $label, string $key, string $sort, string $dir, string $path = '/animals'): string +{ + $nextDir = ($sort === $key && strtoupper($dir) === 'ASC') ? 'desc' : 'asc'; + $arrow = ($sort === $key) ? (strtoupper($dir) === 'ASC' ? ' ↑' : ' ↓') : ''; + $qs = $_GET; + $qs['sort'] = $key; + $qs['dir'] = $nextDir; + $href = $path . '?' . http_build_query($qs); + return '' . $label . $arrow . ''; + +} + +function sortLink($column, $label, $sort, $dir, $year, $q, $species) +{ + $newDir = ($sort === $column && $dir === 'ASC') ? 'desc' : 'asc'; + + $arrow = ''; + if ($sort === $column) { + $arrow = $dir === 'ASC' ? ' ↑' : ' ↓'; + } + + return '' . $label . $arrow . ''; +} diff --git a/app/Views/_layout_footer.php b/app/Views/_layout_footer.php new file mode 100644 index 0000000..be7871c --- /dev/null +++ b/app/Views/_layout_footer.php @@ -0,0 +1,35 @@ + + diff --git a/app/Views/_layout_topbar.php b/app/Views/_layout_topbar.php new file mode 100644 index 0000000..776aec7 --- /dev/null +++ b/app/Views/_layout_topbar.php @@ -0,0 +1,152 @@ + + + diff --git a/app/Views/_public_animal_cards.php b/app/Views/_public_animal_cards.php new file mode 100644 index 0000000..f1af023 --- /dev/null +++ b/app/Views/_public_animal_cards.php @@ -0,0 +1,14 @@ +
diff --git a/app/Views/_settings_links.php b/app/Views/_settings_links.php new file mode 100644 index 0000000..9c962be --- /dev/null +++ b/app/Views/_settings_links.php @@ -0,0 +1,31 @@ + t('settings.group_general'),'business' => t('settings.group_business'),'access' => t('settings.group_access'),'maintenance' => t('settings.group_maintenance'),default => $group + }; + } + function settingsNavigationLinks(): array + { + return [ + 'general' => ['/settings/general','⚙️',t('settings.general'),'general'], + 'public-site' => ['/settings/public-site','🌐',t('settings.public_site'),'general'], + 'species' => ['/settings/species','🐾',t('settings.species'),'business'], + 'rooms' => ['/settings/rooms','🚪',t('settings.rooms'),'business'], + 'pricing' => ['/settings/pricing','💶',t('settings.pricing'),'business'], + 'inventory' => ['/settings/inventory','💊',t('settings.inventory'),'business'], + 'lab-references' => ['/settings/lab-references','🩸',t('settings.lab_references'),'business'], + 'users' => ['/settings/users','👥',t('settings.users'),'access'], + 'permissions' => ['/settings/permissions','🔐',t('settings.permissions'),'access'], + 'privacy' => ['/settings/privacy','🔏',t('settings.privacy'),'access'], + 'backups' => ['/settings/backups','💾',t('settings.backups'),'maintenance'], + 'health' => ['/settings/health','🩺',t('settings.health'),'maintenance'], + 'notifications' => ['/settings/notifications','🔔',t('settings.notifications'),'maintenance'], + 'data' => ['/settings/data','🧪',t('settings.data'),'maintenance'], + 'asm3' => ['/settings/asm3','🚚',t('settings.asm3'),'maintenance'], + 'audit' => ['/settings/audit','📜',t('settings.audit'),'maintenance'], + ]; + } +} diff --git a/app/Views/access_denied.php b/app/Views/access_denied.php new file mode 100644 index 0000000..e5777d5 --- /dev/null +++ b/app/Views/access_denied.php @@ -0,0 +1,21 @@ + +
+
+
+
+
+
🔒
+
+

+

+ $module])) ?> +

+
+
+
+
+
diff --git a/app/Views/account_devices.php b/app/Views/account_devices.php new file mode 100644 index 0000000..799b550 --- /dev/null +++ b/app/Views/account_devices.php @@ -0,0 +1,35 @@ +
+
+

📱

+
+
+
+
+
+
+
+ time(); ?> +
+
+
+
📱
+
+
+ +
+
+ date('d/m/Y H:i', strtotime($device['created_at']))])) ?>
date('d/m/Y H:i', strtotime($device['last_used_at']))])) ?>
date('d/m/Y', strtotime($device['expires_at']))])) ?> +
+
+
+
+
+
+
+
+
diff --git a/app/Views/accounting.php b/app/Views/accounting.php new file mode 100644 index 0000000..2fa6150 --- /dev/null +++ b/app/Views/accounting.php @@ -0,0 +1,315 @@ + + number_format($c / 100, 2, ',', ' ') . ' €'; +$statusLabels = ['paid' => 'Payée','to_pay' => 'À payer','partial' => 'Partiellement payée','disputed' => 'Contestée','cancelled' => 'Annulée','credit' => 'Avoir']; +$methodLabels = ['transfer' => 'Virement','direct_debit' => 'Prélèvement','card' => 'Carte','check' => 'Chèque','cash' => 'Espèces','other' => 'Autre','unknown' => 'Non renseigné'];?> + '','vendor_id' => 0,'status' => '','type' => '','payment_method' => '']; +$filterQuery = array_merge(['year' => (int)$year], $accountingFilters);?> +
+
Pilotage des factures, paiements et partenaires
+
⬇ Export CSV
+
+
+
+
+
+
+
+
+
+
+
+
Réinitialiser +
+
+
+
+
+
+
+
+
+
Payé net
+
+
+
+
+
+
+
+
+
+
À régler
+
+
pièce(s)
+
+
+
+
+
+
+
Échéances dépassées
+
+
+
Avec une date d’échéance connue
+
+
+
+
+
+
+
Taux restant
+
+
+
Sur la période choisie
+
+
+
+
+
+
+
+
+

Coût et paiements par structure

+
+
+ + + + + + + + + + + + + + + + + + + +
StructureFacturéPayéReste dûAnimauxCoût/animal
+
+ / pièce(s) soldée(s) +
+
+ +
+
+ +
+
+
+
+
+

Évolution annuelle des paiements

+
+
+
+
+
+
+
+
+
+
+
+
+

État des pièces

+
+
$count):?> + ·
+
+
+
+
+
+

Modes de règlement

+
+
$count):?> · +
+
+
+
+ $r['document_type'] === 'credit' ? (int)$r['total_ttc_cents'] : 0, $invoices));?> +
+
+
+
+
Facturé TTC net
+
+
+
+
+
+
+
+
Avoirs inclus
+
+
+
+
+
+
+
+
Pièces comptables
+
+
+
+
+
+
+
+

🧾 Nouvelle facture ou avoir

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+

Journal des factures

+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateRéférenceTypeStructureHTTTCStatutRèglement
'Facture','credit' => 'Avoir','adjustment' => 'Régularisation'][$r['document_type']] ?? $r['document_type']) ?> + + + 'Payée','to_pay' => 'À payer','partial' => 'Partielle','disputed' => 'Contestée','cancelled' => 'Annulée','credit' => 'Avoir'][$r['status']] ?? $r['status']) ?> + 'Virement','direct_debit' => 'Prélèvement','card' => 'Carte','check' => 'Chèque','cash' => 'Espèces','other' => 'Autre'][$r['payment_method']] ?? '—') ?> + Voir
Aucune facture pour cette période.
+
+
diff --git a/app/Views/accounting_edit.php b/app/Views/accounting_edit.php new file mode 100644 index 0000000..a632dbd --- /dev/null +++ b/app/Views/accounting_edit.php @@ -0,0 +1,71 @@ +
+
+
+
+
+

Modifier

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
diff --git a/app/Views/admin_exports.php b/app/Views/admin_exports.php new file mode 100644 index 0000000..1ec78b8 --- /dev/null +++ b/app/Views/admin_exports.php @@ -0,0 +1,37 @@ +
+
+

+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
$dataset): ?>
+
+
+
CSV +
+

+
+
+
+
+ +
+
+
+
+

+

+
+
diff --git a/app/Views/admin_grants.php b/app/Views/admin_grants.php new file mode 100644 index 0000000..c3a91d0 --- /dev/null +++ b/app/Views/admin_grants.php @@ -0,0 +1,314 @@ + number_format((float)$v, 2, ',', ' ') . ' €'; +$date = static fn ($v) => $v ? (new DateTimeImmutable($v))->format('d/m/Y') : '—'; ?> +
+
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
$year])) ?> + : 0 ? h(t('common.days', ['count' => round($stats['avg'])])) : '—' ?>.
+ +
+
+
+

$year - 1])) ?>

+
+
+
+
+ + + + + + + + + + + t('grant.entries'),'exits' => t('grant.exits'),'adoptions' => t('grant.adoptions'),'deaths' => t('grant.deaths'),'births' => t('grant.births'),'vaccinations' => t('grant.vaccinations'),'treatments' => t('grant.treatments'),'avg' => t('grant.avg_adoption')] as $key => $label): $c = $comparison[$key]; ?> + + + + + + +
0 ? '+' : '' ?> 0 ? '+' : '') . number_format($c['percent'], 1, ',', ' ') . ' %' : '' ?> +
+
+
+ + DeathController::causeLabel($key), array_keys(DeathController::CAUSES)))], + [t('grant.adoption_stay'),$details['stays'],[]], +]; +?> +
+
+
+
+

+
+
+ $year])) ?>
$total): $display = $labels[$label] ?? $label; + $width = $maximum > 0 ? max(5, round($total / $maximum * 100)) : 0; ?>
+
+
+
+
+
+
+
+
+ +
+
+

$year])) ?>

+
+
+
+
+
+
+
+
+
+ t('grant.activity_summary'),'completed_actions' => t('grant.completed_actions'),'planned_actions' => t('grant.planned_actions'),'officers_notes' => t('grant.officers'),'in_kind_support' => t('grant.in_kind')] as $field => $label): ?> +
+
+
+

+
+ t('grant.actual_expenses'),'actual_income' => t('grant.actual_income'),'forecast_expenses' => t('grant.forecast_expenses'),'forecast_income' => t('grant.forecast_income'),'treasury_amount' => t('grant.treasury'),'voluntary_contributions' => t('grant.voluntary')] as $field => $label): ?> +
+
+
+
+ +
+ +
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + +
Notes
+ $year])) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

+
+
+
+
+
+
+ [],'valid' => 0,'total' => count(GrantService::REQUIRED),'complete' => false]; ?> +
+
+
+
+
+

+
+ (int)$check['valid'],'total' => (int)$check['total']])) ?> +
+
+
+
+
$item): $state = $item['state']; + $meta = match($state) { + 'valid' => ['✓','bg-green-lt',t('grant.valid')],'expiring' => ['⌛','bg-yellow-lt',t('grant.expiring')],'expired' => ['!','bg-red-lt',t('grant.expired')],default => ['×','bg-secondary-lt',t('grant.missing')] + }; ?>
+
+
+
+
+
+
+
+ +
+
+
+

+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ diff --git a/app/Views/admin_grants_print.php b/app/Views/admin_grants_print.php new file mode 100644 index 0000000..af08e61 --- /dev/null +++ b/app/Views/admin_grants_print.php @@ -0,0 +1,197 @@ + htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); +$m = static fn ($v) => number_format((float)$v, 2, ',', ' ') . ' €'; ?> + + + + + + <?= h(t('grant.annual_report', ['year' => $year])) ?> + + + + +
+ AppSettings::get('association_name'),'date' => $generatedAt->format('d/m/Y'),'time' => $generatedAt->format("H\hi")])) ?> +
+

$year])) ?>

+
· SIRET + · RNA
+

+
+ t('grant.entries'),'exits' => t('grant.exits'),'adoptions' => t('grant.adoptions'),'deaths' => t('grant.deaths'),'births' => t('grant.births'),'vaccinations' => t('grant.vaccinations'),'treatments' => t('export.treatments'),'current' => t('grant.current')] as $key => $label): ?> +
+

: + 0 ? $e(t('common.days', ['count' => round($stats['avg'])])) : $e(t('grant.not_calculable')) ?>. +

+

$year - 1])) ?>

+ + + + + + + + + + + t('grant.entries'),'exits' => t('grant.exits'),'adoptions' => t('grant.adoptions'),'deaths' => t('grant.deaths'),'births' => t('grant.births'),'vaccinations' => t('grant.vaccinations'),'treatments' => t('export.treatments')] as $key => $label): $c = $comparison[$key]; ?> + + + + + + +
0 ? '+' : '' ?> +
+

+
+ DeathController::causeLabel($key), array_keys(DeathController::CAUSES)))],[t('grant.adoption_stay'),$details['stays'],[]]] as [$title,$items,$labels]): ?> +
+

$total): ?> + + +

+

+
+

+ + + + + + + + + +
/ /
+ t('grant.report_activity'),'completed_actions' => t('grant.completed_actions'),'planned_actions' => t('grant.actions_planned'),'beneficiaries' => t('grant.beneficiaries_short'),'in_kind_support' => t('grant.in_kind_short')] as $key => $label): ?> +

+

+

+ + + + + + + + + + + + + + + + + +
+

+ + + + + + + + + + + + + + + + + +
+ + + diff --git a/app/Views/admin_register.php b/app/Views/admin_register.php new file mode 100644 index 0000000..7435e26 --- /dev/null +++ b/app/Views/admin_register.php @@ -0,0 +1,236 @@ + +
+
+


+ +
+ +
+
+
+ [t('admin.adoption_document'),'Version 1.5'], + 'abandon' => [t('admin.abandon_document'),'Version 1.0'], + 'benevole' => [t('admin.volunteer_document'),'Version 2.0'], + 'fa' => [t('admin.foster_document'),'Version 1.1'], + 'pre_adoption' => [t('admin.pre_adoption_document'),'Version 1.1'], +] as $documentType => $document): ?> +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#
+ + + + +
+ +
+ +
+ + ' : '' ?> + ' : '' ?> + ' : '' ?> + ' : '' ?> + + +
: + +
+
+ :
+ +
+ : +
+
+ :
+ +
: +
+ + + + + — + +
+
+
diff --git a/app/Views/admin_register_print.php b/app/Views/admin_register_print.php new file mode 100644 index 0000000..00c93c0 --- /dev/null +++ b/app/Views/admin_register_print.php @@ -0,0 +1,180 @@ + htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); +$speciesLabel = static fn ($value): string => match (strtolower((string)$value)) { + 'cat', 'chat' => t('species.cat'), 'dog', 'chien' => t('species.dog'), 'nac' => t('species.nac'), default => ucfirst((string)$value), +}; +?> + + + + + + + <?= $escape(t('pdf.register', ['year' => (int)$year])) ?> + + + + +
← +
+
+ AppSettings::get('association_name'),'date' => $generatedAt->format('d/m/Y'),'time' => $generatedAt->format('H\\hi')])) ?> +
+

(int)$year])) ?>

+
count($rows)])) ?>
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+




+
+
+ + + diff --git a/app/Views/agenda.php b/app/Views/agenda.php new file mode 100644 index 0000000..a89680c --- /dev/null +++ b/app/Views/agenda.php @@ -0,0 +1,561 @@ +modify('first day of this month'); +$offset = (int)$first->format('N') - 1; +$days = (int)$cursor->format('t'); +$trailing = (7 - (($offset + $days) % 7)) % 7; +$weekStart = $weekCursor->modify('monday this week'); +$weekEnd = $weekStart->modify('+6 days'); +$byDay = []; +foreach ($items as $item) { + $byDay[substr($item['instance_starts_at'], 0, 10)][] = $item; +}$edit = $editItem ?? []; +$editStart = !empty($edit['starts_at']) ? new DateTimeImmutable($edit['starts_at']) : null; +$colors = ['appointment' => 'blue','veterinary' => 'red','transport' => 'orange','pre_adoption' => 'purple','task' => 'green','other' => 'secondary','vaccine' => 'cyan','deworming' => 'yellow','treatment_start' => 'teal','treatment_end' => 'pink']; +$sourceTabs = ['vaccine' => 'tab-vac','deworming' => 'tab-med','treatment_start' => 'tab-trt','treatment_end' => 'tab-trt']; +$legend = ['organization' => ['appointment' => '#206bc4','veterinary' => '#d63939','transport' => '#f76707','pre_adoption' => '#ae3ec9','task' => '#2fb344'],'medical' => ['vaccine' => '#17a2b8','deworming' => '#f59f00','treatment_start' => '#0ca678','treatment_end' => '#d6336c']]; +?> + + +
+
+ +
+ +
+
+
+
+
+
+
×
+
+
+
+
+ $entries): ?>
+ $color): ?> +
+
+ 🔄 + + +
+

+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+

+ format('n') - 1] . ' ' . $cursor->format('Y') : $cursor->format('F Y')) ?> +

+
+
+
+ +
+
+ format('Y-m-') . str_pad((string)$day, 2, '0', STR_PAD_LEFT); ?> +
+
+ format('Y-m')) . '&edit=' . (int)$item['id']; ?>🐾 + +
+
+
+
+
+

+ $weekStart->format('d/m/Y'),'end' => $weekEnd->format('d/m/Y')])) ?> +

+
+
+
$label):$weekDate = $weekStart->modify('+' . $i . ' days'); ?>
format('d/m')) ?>
modify('+' . $i . ' days'); + $date = $weekDate->format('Y-m-d'); ?>
+
format('d/m')) ?>
+ format('Y-m-d')) . '&edit=' . (int)$item['id']; ?>🐾 + +
+
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ +
+
' . h($item['animal_name']) . '' : '—' ?> + + +
+
+
+
+
+ diff --git a/app/Views/animal_adoption.php b/app/Views/animal_adoption.php new file mode 100644 index 0000000..d24a1ed --- /dev/null +++ b/app/Views/animal_adoption.php @@ -0,0 +1,93 @@ + diff --git a/app/Views/animal_death_form.php b/app/Views/animal_death_form.php new file mode 100644 index 0000000..47f5386 --- /dev/null +++ b/app/Views/animal_death_form.php @@ -0,0 +1,99 @@ +
+
+
+
+

$animal['name']])) ?>

+
+
← + +
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+

+
+
+
+
+
+
+
+ +
+

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
diff --git a/app/Views/animal_form.php b/app/Views/animal_form.php new file mode 100644 index 0000000..6b5aedf --- /dev/null +++ b/app/Views/animal_form.php @@ -0,0 +1,717 @@ + t('status.refuge'),'fa' => t('status.fa'),'fa_permanente' => t('status.fa_permanente'),'chat_libre' => t('status.chat_libre'),'quarantaine' => t('status.quarantaine'),'soin' => t('status.soin'),'isolation' => t('status.isolation'),'hospitalise' => t('status.hospitalise'),'reserve' => t('status.reserve'),'adopte' => t('status.adopte'),'enfui' => t('status.enfui')]; +if (in_array((string)($animal['status'] ?? ''), ['decede','décédé'], true)) { + $statuses['decede'] = t('status.decede'); +} + +$sexLabels = [ + 'M' => t('sex.male'), +'F' => t('sex.female'), +'U' => t('animals.to_define'), +]; + +$hairTypes = [ + '' => t('animals.to_define'), + 'court' => t('animals.short_hair'), + 'mi-long' => t('animals.medium_hair'), + 'long' => t('animals.long_hair'), + 'frise' => t('animals.curly_hair'), + 'sans-poil' => t('animals.hairless'), +]; + +$lifeProfileOptions = [ + 'unknown' => t('animals.test_unknown'), + 'yes' => 'Oui', + 'no' => 'Non', +]; + +$coatColors = [ + 'Bicolore Noir / Blanc', + 'Bicolore Gris / Blanc', + 'Bicolore Roux / Blanc', + 'Bicolore Tigré et Blanc', + 'Gris', + 'Roux', + 'Noir', + 'Chocolat', + 'Blanc', + 'Tricolore / Calico', + 'Écaille de tortue', + 'Colourpoint / Siamois', + 'Tigré / Tabby', +]; + +$breed = (string)($animal['breed'] ?? ''); + + +$id = (int)($animal['id'] ?? 0); +$isEdit = $id > 0; +$isArchivedEdit = $isEdit && !empty($animal['archived_at']); + +$status = (string)($animal['status'] ?? 'refuge'); +$sex = (string)($animal['sex'] ?? 'U'); + +$birth_is_estimated = (int)($animal['birth_is_estimated'] ?? 0); +$birth_estimated_months = (int)($animal['birth_estimated_months'] ?? 0); + +$estYears = $birth_estimated_months > 0 ? intdiv($birth_estimated_months, 12) : ''; +$estMonths = $birth_estimated_months > 0 ? ($birth_estimated_months % 12) : ''; + +?> +
+
+
+ +
+ + +
📦
+ +
+ + +
+
+
+
+
+ +

+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + > +
+
+ + +
+ + + +
+ +
+ + +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+ disabled> +
+
+
+
+
+
+ + +
+
+
+
+
+ +
+

+
+
+
+
+ ['🐶',t('animal.compatible_dogs')], + 'compatibility_cats' => ['🐱',t('animal.compatible_cats')], + 'compatibility_children' => ['🧒',t('animal.compatible_children')], + 'house_trained' => ['🧼',t('animal.house_trained')], +] as $field => $profile): ?> +
+ + +
+ +
+
+
+
+ + +
+
+
+
+
+ +

+
+ +
+
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+
+ +

+
+ +
+
+ + +
+ + + +
+ +
+ + +
+ + + +
+
+
+
+
+ + +
+
+
+
+
+ +
+

+
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ AppSettings::int('quarantine_days', 1, 90)])) ?> +
+
+ +
+ + +
+
+ +
+
+
+ +
+
+ + +
+
+
+ + diff --git a/app/Views/animal_history.php b/app/Views/animal_history.php new file mode 100644 index 0000000..7cbe38d --- /dev/null +++ b/app/Views/animal_history.php @@ -0,0 +1,36 @@ +
+
+

+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+
diff --git a/app/Views/animal_pdf.php b/app/Views/animal_pdf.php new file mode 100644 index 0000000..149e73c --- /dev/null +++ b/app/Views/animal_pdf.php @@ -0,0 +1,276 @@ + 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + default => 'application/octet-stream' + }; + return "data:$mime;base64," . base64_encode($data); +} + +?> + + + + + + <?= h($title) ?> + + + + + +

$animal['name'] ?? ''])) ?>

+
+ date('d/m/Y H:i')])) ?> +
+ +

+ + + + + + + + + + + + + + + + + + + +
t('species.cat'), 'dog', 'chien' => t('species.dog'), 'nac' => t('species.nac'), default => (string)($animal['species'] ?? '') + }) ?>
+ + +

+
+ + +
+ +
+ +
+ +
+ + +

+ +
+ + + + + + + + + + + + + + + + +
+ : +
+ : + kg
+ : + °C
+ : +
+ : +
+ : +
+ : +
+
+
+ +
+ + +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ : +
+ : +
+ +
+ + +

+ +
+ + + + + + + + + + + + + + + + + + +
+
+ +
+ :
+ +
+ + + + + diff --git a/app/Views/animal_pdf_v2.php b/app/Views/animal_pdf_v2.php new file mode 100644 index 0000000..eced84a --- /dev/null +++ b/app/Views/animal_pdf_v2.php @@ -0,0 +1,569 @@ + 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + default => 'application/octet-stream' + }; + return "data:$mime;base64," . base64_encode($data); +} + +function labelStatus(string $s): string +{ + return match ($s) { + 'refuge' => t('status.refuge'), + 'fa' => t('status.fa'), + 'fa_permanente' => t('status.fa_permanente'), + 'quarantaine' => t('status.quarantaine'), + 'soin' => t('status.soin'), + 'isolation' => t('status.isolation'), + 'hospitalise','hospitalisé' => t('status.hospitalise'), + 'reserve','réservé' => t('status.reserve'), + 'adopte','adopté' => t('status.adopte'), + 'decede','décédé' => t('status.decede'), + 'enfui' => t('status.enfui'), + default => $s, + }; +} + +function fivFelvText(array $a): string +{ + // adapte selon tes colonnes réelles (chez toi ça marche déjà côté liste) + $fiv = (string)($a['fiv'] ?? ''); + $felv = (string)($a['felv'] ?? ''); + if ($fiv === '' && $felv === '') { + return t('animals.test_unknown'); + } + if ($fiv === '0' && $felv === '0') { + return t('animals.test_negative'); + } + if ($fiv === '1' && $felv === '1') { + return 'FIV/FELV+'; + } + if ($fiv === '1') { + return 'FIV+'; + } + if ($felv === '1') { + return 'FELV+'; + } + // fallback : si tu stockes déjà du texte + return trim($fiv . ' ' . $felv); +} + +function kindClass(string $k): string +{ + $k = strtolower(trim($k)); + return match($k) { + 'consult' => 'k-consult', + 'contrôle','controle' => 'k-control', + 'hospi','hospitalisation' => 'k-hospi', + 'chirurgie' => 'k-surgery', + 'analyse','analyses' => 'k-lab', + 'vaccin' => 'k-vaccine', + default => 'k-default' + }; +} + + +$mainPhotoUri = null; +if (!empty($primary_photo)) { + $mainPhotoUri = fileToDataUri($photoDir . '/' . $primary_photo); +} +?> + + + + + + <?= h(t('pdf.medical_record', ['name' => $animal['name'] ?? ''])) ?> + + + + + + +
+ +

$animal['name'] ?? ''])) ?>

+
+ date('d/m/Y H:i')])) ?> • : + +
+ + + + +
+ ⚠️ : + + +
+ + + + + + + + + + + +
+ (($deathDate = DateTimeImmutable::createFromFormat('Y-m-d', (string)$death['deceased_date'])) ? $deathDate->format('d/m/Y') : t('pdf.unknown_date'))])) ?>
+ : +
+ +
+
+ + +
+ +
+
+ + +
:
+
: t('species.cat'), 'dog', 'chien' => t('species.dog'), 'nac' => t('species.nac'), default => (string)($animal['species'] ?? '') + }) ?>
+
:
+
: +
+
: t('animal.microchip'),'tattoo' => t('animal.tattoo'),default => t('common.not_provided') + }) ?>
+
FIV/FELV :
+ + +
+
:
+
: +
+ +
+
+
+
+
+ +
+ + + + + + +
:
+ +
: kg
+ +
:
+ +
:
+ + +
+ +
+ +
+ + + + + + • + +
+ + +
+
+
+ +
+ +
+
+ AppSettings::get('association_name')])) ?> +
+
+ +

+ +
+ + + + + + + + + + + + + + + + +
+ + + : +
+ : + kg
+ : +
+ : +
+ : +
+ : +
+ + + +
+ :
+ + + + +
+ +
+
+ + +
+ + +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+ +
+ : +
+ : +
+ +
+ + +

+ +
+ + + + + + + + + + + + + + + + + + +
+
+ +
+ :
+ +
+ + + + + diff --git a/app/Views/animal_show.php b/app/Views/animal_show.php new file mode 100644 index 0000000..879eb07 --- /dev/null +++ b/app/Views/animal_show.php @@ -0,0 +1,488 @@ +getCachedByAnimal((int)$animal['id']); +$stale = $icadSvc->isStale($cached, 86400); + +// variable attendue par ta vue +$icad = [ + 'cached' => $cached, +'stale' => $stale, +]; + +$statusClass = [ +'refuge' => 'bg-blue-lt text-blue', +'fa' => 'bg-cyan-lt text-cyan', +'fa_permanente' => 'bg-indigo-lt text-indigo', +'chat_libre' => 'bg-green-lt text-green', +'reserve' => 'bg-purple-lt text-purple', +'adopte' => 'bg-green-lt text-green', +'fugue' => 'bg-orange-lt text-orange', +'decede' => 'bg-dark-lt text-dark', +'quarantaine' => 'bg-yellow-lt text-yellow', +'soin' => 'bg-red-lt text-red', +][$animal['status']] ?? 'bg-secondary-lt text-secondary'; + +$statusLabel = status_badge((string)($animal['status'] ?? ''))[1]; + +$historyStyles = [ +'create' => ['bg' => 'bg-green', 'icon' => 'plus'], +'update' => ['bg' => 'bg-blue', 'icon' => 'edit'], +'status' => ['bg' => 'bg-purple', 'icon' => 'refresh'], +'medical' => ['bg' => 'bg-red', 'icon' => 'heart'], +'vaccine' => ['bg' => 'bg-yellow', 'icon' => 'shield-check'], +'deworming' => ['bg' => 'bg-lime', 'icon' => 'pill'], +'treatment' => ['bg' => 'bg-orange', 'icon' => 'pill'], +'care_round' => ['bg' => 'bg-red', 'icon' => 'alert-triangle'], +'death' => ['bg' => 'bg-dark', 'icon' => 'rainbow'], +'photo' => ['bg' => 'bg-teal', 'icon' => 'camera'], +]; + +switch ($animal['sex']) { + case 'M': $animal['sex'] = t('sex.male'); + $sex_color = '367EB3'; + break; + case 'F': $animal['sex'] = t('sex.female'); + $sex_color = 'A736B3'; + break; + default: $animal['sex'] = t('common.unknown'); + $sex_color = '36B362'; +} + +?> + + + + + +
+
+
+
📦 +
+
+ !empty($animal['archived_at']) ? t('animal.archived_on', ['date' => date('d/m/Y H:i', strtotime((string)$animal['archived_at']))]) : ''])) ?> +
+
+
+ [t('animals.test_unknown'), 'bg-secondary-lt'], + 'neg' => [t('animals.test_negative'), 'bg-green-lt'], + 'pos' => [t('animals.test_positive'), 'bg-red-lt'], + 'doubtful' => [t('animals.test_doubtful'), 'bg-yellow-lt'], +]; + +$alertText = []; +if ($fivPos) { + $alertText[] = 'FIV+'; +} +if ($felvPos) { + $alertText[] = 'FeLV+'; +} +?> + + + + +
+
+
+ + + +
+ 🐾 +
+ +
+
+ +
+

+ + + (float)$prevWeight) { + $arrow = '↑'; + } elseif ((float)$lastWeight < (float)$prevWeight) { + $arrow = '↓'; + } + } +?> + kg + + +

+ +
+ : + + • :
+ : + • : + + + + + • : t('animal.hair_short'), + 'mi-long' => t('animal.hair_medium'), + 'long' => t('animal.hair_long'), + 'frise' => t('animal.hair_curly'), + 'sans-poil' => t('animal.hairless'), + default => '—', + }) ?>
+ t('animal.microchip'),'tattoo' => t('animal.tattoo'),default => t('animal.identification_unknown') + }) ?> + · + date('d/m/Y', strtotime((string)$animal['identification_date']))])) ?> +
+ +
+ : + + + + • : > + + + • : + + + + • FIV: + + + + • FeLV: + + + +
+ t('animal.present_on_site'),'missing' => t('animal.missing'),'moved' => t('animal.moved'),'deceased' => t('status.decede')]; ?> +
+
+
🌿 + +
+
+

+
+

+
+

+ → + +

+
+
+
+
+ + +
+ [t('animal.sterilized'),'bg-green-lt'],'no' => [t('animals.not_sterilized'),'bg-yellow-lt'],'unknown' => [t('animals.sterilization_unknown'),'bg-secondary-lt']]; + [$sterilizationLabel,$sterilizationClass] = $sterilizationMeta[$sterilizationState] ?? $sterilizationMeta['unknown']; ?> + + + + + + +
+ + [t('animal.availability_define'),'bg-secondary-lt'],'not_available' => [t('animals.unavailable_title'),'bg-orange-lt'],'available' => [t('animals.available_title'),'bg-green-lt']]; +[$availabilityLabel,$availabilityClass] = $availabilityMeta[$availability] ?? $availabilityMeta['unknown']; +$registrationLabels = ['unknown' => t('animal.registration_unknown'),'pending' => t('animal.registration_pending'),'registered' => t('animal.registration_done')]; +$registration = $animal['identification_registration_status'] ?? 'unknown'; +$registrationClass = $registration === 'registered' ? 'bg-green-lt' : ($registration === 'pending' ? 'bg-yellow-lt' : 'bg-secondary-lt'); ?> +
🌐 + date('d/m/Y', strtotime((string)$animal['adoption_available_from']))])) ?> +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + ✏️ + 🖨 +
+
+ + + ← + + +
+
+
+ + + +
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/app/Views/animals_inactive.php b/app/Views/animals_inactive.php new file mode 100644 index 0000000..5f1e568 --- /dev/null +++ b/app/Views/animals_inactive.php @@ -0,0 +1,168 @@ + ['✓','text-green',t('common.yes')],'no' => ['✕','text-red',t('common.no')],default => ['—','text-secondary',t('common.unknown')] + }; + return '' . $symbol . ''; +}; +$testBadge = static function (string $value, string $label): string { + [$symbol,$class,$title] = match($value) { + 'neg' => ['−','bg-green-lt',t('animals.test_negative')],'pos' => ['+','bg-red-lt',t('animals.test_positive')],'doubtful' => ['?','bg-yellow-lt',t('animals.test_doubtful')],default => ['?','bg-secondary-lt',t('animals.test_unknown')] + }; + return '' . h($label . ' ' . $symbol) . ''; +}; +?> + +
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + diff(new DateTimeImmutable('today')); + $ageLabel = $age->y > 0 ? $age->y . ' a' . ($age->m ? ' · ' . $age->m . ' m' : '') : $age->m . ' m'; + if (!empty($animal['birth_is_estimated'])) { + $ageLabel = '~ ' . $ageLabel; + } + } catch (Throwable) { + } + } + $sex = match((string)($animal['sex'] ?? 'U')) { + 'M' => ['♂','bg-blue-lt text-blue',t('sex.male')],'F' => ['♀','bg-purple-lt text-purple',t('sex.female')],default => ['?','bg-secondary-lt text-secondary',t('sex.unknown')] + }; + $removed = $animal[$isTrash ? 'deleted_at' : 'archived_at'] ?? null; + $removedDate = $removed ? (new DateTimeImmutable((string)$removed))->format('d/m/Y H:i') : '—'; + ?> + + + + + + + + + + + + + + + + +
· + +
['🐶',t('animals.dogs')],'compatibility_cats' => ['🐱',t('animals.cats')],'house_trained' => ['🧼',t('animals.cleanliness')]] as $key => [$icon,$label]):$qs = $_GET; + $qs['sort'] = $key; + $qs['dir'] = $sort === $key && $dir === 'ASC' ? 'desc' : 'asc'; ?> +
+
FIV/FeLV +
+
+
+
+ +
+
+ +
(float)$animal['prev_weight'] ? '↑' : ((float)$animal['last_weight'] < (float)$animal['prev_weight'] ? '↓' : '→'); + } ?> kg + +
+
+
+
+
+
diff --git a/app/Views/animals_list.php b/app/Views/animals_list.php new file mode 100644 index 0000000..5c5c7d6 --- /dev/null +++ b/app/Views/animals_list.php @@ -0,0 +1,918 @@ + + + + +
+
+
+ + + +
+ +
+ +
+ +
+ +
+
+ +
+ 🐈 +
+
+ 🐾 +
+
+ + + +
+ +
🔎 + : count($animals)])) ?>
+ + + + +
+ species_label($mosaicSpeciesCode),'icon' => '🐾','color' => '#6c757d']; + [$mosaicStatusClass,$mosaicStatusLabel] = status_badge((string)($a['status'] ?? '')); + [$mosaicSexSymbol,$mosaicSexClass,$mosaicSexLabel] = match((string)($a['sex'] ?? 'U')) { + 'M' => ['♂','bg-blue-lt text-blue',t('sex.male')],'F' => ['♀','bg-purple-lt text-purple',t('sex.female')],default => ['?','bg-secondary-lt text-secondary',t('sex.unknown')] + }; + $mosaicFoster = in_array((string)($a['status'] ?? ''), ['fa','fa_permanente'], true) ? $shortContactName($a['foster_contact_name'] ?? null) : ''; + $mosaicFreeCat = (string)($a['status'] ?? '') === 'chat_libre' ? trim((string)($a['free_cat_site_name'] ?: $a['free_cat_city'])) : ''; + ?> +
+ <?= h(t('animals.photo_of', ['name' => $a['name']])) ?> +
+
+
+
+ +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + species_label($animalSpecies),'icon' => '🐾','color' => '#6c757d']; + $isCat = SpeciesService::isCat($animalSpecies); + + $fiv = $a['fiv'] ?? null; + $felv = $a['felv'] ?? null; + $fivStatus = (string)(($a['fiv_status'] ?? '') ?: ((int)$fiv === 1 ? 'pos' : 'neg')); + $felvStatus = (string)(($a['felv_status'] ?? '') ?: ((int)$felv === 1 ? 'pos' : 'neg')); + $shortStatusLabels = ['unknown' => mb_strtolower(t('animals.test_unknown')), 'neg' => mb_strtolower(t('animals.test_negative')), 'pos' => mb_strtolower(t('animals.test_positive')), 'doubtful' => mb_strtolower(t('animals.test_doubtful'))]; + $healthLabel = $isCat ? ('FIV ' . ($shortStatusLabels[$fivStatus] ?? mb_strtolower(t('animals.test_unknown'))) + . ' · FeLV ' . ($shortStatusLabels[$felvStatus] ?? mb_strtolower(t('animals.test_unknown')))) : t('animals.tests_not_applicable'); + $testBadgeMeta = [ + 'unknown' => ['?', 'bg-secondary-lt', t('animals.test_unknown')], + 'neg' => ['−', 'bg-green-lt', t('animals.test_negative')], + 'pos' => ['+', 'bg-red-lt', t('animals.test_positive')], + 'doubtful' => ['?', 'bg-yellow-lt', t('animals.test_doubtful')], + ]; + [$fivSymbol, $fivBadgeClass, $fivBadgeTitle] = $testBadgeMeta[$fivStatus] ?? $testBadgeMeta['unknown']; + [$felvSymbol, $felvBadgeClass, $felvBadgeTitle] = $testBadgeMeta[$felvStatus] ?? $testBadgeMeta['unknown']; + + $ageLabel = '—'; + $ageTitle = t('animals.age_unknown'); + if (!empty($a['birth_date'])) { + try { + $birth = new DateTimeImmutable((string)$a['birth_date']); + $age = $birth->diff(new DateTimeImmutable('today')); + if ($age->y > 0) { + $ageLabel = $age->y . ' a'; + if ($age->m > 0) { + $ageLabel .= ' · ' . $age->m . ' m'; + } + $ageTitle = $age->y . ' an' . ($age->y > 1 ? 's' : ''); + if ($age->m > 0) { + $ageTitle .= ' et ' . $age->m . ' mois'; + } + } else { + $ageLabel = $age->m . ' m'; + $ageTitle = $age->m . ' mois'; + } + if (!empty($a['birth_is_estimated'])) { + $ageLabel = '~ ' . $ageLabel; + $ageTitle .= ' (' . t('animals.age_estimated') . ')'; + } + } catch (Throwable) { + $ageLabel = '—'; + } + } + + $sexLabel = match ((string)($a['sex'] ?? 'U')) { + 'M' => t('sex.male'), + 'F' => t('sex.female'), + default => t('animals.sex_not_provided'), + }; + + $hairLabel = match ((string)($a['hair_type'] ?? '')) { + 'court' => t('animals.short_hair'), + 'mi-long' => t('animals.medium_hair'), + 'long' => t('animals.long_hair'), + 'frise' => t('animals.curly_hair'), + 'sans-poil' => t('animals.hairless'), + default => t('animals.hair_unknown'), + }; + $appearance = implode(' · ', [ + (string)($a['breed'] ?: t('animals.breed_unknown')), + (string)($a['color'] ?: t('animals.coat_unknown')), + $hairLabel, + ]); + + $weightLabel = t('animals.weight_unknown'); + if ($a['last_weight'] !== null) { + $trend = '→'; + if ($a['prev_weight'] !== null) { + if ((float)$a['last_weight'] > (float)$a['prev_weight']) { + $trend = '↑'; + } elseif ((float)$a['last_weight'] < (float)$a['prev_weight']) { + $trend = '↓'; + } + } + $weightLabel = number_format((float)$a['last_weight'], 2, ',', ' ') . ' kg ' . $trend; + } + + $vaccineLabel = !empty($a['next_vaccine_due']) + ? (($a['next_vaccine_name'] ?: t('animal.vaccine_booster')) . ' · ' . $a['next_vaccine_due']) + : t('animals.no_vaccine'); + $treatmentLabel = $a['ongoing_treatment_names'] ?: t('animals.no_current_treatment'); + $medicalPoint = $a['latest_medical_point'] ?: t('animals.no_recent_medical'); + $profileMark = static function (string $value, string $label): string { + [$symbol,$class,$state] = match($value) { + 'yes' => ['✓','life-profile-yes',t('common.yes')], + 'no' => ['✕','life-profile-no',t('common.no')], + default => ['—','life-profile-unknown',t('common.unknown')], + }; + return '' . h($symbol) . ''; + }; + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + · + + + +
['🐶',t('animals.compatible_dogs')],'compatibility_cats' => ['🐱',t('animals.compatible_cats')],'compatibility_children' => ['🧒',t('animals.compatible_children')],'house_trained' => ['🧼',t('animals.cleanliness')]] as $profileSortKey => $profileHeader): $profileQs = $_GET; + $profileQs['sort'] = $profileSortKey; + $profileQs['dir'] = ($sort === $profileSortKey && strtoupper($dir) === 'ASC') ? 'desc' : 'asc'; + $profileArrow = $sort === $profileSortKey ? (strtoupper($dir) === 'ASC' ? '↑' : '↓') : ''; ?> +
+
+
+
+
+
+
+
+
+
+ + + + +
+ + + + + + + + + ['♂', 'bg-blue-lt text-blue', t('sex.male')], + 'F' => ['♀', 'bg-purple-lt text-purple', t('sex.female')], + default => ['?', 'bg-secondary-lt text-secondary', t('sex.unknown')], + }; + ?> + + 🔗 + + + + + + + + + + +
+
+ + + + +
+
+ +
🌿 +
+
+
+ +
+
+ +
+ FIV + + FeLV + +
+ +
+
+
+
+ +
+
+ +
+ + + + + + diff --git a/app/Views/auth.php b/app/Views/auth.php new file mode 100644 index 0000000..f4c2d6b --- /dev/null +++ b/app/Views/auth.php @@ -0,0 +1,229 @@ + + + + + + + <?= htmlspecialchars($title) ?> — Globinours + + + + + + + + + +
+
+

Globinours

+
+
+ +
+ + + + diff --git a/app/Views/bonded_form.php b/app/Views/bonded_form.php new file mode 100644 index 0000000..73a27c9 --- /dev/null +++ b/app/Views/bonded_form.php @@ -0,0 +1,47 @@ +
+ +
+
+

+
+
+ +
+
+
+
+ + +
+
+ + +
+
+

+
+ + +
+ +
+ +
+
+ +
diff --git a/app/Views/care_round.php b/app/Views/care_round.php new file mode 100644 index 0000000..2e41068 --- /dev/null +++ b/app/Views/care_round.php @@ -0,0 +1,415 @@ + ['top_ab' => t('care.box_top_left_double'),'top_c' => t('care.box_top_right')],'right2' => ['top_a' => t('care.box_top_left'),'top_bc' => t('care.box_top_right_double')],'merged' => ['top_all' => t('care.box_top_full')],default => ['top_a' => t('care.box_top_left'),'top_b' => t('care.box_top_center'),'top_c' => t('care.box_top_right')] + }; + $bottom = $layout['bottom_layout'] === 'merged' ? ['bottom_all' => t('care.box_bottom_full')] : ['bottom_a' => t('care.box_bottom_left'),'bottom_b' => t('care.box_bottom_right')]; + return $top + $bottom + ['' => t('care.box_unassigned')]; +} +$roomsByCode = []; +foreach ($rooms as $configuredRoom) { + $roomsByCode[$configuredRoom['code']] = $configuredRoom; +} +$byRoom = []; +foreach ($animals as $a) { + $byRoom[ShelterRoomService::resolveAnimal($a, $roomsByCode)][] = $a; +} +$fieldOptions = [ + 'food' => ['normal' => t('care.food_normal'),'little' => t('care.food_little'),'refused' => t('care.food_refused')], + 'water' => ['normal' => t('care.water_normal'),'renewed' => t('care.water_renewed'),'abnormal' => t('care.abnormal')], + 'urine' => ['normal' => t('care.urine_normal'),'absent' => t('care.absent_feminine'),'abnormal' => t('care.abnormal')], + 'stool' => ['normal' => t('care.stool_normal'),'absent' => t('care.absent_feminine'),'abnormal' => t('care.abnormal')], + 'general_state' => ['good' => t('care.state_good'),'watch' => t('care.watch'),'alert' => t('care.alert')], +]; +?> + +
+
+
+ t($period === 'morning' ? 'care.morning' : 'care.evening')])) ?> + ☀️ + 🌙 +
+ + ⚙️ +
+
+
+ +
+
+

+
+
+
+
+ $roomLabel]; + if ($boxed && !array_key_exists('', $boxes)) { + $boxes += ['' => t('care.box_unassigned')]; + } ?> +
+ $boxLabel): $knownBoxKeys = array_values(array_filter(array_keys($boxes), static fn ($key) => $key !== '')); + $boxAnimals = array_values(array_filter($roomAnimals, fn ($a) => !$boxed || ($boxKey === '' ? !in_array((string)($a['care_box_key'] ?? ''), $knownBoxKeys, true) : ($a['care_box_key'] ?? '') === $boxKey))); ?> + isset($observations[(int)$cat['id']]))) === count($boxAnimals); ?> +
+
· + ' . h(t('care.finished')) . '' : '' ?> +
+ +
+ 💊💊 ⚠ + +
+ + $options): ?>
+ $label): ?> +
+
+
💊 +
+
+
+
+
+
+
+
+
+ +
+
⚠ +
+
+
+
+
+ +
+
+ +
+
+
+
+ diff --git a/app/Views/care_setup.php b/app/Views/care_setup.php new file mode 100644 index 0000000..abf4928 --- /dev/null +++ b/app/Views/care_setup.php @@ -0,0 +1,107 @@ + t('care.box_top_left'),'top_b' => t('care.box_top_center'),'top_c' => t('care.box_top_right'),'top_ab' => t('care.box_top_left_double'),'top_bc' => t('care.box_top_right_double'),'top_all' => t('care.box_top_full'),'bottom_a' => t('care.box_bottom_left'),'bottom_b' => t('care.box_bottom_right'),'bottom_all' => t('care.box_bottom_full')]; +$roomsByCode = []; +foreach ($rooms as $room) { + $roomsByCode[$room['code']] = $room; +} +?> +
+
+
+

+
+
+ +
+
+
+ ($roomsByCode['care']['name'] ?? t('care.medical_room')),'quarantine' => ($roomsByCode['quarantine']['name'] ?? t('care.quarantine_room'))] as $room => $label): if (!isset($layouts[$room])) { + continue; + }$layout = $layouts[$room]; ?> +
+
+
+

+
+
+
+
+
+
+
+
+

+
+ + + + + + + + + + + + + + + + +
+
+
+
+ +
+ diff --git a/app/Views/dashboard.php b/app/Views/dashboard.php new file mode 100644 index 0000000..dd8b24b --- /dev/null +++ b/app/Views/dashboard.php @@ -0,0 +1,591 @@ + 'bg-blue-lt', + 'controle' => 'bg-green-lt', + 'symptome' => 'bg-yellow-lt', + 'diagnostic' => 'bg-purple-lt', + 'acte' => 'bg-orange-lt', + 'hosp' => 'bg-red-lt', + default => 'bg-secondary-lt', + }; +} + +function badgeStatus(string $status): array +{ + // [class, label] + return match ($status) { + 'refuge' => ['bg-blue-lt',t('status.refuge')], + 'fa' => ['bg-cyan-lt',t('status.fa')], + 'fa_permanente' => ['bg-indigo-lt',t('status.fa_permanente')], + 'quarantaine' => ['bg-yellow-lt',t('status.quarantaine')], + 'soin' => ['bg-red-lt',t('status.soin')], + 'isolation' => ['bg-orange-lt',t('status.isolation')], + 'hospitalise', 'hospitalisé' => ['bg-red-lt',t('status.hospitalise')], + 'reserve', 'réservé' => ['bg-purple-lt',t('status.reserve')], + 'adopte', 'adopté' => ['bg-green-lt',t('status.adopte')], + 'decede', 'décédé' => ['bg-dark-lt',t('status.decede')], + 'enfui' => ['bg-secondary-lt',t('status.enfui')], + default => ['bg-secondary-lt', $status], + }; +} +?> + $q['days_left'] !== null && (int)$q['days_left'] <= 0); +$weightLosses = array_filter($weight_alerts ?? [], static fn (array $w): bool => (float)$w['pct'] < 0); +$urgentCount = count($vaccines_overdue) + count($treatments_overdue) + count($expiredQuarantines) + count($weightLosses) + count($careAlertsToday) + count($agendaDue); +$morning = $careToday['morning'] ?? ['expected' => 0,'checked' => 0,'alerts' => 0]; +$evening = $careToday['evening'] ?? ['expected' => 0,'checked' => 0,'alerts' => 0]; +?> +
+
+
+
+
+
+

+ +

+
+
+
+ + $alert): ?> + () +
+
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+

+ +
+ +
+
+
+
+
+
+
+ + + 0): ?> + + + +
+
+
+
+ +
+
+
+
+
+
+ + + 0): ?> + + + +
+
+
+
+ +
+
+
+ +
+
+
+ + + + +
+
+
+
+ +
+
+
+ +
+
+
+ + + + +
+
+
+
+
+ + +
+

+
+
+
+
+
+
+
+

+ 0): ?> +

+
+
+ +
+
+
+ 0): ?>
+
📁
+
+
+ (int)($grantAlerts['overdue'] ?? 0),'deadlines' => (int)($grantAlerts['deadlines'] ?? 0),'incomplete' => (int)($grantAlerts['incomplete'] ?? 0),'documents' => (int)($grantAlerts['documents'] ?? 0)])) ?> +
+
+
+ + +
+

+ +
+ +
+
+
+
+

🚨

+
+
+ + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+

+
+
+ + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+

📅

+
+
+ + + + + + + + + + + + + + +
+
+
+
+
+ + +
+
+
+
+

🟨

+
+
+ + + + + + + + + + + + + + + + abs($days)]); + } elseif ($days === 0) { + $timerClass = 'text-warning fw-bold'; + $timerText = t('dashboard.exit_today'); + } elseif ($days === 1) { + $timerClass = 'text-warning fw-bold'; + $timerText = t('dashboard.exit_tomorrow'); + } elseif ($days <= 5) { + $timerClass = 'text-warning fw-bold'; + $timerText = t('dashboard.exit_days', ['days' => $days]); + } else { + $timerClass = 'text-success'; + $timerText = t('dashboard.exit_days', ['days' => $days]); + } + } + ?> + + + + + + + + +
+
+ +
+
+
+ +
+
+
+
+

📉📈

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
J-30
kg () + kg 0 ? '+' : '') . h($w['pct']) ?>%
+
+
+
+
+ + + +
+
+
+
+

⚠️

+
+
+ + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + +
+ +
+
+
+
+ +
+ + +
+

+ +
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/app/Views/directory_form.php b/app/Views/directory_form.php new file mode 100644 index 0000000..e2e62f1 --- /dev/null +++ b/app/Views/directory_form.php @@ -0,0 +1,71 @@ +
+
+

+
+
+
+
+
+
+
$label): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ diff --git a/app/Views/directory_list.php b/app/Views/directory_list.php new file mode 100644 index 0000000..a74c238 --- /dev/null +++ b/app/Views/directory_list.php @@ -0,0 +1,130 @@ + 'cyan','benevole' => 'green','dirigeant' => 'purple','veterinaire' => 'red','cabinet' => 'orange','crematorium' => 'dark','adoptant' => 'blue','deposant' => 'yellow','fourriere' => 'azure']; +?> + +
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ 0): ?> + · 🐾 + 0): ?> + · +
+
+
+ +
+

+
+ + +
+
+
+ diff --git a/app/Views/directory_show.php b/app/Views/directory_show.php new file mode 100644 index 0000000..50bb429 --- /dev/null +++ b/app/Views/directory_show.php @@ -0,0 +1,135 @@ + 'cyan','benevole' => 'green','dirigeant' => 'purple','veterinaire' => 'red','cabinet' => 'orange','crematorium' => 'dark','adoptant' => 'blue','deposant' => 'yellow','fourriere' => 'azure']; +$contactRoles = array_filter(explode(',', (string)$contact['roles'])); +$money = static fn (int$cents): string => number_format($cents / 100, 2, ',', ' ') . ' €'; +$frDate = static function (?string$date): string { + if (!$date) { + return'—'; + }try { + return(new DateTimeImmutable($date))->format('d/m/Y'); + } catch (Throwable) { + return$date; + } +}; +$animalRows = static function (array$rows, string$title, string$color, string$icon) use ($frDate): void { + if (!$rows) { + return; + } ?>
+
+
+

+
+ +
+ +
+
+
+
+
+
+
+ +
+

+
+
+
+
+
: +
+
+
+
+
+
+
+
+
+

+
+
+
📍
+
+
+
+
+
+
+
+
+
+
· + + ·
+
+
(int)$expenseCount])) ?> +
+
+
💶
+
+
+
+ +
+
+

+
+
+
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/app/Views/directory_show_accounting.php b/app/Views/directory_show_accounting.php new file mode 100644 index 0000000..c92bfdd --- /dev/null +++ b/app/Views/directory_show_accounting.php @@ -0,0 +1,54 @@ + + +
+
+
+

🧾 Facturation comptable

Ouvrir Comptabilité +
+
+
+
+
Facturé net
+
+
+
+
Payé
+
+
+
+
Reste dû
+
+
+
+
Pièces
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
DateRéférenceTypeStatutTTC
+ 'Facture','credit' => 'Avoir','adjustment' => 'Régularisation'][$invoice['document_type']] ?? $invoice['document_type']) ?> + 'Payée','to_pay' => 'À payer','partial' => 'Partielle','disputed' => 'Contestée','cancelled' => 'Annulée'][$invoice['status']] ?? $invoice['status']) ?> +
+
+
+ diff --git a/app/Views/evolutions.php b/app/Views/evolutions.php new file mode 100644 index 0000000..d7102bd --- /dev/null +++ b/app/Views/evolutions.php @@ -0,0 +1,52 @@ +
+
+ Globinours · Version +

Évolutions du projet

+

Cette page présente les grandes étapes du produit. Elle ne reprend aucune donnée, + statistique ni information issue d’une installation réelle.

+
+
+ +
+
+
+
+ Socle livré +

Gestion quotidienne du refuge

+

Fiches animales, annuaire, placements, adoptions, tournées, agenda, suivi médical, + pharmacie, documents et statistiques.

+
+
+
+
+
+
+ Protection +

Sécurité et continuité

+

Permissions, audit, médias privés, sauvegardes vérifiées, restauration contrôlée + et outils de maintenance.

+
+
+
+
+
+
+ 1.0.0-rc.3 +

Stabilisation avant la 1.0

+

Découpage des fichiers volumineux, recherche universelle, correction de + l’installation vierge, anonymisation du dépôt, fiche Twix fictive complète et harmonisation des + sources PHP.

+
+
+
+
+
+
+ Après la 1.0 +

Prochaines étapes

+

Tests HTTP plus complets, validation des paquets d’installation et amélioration + continue des parcours.

+
+
+
+
diff --git a/app/Views/layout.php b/app/Views/layout.php new file mode 100644 index 0000000..c664e66 --- /dev/null +++ b/app/Views/layout.php @@ -0,0 +1,382 @@ + t('meta.dashboard'), + $statisticsActive => t('meta.statistics'), + $adminActive => t('meta.administrative'), + $animalsActive => t('meta.animals'), + default => t('meta.default'), + }; +} +?> + + + + + + + <?= h($documentTitle) ?> + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + diff --git a/app/Views/litter_add_existing.php b/app/Views/litter_add_existing.php new file mode 100644 index 0000000..657cbc4 --- /dev/null +++ b/app/Views/litter_add_existing.php @@ -0,0 +1,66 @@ +format('d/m/Y'); + } +} +?> +
+ + + +
+
+

+
+ $dateLabel,'mother' => $litter['mother_name'] ?: t('common.unknown_f'),'father' => $litter['father_name'] ?: t('common.unknown_m')])) ?> +
+
+ +
+
+
+ + +
+
+
+ +
+ +
+ +
+
+
+
+
+ +
+ diff --git a/app/Views/litter_form.php b/app/Views/litter_form.php new file mode 100644 index 0000000..b70f2f4 --- /dev/null +++ b/app/Views/litter_form.php @@ -0,0 +1,226 @@ + t('coat.black_white'), 'Bicolore Gris / Blanc' => t('coat.grey_white'), 'Bicolore Roux / Blanc' => t('coat.ginger_white'), 'Bicolore Tigré et Blanc' => t('coat.tabby_white'), + 'Gris' => t('coat.grey'), 'Roux' => t('coat.ginger'), 'Noir' => t('coat.black'), 'Chocolat' => t('coat.chocolate'), 'Blanc' => t('coat.white'), 'Tricolore / Calico' => t('coat.calico'), + 'Écaille de tortue' => t('coat.tortoiseshell'), 'Colourpoint / Siamois' => t('coat.colourpoint'), 'Tigré / Tabby' => t('coat.tabby'), +]; +$prefillCount = max(0, min(20, (int)($_GET['count'] ?? 4))); +?> +
+ +
+

+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ + +
+
+
+
+

+
+
+
+
+ +
+
+

+
+ +
+
+ +
+ +
+ +
+
+
+ +
+ + + diff --git a/app/Views/public_site_animal.php b/app/Views/public_site_animal.php new file mode 100644 index 0000000..a5cfb46 --- /dev/null +++ b/app/Views/public_site_animal.php @@ -0,0 +1,30 @@ +
+
+
+ +
+
+

+
+

+
+
+
+
+
+
+
+ +

+

+ ['🐶',t('public_site.dogs')],'compatibility_cats' => ['🐱',t('public_site.cats')],'compatibility_children' => ['🧒',t('public_site.children')],'house_trained' => ['✨',t('public_site.clean')]] as $key => $meta): if (($animal[$key] ?? 'unknown') === 'yes'): ?>
+

+
+
+
+
diff --git a/app/Views/public_site_animals.php b/app/Views/public_site_animals.php new file mode 100644 index 0000000..4c6cb53 --- /dev/null +++ b/app/Views/public_site_animals.php @@ -0,0 +1,10 @@ +
+
+

+
+
+
+
diff --git a/app/Views/public_site_contact.php b/app/Views/public_site_contact.php new file mode 100644 index 0000000..bdf3dda --- /dev/null +++ b/app/Views/public_site_contact.php @@ -0,0 +1,49 @@ + implode('', array_reverse(mb_str_split($value))); ?> +
+
+
+

+

+
📍 +
+
+
+ +

+

+
+ +
+
+
+
· +
+ + diff --git a/app/Views/public_site_home.php b/app/Views/public_site_home.php new file mode 100644 index 0000000..4651666 --- /dev/null +++ b/app/Views/public_site_home.php @@ -0,0 +1,12 @@ +
+
+

+

+
+
+
+
+

+
+
diff --git a/app/Views/public_site_layout.php b/app/Views/public_site_layout.php new file mode 100644 index 0000000..d662741 --- /dev/null +++ b/app/Views/public_site_layout.php @@ -0,0 +1,469 @@ + PublicSiteController::url($page, $query); +$theme = in_array($settings['public_site_theme'] ?? 'warm', ['warm','nature','minimal'], true) ? $settings['public_site_theme'] : 'warm'; +$association = $settings['association_name'] ?: 'Association'; +$viewFile = __DIR__ . '/' . $view; +$requestPath = rtrim((string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'), '/') ?: '/'; +$activePage = match($requestPath) { + '/','/site' => 'home','/animaux','/site/animaux','/adoption','/site/animal' => 'animals','/presentation','/site/presentation' => 'about','/dons','/site/dons' => 'donation','/contact','/site/contact' => 'contact',default => '' +}; +$activeSpecies = (string)($selectedSpecies ?? ($animal['species'] ?? '')); +$navClass = static fn (bool$active): string => $active ? ' class="active" aria-current="page"' : ''; +$fullAddress = implode(', ', array_filter([$settings['association_address'] ?? '',trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? ''))])); +$hasContactMap = $activePage === 'contact' && is_numeric($settings['association_latitude'] ?? null) && is_numeric($settings['association_longitude'] ?? null); +if (!function_exists('public_age')) { + function public_age(array$a): string + { + $months = null; + if (!empty($a['birth_date'])) { + $born = new DateTimeImmutable($a['birth_date']); + $now = new DateTimeImmutable('today'); + $months = max(0, $born->diff($now)->y * 12 + $born->diff($now)->m); + } elseif (($a['birth_estimated_months'] ?? '') !== '') { + $months = (int)$a['birth_estimated_months']; + }if ($months === null) { + return t('public_site.age_unknown'); + }if ($months < 12) { + return t('public_site.months', ['count' => $months]); + }$years = intdiv($months, 12); + $rest = $months % 12; + return$rest ? t('public_site.years_months', ['years' => $years,'months' => $rest]) : t('public_site.years', ['count' => $years]); + } +} +?> + + + + + + + <?= h($title . ' — ' . $association) ?> + + + + + + + +
+
+ +
+
+
+ + + + diff --git a/app/Views/public_site_text.php b/app/Views/public_site_text.php new file mode 100644 index 0000000..ca9b007 --- /dev/null +++ b/app/Views/public_site_text.php @@ -0,0 +1,11 @@ +
+
+
+

+
+

+
+
+
diff --git a/app/Views/public_site_unavailable.php b/app/Views/public_site_unavailable.php new file mode 100644 index 0000000..ce0d573 --- /dev/null +++ b/app/Views/public_site_unavailable.php @@ -0,0 +1,8 @@ +
+
+
+

+

+
+
+
diff --git a/app/Views/search.php b/app/Views/search.php new file mode 100644 index 0000000..095b5f7 --- /dev/null +++ b/app/Views/search.php @@ -0,0 +1,27 @@ +
+
+
+
+
+
+
+
+
+
+
$items): ?>
+
+
+

+
+
+
+
+
+
+ diff --git a/app/Views/settings_asm3.php b/app/Views/settings_asm3.php new file mode 100644 index 0000000..2a13ae1 --- /dev/null +++ b/app/Views/settings_asm3.php @@ -0,0 +1,99 @@ + +
+
+
+ +
+
+
+
+

+
+
+
+
+
+
+
+ (string)ini_get('upload_max_filesize')])) ?>
+

php -d + upload_max_filesize=250M -d post_max_size=260M -d max_execution_time=600 -S 127.0.0.1:8000 -t + public
+
+
+
+
+
+
+
+
+

+
+ date('d/m/Y H:i', strtotime($job['created_at']))])) ?> $job['actor']])) : '' ?> +
+
t('asm3.status.analyzed'),'completed' => t('asm3.status.completed'),'failed' => t('asm3.status.failed')][$job['status']] ?? $job['status']) ?> +
+
+
+ +
+
+
+
(int)$warning])) ?> +
+
+
+ (int)($r['media']['metadata'] ?? 0)])) ?>
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
diff --git a/app/Views/settings_audit.php b/app/Views/settings_audit.php new file mode 100644 index 0000000..d64f3e3 --- /dev/null +++ b/app/Views/settings_audit.php @@ -0,0 +1,29 @@ + +
+

+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/app/Views/settings_backups.php b/app/Views/settings_backups.php new file mode 100644 index 0000000..2272487 --- /dev/null +++ b/app/Views/settings_backups.php @@ -0,0 +1,141 @@ += 1_048_576) { + return number_format($bytes / 1_048_576, 1, $decimal, ' ') . ' ' . t('unit.mb'); + } + return number_format($bytes / 1024, 0, $decimal, ' ') . ' ' . t('unit.kb'); +}; +?> + +
+

+
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+
+

+
+
+

+
$configuredRetention])) ?> +
+
+ +
+
+
+
+
+

+
+
+
+ + + +
+ +
+
+
+
+ +
+
+

+
+
+
+
+
+
+
+
+
php + run · : +
+
+
+
+ +
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+
⬇ + +
+
+
+
+
+
diff --git a/app/Views/settings_data.php b/app/Views/settings_data.php new file mode 100644 index 0000000..eb43882 --- /dev/null +++ b/app/Views/settings_data.php @@ -0,0 +1,68 @@ + +
+

+
+
+
+ $backup])) ?>
$backup])) ?>
+
⚠️
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

+
+
+

+
+
+
+ +
+
+
+
+ +
+
+

+
+
+

+
+
+
+ +
+
+
diff --git a/app/Views/settings_general.php b/app/Views/settings_general.php new file mode 100644 index 0000000..5539223 --- /dev/null +++ b/app/Views/settings_general.php @@ -0,0 +1,88 @@ + +
+

+
+
+
+
⚠️
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?= h(t('settings.logo_alt', ['name' => $settings['association_name']])) ?> +
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
ZIP
+
+
+
+
+
diff --git a/app/Views/settings_health.php b/app/Views/settings_health.php new file mode 100644 index 0000000..f81793f --- /dev/null +++ b/app/Views/settings_health.php @@ -0,0 +1,56 @@ + t('health.group_environment'),'database' => t('health.group_database'),'storage' => t('health.group_storage'),'continuity' => t('health.group_continuity'),'files' => t('health.group_files'),'security' => t('health.group_security')]; ?> +
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $report['php'],'sqlite' => $report['sqlite']])) ?> $report['generated_at']])) ?> +
+ $label): $rows = array_values(array_filter($report['checks'], static fn (array$c): bool => $c['group'] === $group)); + if (!$rows) { + continue; + } ?>
+
+

+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/settings_inventory.php b/app/Views/settings_inventory.php new file mode 100644 index 0000000..279c50a --- /dev/null +++ b/app/Views/settings_inventory.php @@ -0,0 +1,169 @@ + $c === null ? '—' : number_format((int)$c / 100, 2, ',', ' ') . ' €'; ?> +
+

💊

+
+
+
+
+
+ · · +
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ 0 ? '+' : '' ?> +
+
+
diff --git a/app/Views/settings_lab_references.php b/app/Views/settings_lab_references.php new file mode 100644 index 0000000..0b5821d --- /dev/null +++ b/app/Views/settings_lab_references.php @@ -0,0 +1,71 @@ + +
+

+
+
+
+
⚠️
+
+
+
+ $speciesGroups): ?>
+
+
+

🩸

+
+
+
+
+ +
+ $rows): $species = $rows[0]['species_code']; ?>
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
diff --git a/app/Views/settings_nav.php b/app/Views/settings_nav.php new file mode 100644 index 0000000..99adb8a --- /dev/null +++ b/app/Views/settings_nav.php @@ -0,0 +1,35 @@ + 'users', + str_starts_with($settingsPath, '/settings/permissions') => 'permissions', + str_starts_with($settingsPath, '/settings/privacy') => 'privacy', + str_starts_with($settingsPath, '/settings/species') => 'species', + str_starts_with($settingsPath, '/settings/rooms') => 'rooms', + str_starts_with($settingsPath, '/settings/pricing') => 'pricing', + str_starts_with($settingsPath, '/settings/inventory') => 'inventory', + str_starts_with($settingsPath, '/settings/lab-references') => 'lab-references', + str_starts_with($settingsPath, '/settings/public-site') => 'public-site', + str_starts_with($settingsPath, '/settings/backups') => 'backups', + str_starts_with($settingsPath, '/settings/health') => 'health', + str_starts_with($settingsPath, '/settings/notifications') => 'notifications', + str_starts_with($settingsPath, '/settings/audit') => 'audit', + str_starts_with($settingsPath, '/settings/asm3') => 'asm3', + str_starts_with($settingsPath, '/settings/data') => 'data', + default => 'general', +}; +require_once __DIR__ . '/_settings_links.php'; +$settingsLinks = settingsNavigationLinks(); +?> + diff --git a/app/Views/settings_notifications.php b/app/Views/settings_notifications.php new file mode 100644 index 0000000..39e897d --- /dev/null +++ b/app/Views/settings_notifications.php @@ -0,0 +1,67 @@ + +
+

+
+
+
+
+
⚠️ +
⚠️ +
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+ +
+
+
+
php + scripts/send-notifications.php
+
+
diff --git a/app/Views/settings_permissions.php b/app/Views/settings_permissions.php new file mode 100644 index 0000000..af30331 --- /dev/null +++ b/app/Views/settings_permissions.php @@ -0,0 +1,88 @@ + +
+
+

+
+
+
+
+ +
+
+
+ + + + $roleLabel): ?> + + + $roleLabel): $shade = $roleIndex++ % 2 ? 'role-shade' : ''; ?> + + + + + [$label,$description]): ?> + $roleLabel): $locked = $role === 'admin'; + $shade = $roleIndex++ % 2 ? 'role-shade' : ''; ?> + + +
+
+
+
+
+
+ data-role="" data-module="" + aria-label=" PermissionService::moduleLabel($module),'role' => PermissionService::roleLabel($role)])) ?>"> + + data-role="" data-module="" + aria-label=" PermissionService::moduleLabel($module),'role' => PermissionService::roleLabel($role)])) ?>"> +
+
+ +
+
+ diff --git a/app/Views/settings_pricing.php b/app/Views/settings_pricing.php new file mode 100644 index 0000000..0314820 --- /dev/null +++ b/app/Views/settings_pricing.php @@ -0,0 +1,172 @@ + +
+

+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
$label):if ($code === 'none') { + continue; + }$value = (float)($clinicRules[$code]['percent'] ?? 0); + if ($value <= 0) { + continue; + }?> · + + % (float)$r['percent'] > 0)): ?> +
+
+
+
+
+
+
+
+ +
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + +
+ $t['unit_label']])) ?>
+
· + % +
+
+
diff --git a/app/Views/settings_privacy.php b/app/Views/settings_privacy.php new file mode 100644 index 0000000..b3afd7a --- /dev/null +++ b/app/Views/settings_privacy.php @@ -0,0 +1,106 @@ + +
+

🔏

+
+
+
+
+
+
+

+
+
+
+
+ t('privacy.other_contacts'),'privacy_retention_adopter_years' => t('privacy.adopters'),'privacy_retention_foster_years' => t('privacy.fosters'),'privacy_retention_volunteer_years' => t('privacy.volunteers')] as $key => $label): ?> +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
⬇ +
+
+
+
+
+
+
+

+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/app/Views/settings_public_site.php b/app/Views/settings_public_site.php new file mode 100644 index 0000000..59635bc --- /dev/null +++ b/app/Views/settings_public_site.php @@ -0,0 +1,131 @@ + +
+
+

+
+
↗ + +
+
+
⚠️
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ['🧡',t('public_site.theme_warm')],'nature' => ['🌿',t('public_site.theme_nature')],'minimal' => ['◻️',t('public_site.theme_minimal')]] as $key => $theme): ?> +
+
+
+
+
+
+
+

🏠

+
+
+
+
+
+
+
+
+

📖

+
+
+
+
+
+
+
+
+
+

💝

+
+
+
+
+
+
+
+
+
+

✉️

+
+
+
+
+
+
+
+
+
+
+
+

⚖️

+
+
+
+
+
+
+
+
+
diff --git a/app/Views/settings_rooms.php b/app/Views/settings_rooms.php new file mode 100644 index 0000000..460145e --- /dev/null +++ b/app/Views/settings_rooms.php @@ -0,0 +1,86 @@ + +
+
+
+
+
+ (int)$box['active'] === 1), 'name')); ?> +
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/settings_species.php b/app/Views/settings_species.php new file mode 100644 index 0000000..220262e --- /dev/null +++ b/app/Views/settings_species.php @@ -0,0 +1,69 @@ + +
+

+
+
+
+
⚠️
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
+
+
+
+
+
+
+ (int)$item['animal_count']])) ?>
+
+
+
+
+
diff --git a/app/Views/settings_users.php b/app/Views/settings_users.php new file mode 100644 index 0000000..fe8baf5 --- /dev/null +++ b/app/Views/settings_users.php @@ -0,0 +1,55 @@ + +
+
+

+
+
🔐 +
+
+
+

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ $user['last_login_at']]) : t('settings.never_logged') ?> +
+
+
+
diff --git a/app/Views/statistics.php b/app/Views/statistics.php new file mode 100644 index 0000000..6aaede6 --- /dev/null +++ b/app/Views/statistics.php @@ -0,0 +1,389 @@ + [t('statistics.handled'),'teal'],'entries' => [t('statistics.entries'),'green'],'exits' => [t('statistics.exits'),'red'],'adoptions' => [t('statistics.adoptions'),'blue'],'deaths' => [t('statistics.deaths'),'dark'],'births' => [t('statistics.births'),'purple'],'vaccinations' => [t('statistics.vaccinations'),'cyan'],'treatments' => [t('statistics.treatments'),'orange'],'medical_notes' => [t('statistics.medical_notes'),'pink']]; +$comparisonLabels = ['handled' => t('statistics.handled'),'entries' => t('statistics.entries'),'exits' => t('statistics.exits'),'adoptions' => t('statistics.adoptions'),'deaths' => t('statistics.deaths'),'births' => t('statistics.births'),'vaccinations' => t('statistics.vaccinations'),'treatments' => t('statistics.treatments_started'),'medical_notes' => t('statistics.medical_notes'),'avg' => t('statistics.avg_adoption_days')]; +$intakeTypeLabels = []; +foreach (['unknown','found','owner_surrender','transfer','born_in_care','return','seizure','other'] as $key) { + $intakeTypeLabels[$key] = t('statistics.intake.' . $key); +} +$intakeReasonLabels = []; +foreach (['unknown','stray_found','unwanted_litter','owner_health','owner_death','owner_care_home','allergy','housing','financial','behavior','abandonment','danger','transfer','birth','other'] as $key) { + $intakeReasonLabels[$key] = t('statistics.reason.' . $key); +} +$distributionLabels = ['Femelles' => t('statistics.value.females'),'Mâles' => t('statistics.value.males'),'Inconnu' => t('statistics.value.unknown'),'Non renseigné' => t('statistics.value.not_provided'),'Non renseignée' => t('statistics.value.not_provided_f'),'Moins d’un an' => t('statistics.value.under_year'),'1 à 2 ans' => t('statistics.value.one_two_years'),'3 à 7 ans' => t('statistics.value.three_seven_years'),'8 ans et plus' => t('statistics.value.eight_plus'),'Moins d’un mois' => t('statistics.value.under_month'),'1 à 3 mois' => t('statistics.value.one_three_months'),'3 à 6 mois' => t('statistics.value.three_six_months'),'6 à 12 mois' => t('statistics.value.six_twelve_months'),'Plus d’un an' => t('statistics.value.over_year'),'Chats' => t('statistics.value.cats'),'Chiens' => t('statistics.value.dogs'),'Inconnue' => t('statistics.value.unknown_f'),'Refuge' => t('status.refuge'),'Famille d’accueil' => t('status.fa'),'Accueil permanent' => t('statistics.value.permanent_foster'),'Chats libres suivis' => t('statistics.free_cats'),'Quarantaine' => t('status.quarantaine'),'Infirmerie' => t('status.soin'),'Réservé' => t('status.reserve'),'Site non renseigné' => t('statistics.value.unprovided_site')]; +$groups = [ + [t('statistics.intake_modes'),$distributions['intakeTypes'],$intakeTypeLabels,'green'], + [t('statistics.intake_reasons'),$distributions['intakeReasons'],$intakeReasonLabels,'orange'], + [t('statistics.colors'),$distributions['colors'],[],'blue'], + [t('statistics.breeds'),$distributions['breeds'],[],'purple'], + [t('statistics.species'),$distributions['species'],[],'cyan'], + [t('statistics.sex'),$distributions['sex'],[],'pink'], + [t('statistics.age_at_entry'),$distributions['ages'],[],'orange'], + [t('statistics.origins'),$distributions['origins'],[],'green','municipality'], + [t('statistics.adoption_delay'),$distributions['stays'],[],'azure'], + [t('statistics.death_causes'),$distributions['deathCauses'],array_combine(array_keys(DeathController::CAUSES), array_map(static fn ($key) => DeathController::causeLabel($key), array_keys(DeathController::CAUSES))),'dark'], + [t('statistics.current_distribution'),$distributions['currentStatus'],[],'teal'], + [t('statistics.free_cat_sites'),$distributions['freeCatSites'] ?? [],[],'green'], + [$allYears ? t('statistics.releases_year') : t('statistics.releases_site'),$distributions['freeCatReleases'] ?? [],[],'lime'], +]; +?> + +
+
+
+
+
+

+ $municipality]) : t('statistics.figures') ?> +

+
+
+
+
+
+
+
+
+ +
+ · + (int)$stats['handled']])) ?>
+
+
+ $municipalityCoverage['documented']])) ?> $municipalityCoverage['inferred']])) ?> $municipalityCoverage['born_in_care']])) ?> $municipalityCoverage['unknown']])) ?> +
+
+
+
+ (int)$stats['deaths'],'count' => (int)$undatedDeaths])) ?>
+ +
+ (int)$undatedHandled,'exits' => (int)$undatedExits,'births' => (int)$undatedBirths,'vaccinations' => (int)$stats['vaccinations'],'vaccinated' => (int)($scopeCounts['vaccinated_animals'] ?? 0),'notes' => (int)$stats['medical_notes'],'medical' => (int)($scopeCounts['medical_animals'] ?? 0)])) ?> +
+ +
[$label,$color]): ?>
+
+
+
+
+
+
+ (int)$year]) ?> +
+
+
+
+ +
+
+
+
+

+
+
+
+
+
+ (int)($costs['animals'] ?? 0)])) ?>
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+

+
+ (int)$r['contact_id'],'year' => $allYears ? 'all' : $year,'municipality' => $municipality]) : null; ?> +
+ (int)$r['animals']])) ?> + · € / +
+
+
+

+
+
+ (int)$r['animals']])) ?> + · € / +
+ +
+
+
+
+ +
+
+
+
+

+
+
+
+
+ + + + + + + + + + + + + + $allYears ? 'all' : $year,'municipality' => $row['municipality']]); ?> + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+

+
+
+
+
+
+
+ (int)$undatedHandled])) ?>
+
+
+ + + + + + + + + + + + + $annual): ?> + + + + + + + +
+
+
+
+
+

$year - 1])) ?>

+
+
+
+
+ + + + + + + + + + $label): $c = $comparison[$key]; ?> + + + + + +
0 ? '+' : '' ?> 0 ? '+' : '') . number_format($c['percent'], 1, ',', ' ') . ' %' : '' ?> +
+
+
+
+
+
+
+

+
+
+
+
+
+ 0 ? round($total / $health['total'] * 100) : 0; ?> +
+
+ · %
+
+
+
+
+
+
+
+
+
+ +
+

(int)$year])) ?> +
+
+
+
+
+

+
+ $sum])) ?> +
+
+
+
+
+ $total): $display = $labels[$label] ?? $distributionLabels[$label] ?? $label; + $width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0; + $pct = $sum > 0 ? round($total / $sum * 100) : 0; + $cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $label]); ?>
+
+ %
+
+
+
+
+
+
+ diff --git a/app/Views/surgery_form.php b/app/Views/surgery_form.php new file mode 100644 index 0000000..ba39841 --- /dev/null +++ b/app/Views/surgery_form.php @@ -0,0 +1,68 @@ +
+
+
+
+

🏥 Chirurgie —

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..69e04c0 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,42 @@ +# Déployer Globinours + +La racine web doit toujours être `public/`. La base SQLite, les sauvegardes et les documents privés ne doivent jamais être servis par Nginx. + +## Serveur Nginx + PHP-FPM + +1. Installer PHP 8.2+ et les extensions `pdo_sqlite`, `sqlite3`, `mbstring`, `xml`, `zip`, `gd`, `curl` et `fileinfo`, ainsi que `rsync`. +2. Copier le projet dans `/var/www/globinours` et donner l’écriture à l’utilisateur PHP sur `data/`, `storage/` et `public/media/`. +3. Adapter puis installer `deploy/php-fpm/globinours.conf` et `deploy/nginx/globinours.conf`. +4. Recharger PHP-FPM et Nginx, ouvrir le domaine puis suivre `/setup`. + +Le service systemd fourni est réservé à une démonstration derrière un reverse proxy. Le serveur PHP intégré n’est pas recommandé en production. + +## Mise à niveau et retour arrière + +Créer une archive de version contenant `VERSION`, puis lancer : + +```bash +sudo -u www-data ./scripts/update.sh /tmp/globinours-1.0.0.tar.gz +``` + +Le script valide l’archive, crée une sauvegarde fonctionnelle, conserve un snapshot du code, active la maintenance, déploie avec `rsync`, applique les migrations et vérifie SQLite. Pour restaurer le code : + +```bash +sudo -u www-data ./scripts/rollback.sh storage/releases/code-1.0.0-YYYYmmdd-HHMMSS.tar.gz +``` + +Une migration de base n’est volontairement jamais annulée automatiquement. Si nécessaire, restaurez ensuite le ZIP créé avant la mise à niveau depuis Paramètres > Sauvegardes. + +## Maintenance planifiée + +Activez la fréquence dans **Paramètres > Sauvegardes**, puis faites exécuter la commande suivante toutes les heures par cron ou un timer systemd : + +```bash +sudo -u www-data php /var/www/globinours/scripts/maintenance.php run +``` + +La commande ne recrée une sauvegarde que lorsqu’elle est arrivée à échéance. Elle relit ensuite le ZIP, vérifie toutes les empreintes du manifeste et exécute le contrôle d’intégrité SQLite. Elle purge également les anciennes lignes du registre d’accès privé selon la politique RGPD et tente l’envoi du résumé de notifications lorsqu’il est activé. Son résultat JSON et son code de sortie peuvent être supervisés. + +## YunoHost + +Le paquet YunoHost est maintenu séparément dans le dépôt voisin `yunohost/`. Il téléchargera une archive officielle de Globinours avec contrôle SHA-256 après la création du premier tag stable. diff --git a/deploy/nginx/globinours.conf b/deploy/nginx/globinours.conf new file mode 100644 index 0000000..77f461c --- /dev/null +++ b/deploy/nginx/globinours.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name refuge.example.org; + root /var/www/globinours/public; + index index.php; + client_max_body_size 250M; + + location / { try_files $uri $uri/ /index.php?$query_string; } + location ~ \.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTP_PROXY ""; + fastcgi_pass unix:/run/php/php8.2-fpm-globinours.sock; + } + location ~ /\. { deny all; } +} diff --git a/deploy/php-fpm/globinours.conf b/deploy/php-fpm/globinours.conf new file mode 100644 index 0000000..ef75112 --- /dev/null +++ b/deploy/php-fpm/globinours.conf @@ -0,0 +1,15 @@ +[globinours] +user = www-data +group = www-data +listen = /run/php/php8.2-fpm-globinours.sock +listen.owner = www-data +listen.group = www-data +pm = ondemand +pm.max_children = 8 +pm.process_idle_timeout = 10s +php_admin_value[upload_max_filesize] = 250M +php_admin_value[post_max_size] = 260M +php_admin_value[max_execution_time] = 600 +php_admin_value[session.cookie_httponly] = 1 +php_admin_value[session.cookie_samesite] = Lax +php_admin_value[open_basedir] = /var/www/globinours:/tmp diff --git a/deploy/systemd/globinours-demo.service b/deploy/systemd/globinours-demo.service new file mode 100644 index 0000000..6d08584 --- /dev/null +++ b/deploy/systemd/globinours-demo.service @@ -0,0 +1,16 @@ +[Unit] +Description=Globinours demonstration server +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/globinours +ExecStart=/usr/bin/php -S 127.0.0.1:8091 -t public public/router.php +Restart=on-failure +PrivateTmp=true +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/01-premiers-pas.md b/docs/01-premiers-pas.md new file mode 100644 index 0000000..e29b0a1 --- /dev/null +++ b/docs/01-premiers-pas.md @@ -0,0 +1,56 @@ +# 1. Premiers pas + +[← Sommaire](README.md) · [Chapitre suivant : Animaux →](02-animaux.md) + +## Se connecter + +Ouvrez l’adresse fournie par l’administrateur, saisissez votre identifiant et votre mot de passe, puis validez. Si l’option d’appareil de confiance est proposée, ne l’utilisez que sur un appareil personnel ou exclusivement réservé au refuge. + +Après connexion, Globinours ouvre le **Dashboard** ou la première page autorisée à votre rôle. La roue crantée, en haut à droite, affiche votre nom, les réglages accessibles et la déconnexion. + +## Comprendre la navigation + +Le bandeau principal rassemble les modules du quotidien : + +- **Dashboard** : priorités et situation actuelle ; +- **Animaux** : animaux actifs, archives et corbeille ; +- **Annuaire** : personnes et structures ; +- **Tournée** : contrôles du matin et du soir ; +- **Statistiques** : activité et coûts ; +- **Administratif** : registre, exports, documents et subventions ; +- **Roue crantée** : compte et paramètres d’administration. + +Une page ou un bouton peut disparaître selon vos permissions. Les droits sont contrôlés également par le serveur : copier l’adresse d’une page ne contourne pas une interdiction. + +## Lire le Dashboard + +Le Dashboard répond à la question : **que faut-il surveiller aujourd’hui ?** Il présente notamment les effectifs, les lieux de vie, les alertes vaccinales, les informations médicales et la qualité des fiches. Les compteurs exploitables ouvrent le listing déjà filtré. + +Le Dashboard ne remplace pas la page Statistiques : le premier décrit la situation opérationnelle présente, la seconde analyse une année ou tout l’historique. + +## Rechercher et filtrer + +Dans le listing des animaux, saisissez une partie du nom, du code ou d’une autre information recherchée. Les listes déroulantes appliquent leurs filtres dès leur modification. Le tri choisi est conservé dans l’adresse de la page, ce qui permet de recharger ou partager une vue autorisée. + +Deux présentations sont disponibles : + +- la **vue tableau**, dense et triable ; +- la **vue mosaïque**, visuelle et adaptée aux photos. + +Votre préférence est mémorisée pour votre compte. + +## Bonnes pratiques + +1. Recherchez toujours un animal ou un contact avant d’en créer un nouveau. +2. Utilisez les événements de parcours et les actions médicales plutôt qu’une note libre lorsque le champ structuré existe. +3. Ne mettez à la corbeille qu’une fiche créée par erreur ; archivez une fiche sortie du suivi courant. +4. Ne publiez une photo que si elle peut être montrée au public. +5. Signalez immédiatement une mauvaise manipulation à un administrateur : l’audit et les sauvegardes facilitent la correction. + +## Se déconnecter + +Ouvrez la roue crantée, puis choisissez **Déconnexion**. Sur un appareil partagé, révoquez également l’appareil de confiance depuis la gestion du compte si cette option avait été activée. + +--- + +[← Sommaire](README.md) · [Animaux →](02-animaux.md) diff --git a/docs/02-animaux.md b/docs/02-animaux.md new file mode 100644 index 0000000..838f3df --- /dev/null +++ b/docs/02-animaux.md @@ -0,0 +1,78 @@ +# 2. Gérer les animaux + +[← Premiers pas](01-premiers-pas.md) · [Sommaire](README.md) · [Portées et liens →](03-portees-et-liens.md) + +## Le listing + +La page **Animaux** présente les animaux actuellement suivis par le refuge. Les animaux adoptés, décédés ou autrement sortis du suivi courant peuvent se trouver dans les archives. Les fiches supprimées logiquement se trouvent dans la corbeille. + +La vue tableau permet de trier les colonnes disponibles. Les lignes de portées et de groupes inséparables restent regroupées tout en participant au tri. Les icônes de compatibilité indiquent les relations avec les chiens, chats et enfants ainsi que la propreté. Un trait signifie que l’information n’est pas renseignée. + +La vue mosaïque met en avant la photo, le nom, le statut, la famille d’accueil éventuelle et les compatibilités. + +## Créer une fiche + +Cliquez sur **Ajouter un animal**. Renseignez au minimum les informations certaines. Une estimation doit rester présentée comme telle : ne créez pas une date précise si seul l’âge approximatif est connu. + +La fiche peut notamment contenir : + +- espèce, nom, sexe, date de naissance ou âge estimé ; +- race, robe et signes particuliers ; +- mode et motif d’entrée, déposant, commune et circonstances ; +- identification par puce ou tatouage et statut I-CAD ; +- stérilisation et disponibilité à l’adoption ; +- compatibilités et profil de vie ; +- lieu de vie, famille d’accueil et publication publique. + +Les espèces sont administrables. Utilisez **Chat**, **Chien** ou une espèce précise plutôt qu’une catégorie vague lorsque le référentiel la propose. + +## La fiche animale + +La fiche s’ouvre toujours sur **Identité**. Les autres onglets regroupent : + +- **Activité** : événements marquants ; +- **Médical**, **Traitements** et **Vaccins** ; +- **Lieux de vie** : position actuelle et historique interne ; +- **Parcours d’accueil** : réservation, famille d’accueil, adoption, retour ou annulation ; +- **Portées** pour les chats ; +- **Documents**, **Historique** et **Photos**. + +Le menu **Ajouter** centralise les événements structurés. Le menu **Actions** contient les opérations globales comme l’impression, le décès, l’archivage ou la corbeille. + +## Photos et visibilité + +Depuis l’onglet **Photos**, téléversez une ou plusieurs images, choisissez la photo principale et définissez leur visibilité. Les photos privées restent réservées à l’application. Une photo publique peut apparaître sur le mini-site si l’animal lui-même est publié. + +Lorsque les outils d’image du serveur sont disponibles, Globinours réoriente, optimise et retire les métadonnées des images. Vérifiez néanmoins qu’aucune personne, adresse, ordonnance ou information sensible n’est visible. + +## Lieux de vie et familles d’accueil + +Le **lieu de vie** décrit où se trouve l’animal : refuge, salle, infirmerie, quarantaine, famille d’accueil, accueil permanent ou autre emplacement configuré. Lorsqu’une famille d’accueil est choisie, sélectionnez la personne correspondante dans l’annuaire. + +Le **parcours d’accueil** décrit pourquoi et dans quel cadre il s’y trouve : accueil en FA, retour, réservation, adoption, etc. Les deux notions sont liées mais ne sont pas interchangeables. + +## Enregistrer une adoption + +Depuis la fiche, choisissez l’action d’adoption, sélectionnez l’adoptant dans l’annuaire ou créez-le à la volée, puis renseignez la date. L’adoption met à jour le parcours, la sortie, la situation et l’archivage sans effacer l’historique antérieur en famille d’accueil. + +## Enregistrer un décès + +Choisissez **Enregistrer le décès** dans le menu Actions. Renseignez les informations connues : date ou année, cause, euthanasie éventuelle, vétérinaire, crématorium, devenir du corps et note mémorielle. N’inventez pas de jour précis pour un décès historique connu seulement à l’année. + +Un décès est une sortie et retire l’animal du suivi courant. La fiche reste consultable et modifiable dans les archives. Les statistiques annuelles n’attribuent pas artificiellement les décès sans année connue. + +## Archives et corbeille + +- **Archiver** retire une fiche du listing courant tout en conservant son dossier et son histoire. +- **Mettre à la corbeille** sert aux créations erronées ou doublons. La fiche ne compte plus dans les statistiques. +- **Restaurer** replace une fiche archivée ou supprimée dans le parcours prévu par l’interface. + +Les archives disposent de la recherche, des filtres et des tris du listing principal. Une archive peut être corrigée sans être restaurée pour rectifier une identité, un sexe ou une identification ; les champs qui déterminent sa situation historique restent protégés. + +## Chats libres + +Le parcours **Chat libre** permet de conserver le site ou la colonie, la commune, le référent, les dates de capture et de remise en liberté ainsi que l’état du suivi. Utilisez ce parcours plutôt qu’un statut approximatif : il alimente les filtres et les statistiques dédiées. + +--- + +[← Premiers pas](01-premiers-pas.md) · [Portées et liens →](03-portees-et-liens.md) diff --git a/docs/03-portees-et-liens.md b/docs/03-portees-et-liens.md new file mode 100644 index 0000000..220559a --- /dev/null +++ b/docs/03-portees-et-liens.md @@ -0,0 +1,42 @@ +# 3. Portées, fratries et animaux inséparables + +[← Animaux](02-animaux.md) · [Sommaire](README.md) · [Suivi médical →](04-suivi-medical.md) + +## Créer une portée + +Une portée peut être créée depuis le bouton **Ajouter une portée** du listing ou depuis l’onglet **Portées** d’un parent. + +Deux parcours sont proposés : + +- **Nouveaux chatons** : indiquez d’abord leur nombre ; Globinours affiche alors exactement le nombre de fiches à compléter. +- **Chats existants** : sélectionnez des fiches déjà présentes pour les réunir dans une nouvelle portée. + +La date de naissance est saisie au format `JJ/MM/AAAA`. Le père, la mère ou les deux peuvent rester inconnus. Depuis la fiche d’un parent, Globinours préremplit naturellement celui-ci et propose l’autre parent parmi les chats vivants et non adoptés compatibles. + +## Nommer les chatons + +Le nom est facultatif. Sans nom saisi, Globinours construit un nom à partir d’un parent et du rang, par exemple `Caramel-Bébé-1`. Le sexe et la robe peuvent également rester inconnus puis être complétés plus tard. + +## Portées sans parent connu + +Une portée sans parent reste un groupe visible : un parent visuel factice et non cliquable maintient la même présentation que les autres portées. Les frères et sœurs ne sont donc jamais affichés comme des animaux sans relation. + +## Ajouter un animal après coup + +Depuis l’onglet **Portées** d’un chat ou l’action prévue sur la portée, choisissez **Ajouter des chats existants**. Cette opération convient lorsqu’une filiation est découverte après la création des fiches. Vérifiez le groupe sélectionné avant validation pour ne pas fusionner deux portées homonymes. + +## Groupes inséparables + +Un groupe inséparable relie des animaux qui doivent être placés ensemble, sans supposer de filiation. Depuis la fiche d’un animal, choisissez **Groupe inséparable**, recherchez les autres membres et nommez le groupe si nécessaire. + +Dans le listing, le groupe reçoit un en-tête et un fond léger commun. Chaque membre conserve sa propre fiche, ses soins et son parcours. Dissoudre le groupe supprime uniquement le lien : aucune fiche animale n’est supprimée. + +## Choisir le bon lien + +- Même naissance ou filiation : **portée**. +- Placement obligatoirement commun : **groupe inséparable**. +- Simple cohabitation dans une salle ou un box : **lieu de vie**, sans créer de lien familial. + +--- + +[← Animaux](02-animaux.md) · [Suivi médical →](04-suivi-medical.md) diff --git a/docs/04-suivi-medical.md b/docs/04-suivi-medical.md new file mode 100644 index 0000000..8b0e8f1 --- /dev/null +++ b/docs/04-suivi-medical.md @@ -0,0 +1,55 @@ +# 4. Suivi médical + +[← Portées et liens](03-portees-et-liens.md) · [Sommaire](README.md) · [Tournées →](05-tournees.md) + +> Le module médical peut être masqué par les permissions. Les données de ce chapitre sont privées et ne sont jamais destinées au mini-site. + +## Aperçu médical + +La fiche regroupe les statuts FIV/FeLV, la stérilisation, les pathologies, le poids, les vaccinations, les traitements, le dernier vermifuge, les ordonnances et les analyses. Le listing affiche un aperçu compact pour repérer rapidement les informations manquantes ou préoccupantes. + +## Note médicale + +Dans **Ajouter → Note médicale**, saisissez la date, l’observation, le vétérinaire ou l’intervenant et, si nécessaire, une photo. Une photo prise depuis une tablette ou un téléphone rejoint les médias privés de l’animal. Une note anormale issue d’une tournée est également ajoutée à son activité ; un passage entièrement normal ne surcharge pas l’historique. + +La stérilisation peut être confirmée depuis ce parcours. La case reprend automatiquement l’état déjà enregistré. + +## Traitements + +Dans **Ajouter → Traitement**, choisissez le médicament, la dose, la voie, les dates et le caractère continu. La liste est triée alphabétiquement. Si le produit manque, utilisez l’ajout à la volée plutôt que de détourner un autre médicament. + +Les vermifuges disposent volontairement d’un catalogue séparé : ne les créez pas comme traitements ordinaires. + +## Vaccinations + +Enregistrez le vaccin, la date, le prochain rappel, le lot, l’expiration, le vétérinaire et l’intervenant lorsque ces informations sont connues. Le Dashboard distingue les vaccins en retard, attendus sous 7 jours et sous 30 jours. + +## Vermifuges + +L’action **Vermifuge** enregistre le produit, la dose, le poids éventuel et la prochaine échéance. Le dernier passage et le prochain sont visibles dans l’aperçu médical. + +Le calendrier proposé sert de repère : semaines 3, 5 et 7 pour les chatons, puis chaque mois jusqu’à 6 mois et généralement tous les 3 mois à l’âge adulte. Une maman allaitante peut être traitée avec sa portée. L’équipe vétérinaire reste prioritaire sur toute suggestion automatique. + +## Ordonnances + +Dans **Ajouter → Ordonnance**, photographiez ou déposez le document, renseignez sa date, la clinique et le vétérinaire, puis reliez les traitements concernés. Le fichier est stocké comme document privé et chaque ouverture est traçable. + +## Résultats d’analyses + +Dans **Ajouter → Résultats analyses**, choisissez un modèle adapté à l’espèce — bilan complet, rénal, hépatique ou thyroïdien — et saisissez les valeurs. La touche Tab suit les paramètres verticalement pour accélérer la saisie. + +Les intervalles configurés dans **Paramètres → Valeurs de référence** préremplissent les bornes et unités. Une valeur hors intervalle apparaît comme telle, mais Globinours ne pose aucun diagnostic. Les références propres au compte-rendu ou à la machine de la clinique priment toujours sur les valeurs initiales du logiciel. + +## Dépenses et coût estimé + +Dans **Ajouter → Dépense vétérinaire**, sélectionnez la structure, l’acte tarifé ou un intitulé libre, puis le prix et la remise applicables. Globinours conserve le prix catalogue, la réduction et le coût net au moment de l’enregistrement : une modification future du tarif ne réécrit pas l’historique. + +La fiche affiche une carte **Coût vétérinaire estimé**. Les statistiques consolident ensuite ces dépenses par structure, catégorie, période et commune d’origine. + +## Choisir vétérinaire et structure + +Un vétérinaire personne peut être rattaché à son cabinet dans l’annuaire. Le sélectionner complète automatiquement sa structure. Inversement, choisir d’abord une structure limite la liste aux vétérinaires compatibles. + +--- + +[← Portées et liens](03-portees-et-liens.md) · [Tournées →](05-tournees.md) diff --git a/docs/05-tournees.md b/docs/05-tournees.md new file mode 100644 index 0000000..ef486ac --- /dev/null +++ b/docs/05-tournees.md @@ -0,0 +1,37 @@ +# 5. Tournées de soins + +[← Suivi médical](04-suivi-medical.md) · [Sommaire](README.md) · [Annuaire →](06-annuaire.md) + +La page **Tournée** est conçue pour une utilisation tactile rapide, le matin ou le soir. + +## Avant de commencer + +Choisissez la date et la période **Matin** ou **Soir**. Les traitements actifs et leur programmation indiquent les soins ou médicaments attendus pour ce passage. Un symbole de médicament apparaît à côté du nom lorsqu’une action est prévue. + +## Salles et box + +- **Salle Silver** et **Salle Twix** sont des espaces collectifs ; plusieurs animaux y apparaissent côte à côte. +- **Infirmerie** et **Quarantaine** disposent de box modulables, avec trois modules en haut et deux en bas. +- Un box peut accueillir plusieurs animaux. + +Dans **Configurer les box**, assemblez ou séparez les modules selon l’installation physique réelle, puis affectez les animaux par leur lieu de vie habituel. + +## Valider un animal + +Ouvrez sa carte et contrôlez les éléments proposés : selles, urines, alimentation, eau, état général et soins prévus. Ajoutez un commentaire uniquement s’il apporte une information utile. Une photo peut documenter une plaie ou une anomalie ; elle est versée aux médias privés. + +## Valider une salle collective + +Pour Silver et Twix, **Tout le monde va bien** valide en une fois les animaux de la salle. Utilisez cette action seulement après avoir réellement contrôlé chacun d’eux. Si un animal présente une anomalie, ouvrez plutôt sa carte et saisissez son observation séparément. + +## Historique + +Un passage entièrement normal reste enregistré pour le suivi de la tournée mais n’encombre pas l’activité de chaque animal. Une anomalie, un commentaire préoccupant ou une photo crée en revanche une trace visible sur sa fiche. + +## Plein écran + +Le mode plein écran masque les éléments inutiles sur tablette. Sortez-en avec la commande du navigateur ou de l’appareil. La date, la période et la configuration restent accessibles dans l’en-tête de la tournée. + +--- + +[← Suivi médical](04-suivi-medical.md) · [Annuaire →](06-annuaire.md) diff --git a/docs/06-annuaire.md b/docs/06-annuaire.md new file mode 100644 index 0000000..bb21df3 --- /dev/null +++ b/docs/06-annuaire.md @@ -0,0 +1,41 @@ +# 6. Annuaire + +[← Tournées](05-tournees.md) · [Sommaire](README.md) · [Statistiques et administratif →](07-statistiques-et-administratif.md) + +L’annuaire centralise les personnes et structures afin d’éviter de ressaisir leurs coordonnées dans chaque adoption, placement ou acte médical. + +## Personnes et structures + +Les rôles comprennent notamment : adoptant, bénévole, dirigeant, déposant, famille d’accueil et vétérinaire. Les structures comprennent les cabinets vétérinaires, crématoriums et fourrières. Une personne peut cumuler plusieurs rôles. + +Une personne peut être rattachée à une structure, par exemple un vétérinaire à son cabinet. Une structure ne doit pas être rattachée à une autre structure par ce champ. + +## Créer ou modifier un contact + +Recherchez d’abord son nom, son téléphone ou son courriel. Si aucun résultat ne correspond, choisissez **Ajouter** et sélectionnez le ou les rôles appropriés. Les rôles sont classés alphabétiquement dans les listes. + +Les contacts peuvent aussi être créés à la volée depuis une adoption ou une action médicale. Complétez ensuite leur fiche depuis l’annuaire si nécessaire. + +## Consulter une fiche + +Toute la ligne du contact est cliquable. Selon son rôle, la fiche présente : + +- les animaux adoptés, y compris ceux aujourd’hui archivés ou décédés ; +- les accueils en famille d’accueil, séparés des adoptions ; +- les animaux déposés ; +- les dépenses rattachées à une structure partenaire ; +- les relations médicales connues. + +Le nombre d’adoptions apparaît dans le listing des adoptants. Pour une structure, la fiche peut reprendre la période et la commune choisies dans les statistiques afin d’expliquer exactement le montant affiché. + +## Éviter et corriger les doublons + +Les variantes de casse, d’accents ou d’ordre prénom/nom peuvent désigner la même personne. Ne fusionnez pas manuellement deux personnes seulement parce que leurs noms se ressemblent : comparez coordonnées et animaux liés. Les outils de rapprochement administratifs effectuent une simulation et une sauvegarde avant toute fusion contrôlée. + +## Données personnelles + +Limitez les coordonnées à ce qui est nécessaire au refuge. N’écrivez pas d’information médicale humaine ni de commentaire subjectif. Les fonctions d’export et d’anonymisation se trouvent dans **Paramètres → Confidentialité/RGPD** et sont réservées aux administrateurs. + +--- + +[← Tournées](05-tournees.md) · [Statistiques et administratif →](07-statistiques-et-administratif.md) diff --git a/docs/07-statistiques-et-administratif.md b/docs/07-statistiques-et-administratif.md new file mode 100644 index 0000000..7e53cc4 --- /dev/null +++ b/docs/07-statistiques-et-administratif.md @@ -0,0 +1,51 @@ +# 7. Statistiques, administratif et subventions + +[← Annuaire](06-annuaire.md) · [Sommaire](README.md) · [Documents et site public →](08-documents-et-site-public.md) + +## Choisir le bon écran + +- Le **Dashboard** montre les priorités actuelles. +- **Statistiques** analyse l’activité et les coûts. +- **Administratif** produit le registre, les exports et les dossiers annuels. + +## Statistiques + +Sélectionnez une année ou **Toutes les années**. Une année affiche sa comparaison avec l’année précédente. La vue globale produit une évolution sur toutes les années sans inventer de date pour les événements historiques imprécis. + +Les indicateurs couvrent notamment : animaux distincts pris en charge, entrées, sorties, adoptions, décès, naissances, activité médicale, vaccinations, traitements, sexes, âges, espèces, races, robes, origines et délai d’adoption. + +Le nombre d’animaux pris en charge n’est pas la somme brute des événements : un même animal peut avoir plusieurs actes sans être compté plusieurs fois. + +## Filtrer par commune + +La commune représente l’origine ou le lieu de récupération de l’animal, jamais l’adresse de la famille d’accueil ni du cabinet. Cliquez sur une commune ou utilisez le filtre pour recalculer la page. Cette vue sert notamment à chiffrer l’activité réalisée pour une collectivité. + +Les anciens dossiers sans commune fiable restent dans les totaux généraux, mais ne sont pas attribués arbitrairement à une ville. + +## Analyser les coûts + +Les dépenses peuvent être réparties par structure et par catégorie. Le tableau indique total, animaux concernés, moyenne et médiane. Cliquez sur une structure pour ouvrir sa fiche avec la même année et la même commune : le détail permet de vérifier les actes avant de présenter un montant à un partenaire. + +Le coût moyen sur tous les animaux du périmètre et celui sur les seuls animaux ayant généré des frais répondent à deux questions différentes ; conservez leurs libellés dans toute communication de subvention. + +## Registre et exports + +L’espace Administratif permet de consulter et imprimer le registre des entrées et sorties. Les exports produisent des fichiers de travail ou de portabilité. Un export n’est pas une sauvegarde complète : il ne remplace pas la procédure de sauvegarde ZIP. + +Les données exportées peuvent contenir des informations personnelles. Stockez-les dans un emplacement protégé et supprimez les copies devenues inutiles. + +## Subventions et bilans + +Dans **Administratif → Subventions et bilans**, choisissez l’année puis complétez : effectifs associatifs, rapport d’activité, projets, données financières et demandes par organisme. Les chiffres issus de Globinours sont calculés automatiquement ; les informations comptables externes restent à saisir. + +La checklist documentaire suit les statuts manquant, valide, proche de l’expiration ou expiré. Les justificatifs sont privés et inclus dans les sauvegardes. La synthèse imprimable peut être enregistrée en PDF depuis le navigateur. + +Globinours prépare le socle commun, mais ne remplit pas automatiquement tous les formulaires officiels : leur contenu change selon les communes et les années. + +## Comprendre les données historiques + +Une donnée sans année certaine compte dans le total historique lorsque son existence est démontrée, mais pas dans une courbe annuelle. Ce principe explique qu’un total « Toutes les années » puisse dépasser la somme des années visibles. Il protège les statistiques contre les fausses dates créées pour combler un manque d’archives. + +--- + +[← Annuaire](06-annuaire.md) · [Documents et site public →](08-documents-et-site-public.md) diff --git a/docs/08-documents-et-site-public.md b/docs/08-documents-et-site-public.md new file mode 100644 index 0000000..87d6c1d --- /dev/null +++ b/docs/08-documents-et-site-public.md @@ -0,0 +1,45 @@ +# 8. Documents et mini-site public + +[← Statistiques et administratif](07-statistiques-et-administratif.md) · [Sommaire](README.md) · [Utilisateurs et RGPD →](09-utilisateurs-et-rgpd.md) + +## Documents administratifs vierges + +Dans **Administratif**, ouvrez la liste des documents pour télécharger un modèle ODT ou afficher sa version imprimable. Les modèles vierges servent lorsqu’aucun animal n’est encore sélectionné. + +## Documents préremplis d’un animal + +Dans la fiche animale, ouvrez **Plus → Documents**. Les contrats et formulaires proposés reprennent les informations structurées de la fiche. Vérifiez systématiquement le nom, l’identification, la race, la robe, l’adoptant et les dates avant impression. + +Le fichier ODT reste modifiable dans une suite bureautique. La version PDF/HTML est destinée à la visualisation ou l’impression. Les documents générés portent une mention de génération afin d’identifier leur date et leur origine. + +## Activer le mini-site + +Dans **Paramètres → Site public**, activez le site, choisissez son thème et complétez les contenus : accueil, présentation libre, dons, contact et mentions légales. Trois thèmes légers sont fournis. + +Deux modes existent : + +- **intégré** : le site est disponible sous `/site` ; +- **domaine public** : le site occupe `/` et l’application interne reste accessible par `/gestion`. + +## Publier un animal + +Une fiche publique apparaît uniquement si l’animal est à la fois : + +1. disponible à l’adoption ; +2. autorisé à être publié sur le mini-site. + +Au moins une photo doit être publique pour illustrer correctement la fiche. Les données privées — identification, médical, lieux internes, coordonnées de FA, notes et documents — ne sont jamais chargées par les pages publiques. + +## Pages publiques + +Le menu présente directement les espèces publiées, puis les pages de texte, dons et contact. Le pied de page utilise l’adresse complète, le RNA et le SIRET configurés. La page Contact affiche une carte positionnée sur l’adresse de l’association ; la bibliothèque cartographique est locale mais les tuiles OpenStreetMap nécessitent Internet. + +Le téléphone et le courriel bénéficient d’une obfuscation CSS légère contre les robots simples. Cela ne remplace ni une politique antispam ni une adresse dédiée au public. + +## Mentions légales et confidentialité + +Des contenus FR/EN préremplis sont proposés, mais l’association reste responsable de leur exactitude. Complétez l’hébergeur, le responsable de publication, les finalités du formulaire éventuel et les coordonnées adaptées avant mise en ligne. + +--- + +[← Statistiques et administratif](07-statistiques-et-administratif.md) · [Utilisateurs et RGPD →](09-utilisateurs-et-rgpd.md) diff --git a/docs/09-utilisateurs-et-rgpd.md b/docs/09-utilisateurs-et-rgpd.md new file mode 100644 index 0000000..3a0b336 --- /dev/null +++ b/docs/09-utilisateurs-et-rgpd.md @@ -0,0 +1,48 @@ +# 9. Utilisateurs, rôles et confidentialité + +[← Documents et site public](08-documents-et-site-public.md) · [Sommaire](README.md) · [Paramètres →](10-parametres.md) + +## Créer les comptes + +Dans **Paramètres → Utilisateurs**, créez un compte nominatif par personne. Ne partagez pas un compte d’équipe : l’historique et l’audit doivent pouvoir attribuer une action à son auteur réel. + +Les mots de passe sont hachés et leur validation est affichée dans l’interface. Un administrateur ne doit jamais demander à connaître le mot de passe d’un utilisateur. + +## Rôles et permissions + +Les groupes fournis sont **Administrateur**, **Responsable**, **Bénévole** et **Lecture seule**. Dans **Rôles et permissions**, chaque module distingue : + +- **Voir** : consulter les informations ; +- **Modifier** : créer ou changer des données. + +Les permissions portent sur un groupe, jamais sur une personne isolée. Les administrateurs conservent tous les droits. Le module médical exige également l’accès en lecture aux animaux. + +Appliquez le principe du moindre privilège : un bénévole chargé des tournées n’a pas nécessairement besoin des exports administratifs ou des coordonnées complètes de l’annuaire. + +## Appareils de confiance + +Une session persistante peut reconnaître un appareil pendant une durée limitée sans enregistrer le mot de passe. Depuis la page des appareils du compte, révoquez un appareil perdu, revendu ou partagé. La révocation globale déconnecte les appareils concernés. + +## Journal d’audit + +**Paramètres → Audit** retrace les actions importantes : auteur, date, type d’action et objet. L’audit aide à comprendre une correction ou une suppression ; il ne remplace pas une sauvegarde. + +## Confidentialité/RGPD + +Dans **Paramètres → Confidentialité/RGPD**, configurez la durée de conservation indicative et examinez les contacts à revoir. Globinours ne supprime pas automatiquement les dossiers arrivés à échéance : une décision humaine reste nécessaire. + +Pour un contact, les outils permettent : + +- d’exporter les données le concernant ; +- de lancer une anonymisation contrôlée ; +- de préserver les statistiques agrégées sans conserver inutilement son identité. + +L’anonymisation demande un motif et déclenche une sauvegarde préalable. Vérifiez les obligations légales, comptables, contractuelles et de protection animale avant de l’utiliser. + +## Fichiers privés + +Les ordonnances, analyses, justificatifs et médias privés sont servis par des routes contrôlées. Leur consultation est journalisée. Ne contournez pas cette protection en copiant les fichiers dans un dossier public ou dans un ticket Forgejo. + +--- + +[← Documents et site public](08-documents-et-site-public.md) · [Paramètres →](10-parametres.md) diff --git a/docs/10-parametres.md b/docs/10-parametres.md new file mode 100644 index 0000000..6e75959 --- /dev/null +++ b/docs/10-parametres.md @@ -0,0 +1,43 @@ +# 10. Paramètres et référentiels + +[← Utilisateurs et RGPD](09-utilisateurs-et-rgpd.md) · [Sommaire](README.md) · [Sauvegardes et maintenance →](11-sauvegardes-et-maintenance.md) + +La roue crantée et la barre interne utilisent les mêmes catégories : **Configuration**, **Référentiels métier**, **Accès** et **Maintenance**. + +## Paramètres généraux + +Renseignez l’identité de l’association : nom, coordonnées, adresse, RNA, SIRET, logo, langue et tarifs d’adoption. Ces valeurs alimentent l’interface, le mini-site et les documents générés. + +Le logo de l’association est une donnée propre à l’installation. Le logo Globinours reste l’identité du logiciel. + +## Espèces + +Le référentiel des espèces contrôle les choix des fiches et la navigation du site public. Chaque entrée peut disposer d’un nom, d’une catégorie, d’une icône, d’une couleur et d’un état actif. Désactivez une espèce inutilisée plutôt que de supprimer une valeur déjà liée à des animaux. + +## Tarifs + +Les structures éligibles sont les cabinets vétérinaires, crématoriums et fourrières de l’annuaire. Pour chacune, configurez les actes, produits, prix et remises. Les intitulés peuvent être ajoutés à la volée. + +Les tarifs servent à préremplir une dépense, mais la dépense enregistrée garde son propre instantané financier. Modifiez donc un ancien acte sur la fiche concernée plutôt que son référentiel si vous corrigez une facture passée. + +## Valeurs de référence biologiques + +Configurez les intervalles par analyseur, bilan, paramètre et espèce. Les valeurs fournies servent d’aide visuelle, pas de diagnostic. Si une clinique a personnalisé sa machine, saisissez les bornes imprimées sur ses comptes-rendus. + +Une absence d’intervalle constructeur doit rester vide. Ne créez pas une plage simplement pour obtenir un affichage vert ou rouge. + +## Site public + +Cette rubrique gère l’activation, le mode d’URL, le thème, les textes et les pages légales. Consultez le chapitre [Documents et mini-site public](08-documents-et-site-public.md) avant la première publication. + +## Santé et notifications + +La page Santé vérifie les prérequis, les répertoires, SQLite et les composants utiles. Les notifications configurées complètent le Dashboard pour les rappels prévus par l’installation. + +## Données et démonstration + +Les outils de données permettent de charger un jeu factice ou de vider les données métier. Ils exigent une phrase de confirmation traduite exactement dans la langue active. Ces fonctions sont destructives : créez et téléchargez une sauvegarde avant toute utilisation. + +--- + +[← Utilisateurs et RGPD](09-utilisateurs-et-rgpd.md) · [Sauvegardes et maintenance →](11-sauvegardes-et-maintenance.md) diff --git a/docs/11-sauvegardes-et-maintenance.md b/docs/11-sauvegardes-et-maintenance.md new file mode 100644 index 0000000..6106d09 --- /dev/null +++ b/docs/11-sauvegardes-et-maintenance.md @@ -0,0 +1,57 @@ +# 11. Sauvegardes, import ASM3 et maintenance + +[← Paramètres](10-parametres.md) · [Sommaire](README.md) · [Mobile →](12-mobile.md) + +## Créer une sauvegarde + +Dans **Paramètres → Sauvegardes**, créez une archive ZIP puis téléchargez-en régulièrement une copie hors du serveur. Une sauvegarde complète inclut la base SQLite et les fichiers privés nécessaires à la restauration. + +Conservez plusieurs générations sur un support séparé. Une archive stockée uniquement sur le même disque ne protège pas contre sa panne. + +## Restaurer + +La restauration remplace des données actuelles par le contenu de l’archive. Avant de confirmer : + +1. vérifiez la provenance et la date du fichier ; +2. prévenez les utilisateurs et interrompez les saisies ; +3. créez une sauvegarde de l’état actuel ; +4. restaurez puis contrôlez la page Santé, les animaux récents et les médias. + +Globinours crée une sauvegarde d’urgence lors des opérations sensibles prévues, mais cette protection ne dispense pas d’une copie externe. + +## Importer Animal Shelter Manager 3 + +Dans **Paramètres → Migration ASM3**, déposez un dump SQL ou PostgreSQL. L’assistant commence par analyser le format, les volumes et ambiguïtés. Lisez le rapport avant l’import. + +Deux modes sont disponibles : + +- **Fusionner** conserve les données Globinours et ajoute les correspondances sûres ; +- **Remplacer les données métier** efface animaux, annuaire et historique opérationnel avant import, tout en conservant comptes et configuration. + +Le remplacement est réservé à une installation de test ou une migration initiale. Il exige une phrase de confirmation et ne doit jamais être lancé pendant que d’autres personnes travaillent. + +L’import est transactionnel : une erreur annule ses écritures. Une sauvegarde est créée au préalable. Les ambiguïtés ne doivent pas être forcées automatiquement ; corrigez-les à partir des rapports produits. + +## Photos ASM3 + +Les dumps ASM3 peuvent ne contenir que les références des médias. Les fichiers physiques doivent alors être fournis séparément et rapprochés avec les tables `media` et `dbfs`. L’import dédié vérifie les images, conserve les médias existants et reprend la photo principale lorsqu’elle est identifiable. + +## Mise à jour de Globinours + +Suivez le guide de déploiement fourni avec la version utilisée. Une mise à jour sûre comprend au minimum : sauvegarde, validation de l’archive, activation de la maintenance, remplacement du code, migrations, contrôle SQLite et possibilité de retour à la version précédente. + +Ne copiez jamais une base réelle, un `.env`, les médias privés ou une sauvegarde dans un dépôt Git. + +## Contrôles après maintenance + +- connexion et permissions ; +- page **Paramètres → Santé** ; +- ouverture d’une fiche active et d’une archive ; +- affichage d’une photo privée autorisée ; +- création puis suppression d’une donnée de test si le contexte le permet ; +- sauvegarde téléchargeable ; +- mini-site public sans information privée. + +--- + +[← Paramètres](10-parametres.md) · [Mobile →](12-mobile.md) diff --git a/docs/12-mobile.md b/docs/12-mobile.md new file mode 100644 index 0000000..2ebc058 --- /dev/null +++ b/docs/12-mobile.md @@ -0,0 +1,36 @@ +# 12. Mobile, PWA et application Android + +[← Sauvegardes et maintenance](11-sauvegardes-et-maintenance.md) · [Sommaire](README.md) · [FAQ →](13-faq.md) + +## Interface responsive + +Globinours adapte ses listes, cartes, onglets et formulaires aux ordinateurs, tablettes et téléphones. Le mode Tournée est particulièrement pensé pour la tablette, mais les autres fonctions restent disponibles selon les permissions. + +## Installer la PWA + +Sur un navigateur compatible, utilisez **Installer l’application** ou **Ajouter à l’écran d’accueil**. La PWA ouvre Globinours comme une application mais utilise toujours le serveur de l’association. + +Elle ne conserve pas de copie locale des animaux. Sans réseau vers l’instance, les données métier ne sont pas accessibles. Les bibliothèques d’interface sont embarquées localement ; la cartographie et les intégrations externes peuvent toutefois nécessiter Internet. + +## Application Android + +L’application Android est un wrapper sécurisé de l’instance web, pas une seconde version de Globinours. Au premier lancement : + +1. saisissez l’adresse HTTPS complète de votre instance ; +2. vérifiez soigneusement le domaine ; +3. connectez-vous dans l’interface Globinours ; +4. activez la protection biométrique ou le code de l’appareil si elle est proposée. + +La WebView reste limitée au domaine choisi. Le mot de passe n’est pas enregistré par l’application ; la connexion persistante repose sur la session délivrée par Globinours. + +## Sécurité de l’appareil + +- Utilisez un verrouillage Android robuste. +- N’installez un APK que depuis la source officielle du projet ou de votre administrateur. +- Révoquez l’appareil depuis votre compte s’il est perdu. +- N’utilisez pas l’application sur un téléphone partagé sans déconnexion. +- N’acceptez jamais un certificat HTTPS invalide. + +--- + +[← Sauvegardes et maintenance](11-sauvegardes-et-maintenance.md) · [FAQ →](13-faq.md) diff --git a/docs/13-faq.md b/docs/13-faq.md new file mode 100644 index 0000000..3300ad4 --- /dev/null +++ b/docs/13-faq.md @@ -0,0 +1,51 @@ +# 13. FAQ et dépannage + +[← Mobile](12-mobile.md) · [Sommaire](README.md) + +## Je ne vois pas un menu ou un bouton + +Votre rôle ne possède probablement pas le droit de voir ou modifier ce module. Demandez à un administrateur de vérifier **Paramètres → Rôles et permissions**. Une URL directe ne contourne pas ces droits. + +## Un animal a disparu du listing + +Recherchez-le dans **Archives**, puis dans **Corbeille**. Une adoption ou un décès l’archive normalement. Une fiche en corbeille ne compte plus dans les statistiques. + +## Une statistique globale dépasse la somme des années + +Des événements historiques sont connus sans date ou année certaine. Ils comptent dans **Toutes les années**, mais Globinours refuse de les placer arbitrairement dans une courbe annuelle. + +## La carte n’affiche rien + +Vérifiez que l’adresse ou les coordonnées sont renseignées. La bibliothèque Leaflet est locale, mais les tuiles OpenStreetMap et la recherche d’adresse nécessitent un accès réseau. Un blocage 403 peut également provenir d’un proxy ou d’une politique réseau. + +## Une photo ne s’affiche pas sur le site public + +Vérifiez les trois conditions : animal disponible, publication de l’animal autorisée et photo marquée publique. Une photo privée ne doit jamais être rendue publique automatiquement. + +## Je ne peux pas ouvrir un document privé + +Vérifiez votre accès au module médical ou administratif et l’existence du fichier. Consultez **Paramètres → Santé**. Ne déplacez pas manuellement le document dans `public/`. + +## Une restauration ou un import échoue + +Arrêtez les autres saisies, conservez le message exact et consultez la page Santé ainsi que les journaux serveur. Ne relancez pas en boucle un remplacement ASM3. L’opération transactionnelle ne conserve normalement aucune écriture partielle. + +## Le serveur affiche « could not find driver » + +L’extension PDO SQLite de PHP manque. Installez le paquet correspondant à votre version de PHP, activez-le pour le mode web utilisé, puis redémarrez PHP-FPM ou le service concerné. + +## Le calendrier est en anglais + +Vérifiez la langue dans **Paramètres généraux** et videz le cache du navigateur après une mise à jour. Les sélecteurs de date Globinours utilisent normalement le français et le format `JJ/MM/AAAA` en interface française. + +## Puis-je travailler sans Internet ? + +Oui si votre appareil peut joindre le serveur Globinours sur le réseau local. Les fonctions internes et bibliothèques d’interface sont locales. Les cartes, tuiles et services externes restent indisponibles sans Internet. + +## Comment demander de l’aide ? + +Indiquez la version du pied de page, la page, l’action, le rôle de votre compte et le message exact. Masquez noms, adresses, identifications, documents et données médicales avant toute capture publique. + +--- + +[← Mobile](12-mobile.md) · [Retour au sommaire](README.md) diff --git a/docs/14-agenda.md b/docs/14-agenda.md new file mode 100644 index 0000000..ad7a783 --- /dev/null +++ b/docs/14-agenda.md @@ -0,0 +1,60 @@ +# Agenda partagé + +[← Tournées de soins](05-tournees.md) · [Sommaire](README.md) · [Annuaire →](06-annuaire.md) + +L’agenda se trouve dans la navigation principale, entre **Tournée** et **Statistiques**. Sa visibilité et sa modification dépendent des permissions du groupe utilisateur. + +## Lire le calendrier + +Les boutons **Mois** et **Semaine** changent la présentation ; Globinours mémorise ce choix pour chaque utilisateur. Les flèches changent de période et **Aujourd’hui** revient immédiatement à la période courante. Chaque événement présente son heure, son titre et l’animal éventuellement lié. La table inférieure rassemble les trente prochains jours et permet de terminer rapidement une tâche. + +Cliquez dans l’espace libre d’une journée pour ouvrir directement le formulaire avec cette date déjà renseignée. Le petit `+` apparaît au survol. Au clavier, placez le focus sur la case puis utilisez Entrée ou Espace. Cliquer sur un événement existant conserve son action habituelle. + +Les rappels de vaccins, les prochains vermifuges et les débuts ou fins de traitements apparaissent automatiquement avec leur propre couleur. Ils ouvrent la section médicale concernée et ne se modifient pas depuis l’agenda. + +Les filtres permettent d’afficher **Toute l’équipe** ou **Mon agenda**, puis de cibler un responsable, un bénévole, une catégorie ou un texte présent dans la mission, l’animal, le lieu ou le nom d’une personne. Les échéances médicales automatiques peuvent être masquées temporairement. Les filtres restent actifs lorsque vous changez de semaine ou de mois et s’appliquent aussi à l’export ponctuel. + +## Exporter ou synchroniser + +**Exporter en .ics** télécharge uniquement le mois ou la semaine affichée. Le fichier peut être ouvert dans Google Agenda, Outlook, Apple Calendrier ou Thunderbird. + +Pour une mise à jour automatique, ouvrez **Abonnement à l’agenda**, puis créez votre lien privé. Copiez-le immédiatement dans la fonction d’abonnement par URL de votre application de calendrier : Globinours ne l’affiche qu’une seule fois et ne conserve que son empreinte cryptographique. + +Ce lien donne accès à l’agenda sans demander de connexion afin que le téléphone puisse le synchroniser en arrière-plan. Il doit donc rester secret. **Régénérer le lien** invalide l’ancienne adresse et en fournit une nouvelle ; **Révoquer le lien** coupe immédiatement la synchronisation. Le flux couvre les 90 derniers jours et les 365 prochains jours. + +## Ajouter un élément + +Cliquez sur **Ajouter un événement**, puis choisissez le type : + +- rendez-vous ; +- rendez-vous vétérinaire ; +- transport ; +- visite pré-adoption ; +- tâche ; +- autre. + +Indiquez au minimum un titre, une date et une heure, ou cochez **Toute la journée**. Vous pouvez ensuite ajouter un lieu, une description, une priorité, un responsable, un animal et un contact de l’annuaire. + +Le responsable est un utilisateur de Globinours chargé du suivi. Les **bénévoles mobilisés** sont sélectionnés séparément parmi les personnes ayant le rôle Bénévole dans l’annuaire ; plusieurs personnes peuvent participer à la même mission sans disposer d’un compte. Leur nom apparaît directement avec la mission. Si un responsable ou un bénévole possède déjà une autre mission sur le même créneau, Globinours enregistre l’événement mais affiche un avertissement afin de laisser l’équipe arbitrer. + +## Rappels et répétitions + +Le rappel peut être fixé à l’heure prévue, trente minutes, une heure, un jour, deux jours ou une semaine avant. Les éléments sous vingt-quatre heures sont visibles sur le dashboard et dans le résumé des notifications. + +Une répétition quotidienne, hebdomadaire ou mensuelle peut être limitée par une date. Globinours développe les occurrences dans le calendrier sans copier l’événement dans la base. + +## Terminer ou annuler + +Le bouton ✓ de la liste marque l’élément comme terminé et conserve l’utilisateur ayant effectué l’action. Un événement peut également être modifié ou annulé. Les événements annulés ne sont plus affichés dans le calendrier courant. + +## Bonnes pratiques + +- liez toujours un rendez-vous à l’animal concerné ; +- attribuez les transports et visites à une personne précise ; +- réservez la priorité urgente aux actions qui nécessitent réellement une réaction immédiate ; +- terminez les tâches réalisées afin de conserver un dashboard lisible ; +- utilisez les répétitions pour les obligations organisationnelles, pas pour les traitements médicaux déjà gérés dans leur module dédié. + +--- + +[← Tournées de soins](05-tournees.md) · [Sommaire](README.md) · [Annuaire →](06-annuaire.md) diff --git a/docs/15-audit-structure-fichiers.md b/docs/15-audit-structure-fichiers.md new file mode 100644 index 0000000..62fbd80 --- /dev/null +++ b/docs/15-audit-structure-fichiers.md @@ -0,0 +1,60 @@ +# Audit des fichiers du projet + +Audit réalisé le 31 août 2026. Les éléments non applicatifs ont ensuite été déplacés, sans suppression, vers le dossier voisin `ARCHIVES/Globinours-recuperation-2026/`. + +## Résumé + +Le cœur déployable de Globinours est désormais séparé de ses données temporaires. Les **612 Mo** de travail historique ont quitté `Twix Codex`, qui ne représente plus qu’environ **130 Mo** avec ses données et sauvegardes locales. + +## Éléments indispensables + +- `app/`, `public/`, `resources/` : code, interface et ressources livrées ; +- `migrations/` : **indispensable**. Les 81 fichiers SQL correspondent aux 81 migrations enregistrées dans `_migrations`. Ils reconstruisent une installation neuve et mettent à niveau les anciennes bases ; +- `data/refuge.sqlite` et les médias présents dans `data/` ou `public/media/` : données de l’instance ; +- `storage/` : sauvegardes, journaux, rapports et dumps ASM3 encore associés aux travaux d’import ; +- `scripts/`, `deploy/`, `tests/` et `docs/` : maintenance, déploiement, contrôles et documentation. + +## Doublons confirmés + +Les comparaisons récursives ne montrent aucune différence entre : + +- `tmp/Website/` et le projet voisin `Website/` ; +- `tmp/repositories/Android/` et le projet voisin `Android/` ; +- `tmp/repositories/yunohost/` et le projet voisin `yunohost/`. + +Les trois copies placées dans `tmp/` étaient donc redondantes. Elles sont conservées dans l’archive de récupération, sans toucher aux projets voisins. + +## Données temporaires historiques + +| Élément | Taille approximative | Conclusion | +|---|---:|---| +| `tmp/recup_chat/` | 580 Mo | Sources PDF/CSV de la récupération historique. Non nécessaires au fonctionnement quotidien, mais à conserver ailleurs tant que la récupération n’est pas officiellement clôturée. | +| `tmp/recup_photo/` | 24 Mo | Anciennes photos ASM3 déjà importées. Non nécessaires à l’exécution ; archivage externe conseillé avant suppression. | +| `tmp/dump_asm3.sql` | 5 Mo | Copie de travail d’un dump déjà importé. Les scripts historiques le prennent encore comme chemin par défaut ; mieux vaut rendre leur argument obligatoire avant suppression définitive. | +| rapports et simulations `tmp/*.json` / `tmp/*.md` | moins de 300 Ko | Traces de migration utiles uniquement pour l’audit historique. | +| `tmp/test/` | 156 Ko | Documents de test, non nécessaires en production. | + +`tmp/` est maintenant vide, hormis son éventuel fichier `.gitkeep`, et reste entièrement ignoré par Git. Il ne doit jamais être copié dans un paquet de production. Les scripts historiques exigent désormais un chemin source explicite et ne dépendent plus de sa présence. + +## Stockage applicatif + +- `storage/backups/` : environ 88 Mo. Ce ne sont pas des doublons de code mais de vraies sauvegardes ; définir une politique de rotation avant nettoyage. +- `storage/asm3-imports/` : environ 25 Mo pour cinq imports. Trois travaux sont encore marqués `failed` ou `analyzed`, deux seulement étant finalisés ou analysés. Ces fichiers doivent être nettoyés depuis une fonction applicative qui synchronise également `asm3_import_jobs`, pas manuellement. +- `storage/logs/` et `storage/reports/` : petits et utiles au diagnostic. + +## Dossiers vides + +Les anciens dossiers vides `conf/` et `doc/`, distincts des vrais dossiers `deploy/` et `docs/`, ont été retirés lors du nettoyage pré-1.0 du 2 septembre 2026. + +## Nettoyage réalisé et suite recommandée + +1. ✅ copies de travail et sources historiques déplacées vers `ARCHIVES/Globinours-recuperation-2026/` ; +2. ✅ chemins des anciens scripts rendus obligatoires au lieu de dépendre de `tmp/` ; +3. protéger l’archive externe, car elle contient des documents et données personnelles historiques ; +4. ajouter une politique de rotation des sauvegardes et une suppression contrôlée des imports ASM3 ; +5. ✅ dossiers réellement vides `conf/` et `doc/` retirés ; +6. conserver sans exception tous les fichiers `migrations/*.sql`. + +## Nettoyage pré-1.0 du 2 septembre 2026 + +Le script historique `architecture.sh` et les trois tableurs ponctuels revenus dans `tmp/` ont été déplacés sans suppression vers `ARCHIVES/Globinours-nettoyage-pre-1.0-20260902/`. Le dossier `tmp/` ne contient plus que son fichier `.gitkeep`. diff --git a/docs/ANALYSE_SUBVENTIONS.md b/docs/ANALYSE_SUBVENTIONS.md new file mode 100644 index 0000000..8bdc92d --- /dev/null +++ b/docs/ANALYSE_SUBVENTIONS.md @@ -0,0 +1,108 @@ +# Analyse des dossiers de subvention + +Analyse réalisée à partir des dossiers communaux et des pièces justificatives placés temporairement dans `tmp/`. Cette synthèse ne reprend aucune donnée bancaire ni coordonnée personnelle issue des fichiers. + +## Constat + +Les communes utilisent des présentations différentes, mais demandent un socle largement commun. Globinours ne doit donc pas reproduire chaque formulaire : il doit conserver une fiche annuelle structurée, calculer les indicateurs disponibles et produire une synthèse réutilisable pour chaque organisme. + +Les modèles analysés datent principalement de 2022. Les champs et pièces devront rester configurables afin d’intégrer les futures versions des communes. + +## Noyau commun + +### Identité et vie statutaire + +- nom, objet, activité, siège, téléphone, courriel et site ; +- RNA/récépissé, SIRET, date de création et date de déclaration ; +- affiliation, fédération et agréments éventuels ; +- président, secrétaire, trésorier, personne chargée du dossier et membres du bureau ; +- date de la dernière assemblée générale. + +### Moyens humains + +- adhérents, parfois ventilés par commune, âge et sexe ; +- montant des cotisations ; +- bénévoles réguliers et valorisation du bénévolat ; +- salariés, type de contrat et équivalent temps plein ; +- informations sportives non pertinentes pour le refuge, à pouvoir marquer « sans objet ». + +### Activité et projet + +- rapport d’activité de l’exercice précédent ; +- actions et manifestations passées et prévues ; +- publics bénéficiaires et participation à la vie locale ; +- description, calendrier, objectifs et budget d’un projet particulier ; +- moyens ou avantages en nature accordés par la commune ; +- montant demandé, montant reçu l’année précédente et autres demandes publiques. + +### Finances + +- compte de résultat ou bilan financier de l’exercice précédent ; +- budget prévisionnel de l’exercice courant ; +- trésorerie et soldes bancaires à la clôture ; +- charges et produits selon les catégories comptables associatives 60 à 78 ; +- contributions volontaires en nature, comptes 86 et 87 ; +- aides publiques cumulées sur les trois derniers exercices ; +- certification de sincérité, régularité sociale et fiscale. + +## Pièces justificatives + +### Permanentes ou rarement renouvelées + +- statuts signés ; +- récépissé de déclaration en préfecture ; +- publication au Journal officiel ; +- relevé d’identité bancaire ; +- composition du bureau. + +### À renouveler + +- attestation d’assurance avec date de validité ; +- procès-verbal de la dernière assemblée générale ; +- rapport d’activité et rapport moral ; +- comptes, bilan financier et compte de résultat ; +- budget prévisionnel ; +- relevés ou attestation des soldes bancaires. + +### Selon la commune ou le projet + +- liste des adhérents résidant dans la commune ; +- convention de mise à disposition d’une salle ; +- lettre motivant la demande ; +- contrat d’engagement républicain ; +- devis et budget d’une action ; +- justificatifs de demandes adressées à d’autres communes ; +- revue de presse ou supports présentant l’activité. + +## Données calculables par Globinours + +- entrées, sorties, adoptions et animaux encore pris en charge ; +- adultes, chatons, sexes, statuts et lieux d’accueil ; +- portées et chatons accueillis ; +- soins, traitements, vaccinations et stérilisations ; +- décès, causes, euthanasies et contexte de prise en charge ; +- durées de séjour et activité par période ; +- animaux confiés ou récupérés par commune lorsque l’origine est correctement renseignée. + +## Données à saisir ou importer + +- comptabilité et trésorerie ; +- adhérents, bénévoles, salariés et temps consacré ; +- manifestations, actions locales et rapports narratifs ; +- subventions demandées, obtenues ou refusées ; +- avantages en nature et moyens municipaux ; +- dates de validité et versions des pièces justificatives. + +## Module recommandé + +1. **Profil administratif enrichi** : identité légale, objet, dirigeants et vie statutaire. +2. **Exercices annuels** : effectifs humains, rapport d’activité, événements et commentaires. +3. **Synthèse comptable** : compte de résultat, budget prévisionnel, trésorerie et bénévolat valorisé, sans chercher à remplacer un logiciel comptable complet. +4. **Demandes de subvention** : organisme, année, date limite, montant demandé/obtenu, état et particularités locales. +5. **Coffre documentaire** : pièces permanentes et annuelles, validité, version et checklist par demande. +6. **Statistiques automatiques du refuge** : chiffres annuels issus de la base avec comparaison N-1. +7. **Sorties** : bilan imprimable, annexes CSV et dossier ZIP des pièces sélectionnées. + +## Règle de conception + +Le PDF propre à une commune reste une pièce externe à compléter lorsque son format l’impose. Globinours doit préparer les chiffres, textes et annexes communs, signaler les éléments manquants et assembler le dossier final, sans enfermer les données dans un formulaire municipal particulier. diff --git a/docs/AUDIT-1.0.md b/docs/AUDIT-1.0.md new file mode 100644 index 0000000..431f623 --- /dev/null +++ b/docs/AUDIT-1.0.md @@ -0,0 +1,47 @@ +# Audit fonctionnel — Globinours 1.0.0 + +Audit exécuté le 30 août 2026. Les contrôles automatisés utilisent une base SQLite temporaire et ne lisent ni ne modifient la base du refuge. + +## Contrôles validés + +- reconstruction complète du schéma et application ordonnée des migrations ; +- `PRAGMA integrity_check` et absence de relations étrangères invalides ; +- installation initiale, authentification, hachage des mots de passe et permissions ; +- parcours animal, note médicale, adoption et relations avec l’annuaire ; +- référentiels biologiques chat et chien ; +- notifications d’échéance ; +- création d’une sauvegarde, manifeste, empreintes, extraction de la base et contrôle SQLite ; +- pharmacie : réception, valorisation, consommation, historique et refus d’un stock négatif ; +- export RGPD et registre des accès privés ; +- purge à échéance du seul registre technique, sans suppression automatique des contacts ; +- appareils de confiance et révocation ; +- parité des catalogues français et anglais et cohérence des variables traduites ; +- validation syntaxique des contrôleurs, services et vues modifiés. + +## Défauts corrigés pendant l’audit + +1. Le formulaire de vermifugation utilisait le champ `medication_id` et le catalogue des médicaments ; il utilise désormais `dewormer_id` et le référentiel dédié. +2. Les actions de suppression et vérification d’une sauvegarde possèdent désormais leur jeton CSRF dans le HTML, sans dépendre de l’initialisation JavaScript. +3. Une archive créée n’était pas relue comme un tout hors restauration ; la vérification autonome contrôle maintenant chaque empreinte et la base extraite. +4. Une dépense médicamenteuse pouvait rester étrangère au coût de l’animal ; la consommation d’un lot tarifé crée désormais la dépense correspondante. + +## Validations terrain encore nécessaires + +Ces points dépendent d’un environnement réel et ne constituent pas des erreurs connues : + +- réception effective des e-mails par le serveur choisi ; +- caméra et sélecteur de fichiers sur les modèles Android/iOS réellement utilisés ; +- tâches cron ou timer systemd sous l’utilisateur PHP de production ; +- restauration complète sur une copie d’installation avec ses médias réels ; +- installation et mise à niveau du futur paquet YunoHost ; +- ergonomie de la pharmacie avec les unités réellement employées par le refuge. + +## Commandes de contrôle + +```bash +php tests/run.php +php scripts/check_translations.php +php scripts/maintenance.php run +``` + +Une sauvegarde de production doit toujours être téléchargée ou répliquée hors du serveur. Une archive présente uniquement sur la même machine ne protège pas d’une panne matérielle. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..467288d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,52 @@ +# Manuel d’utilisation de Globinours + +Bienvenue dans la documentation utilisateur de **Globinours**, le logiciel de gestion quotidienne d’un refuge animalier. + +Ce manuel décrit l’interface telle qu’elle existe actuellement. Il peut être lu directement dans Forgejo, téléchargé avec le projet ou consulté fichier par fichier depuis le dossier `docs/`. + +> Les écrans visibles dépendent du rôle et des permissions de votre compte. Une action absente de votre interface peut simplement ne pas être autorisée pour votre groupe. + +## Sommaire + +### Prendre en main Globinours + +1. [Premiers pas](01-premiers-pas.md) — connexion, navigation, tableau de bord et bonnes pratiques. +2. [Animaux](02-animaux.md) — création, recherche, fiche, photos, lieux de vie, placements, adoption, décès, archives et corbeille. +3. [Portées, fratries et animaux inséparables](03-portees-et-liens.md). +4. [Suivi médical](04-suivi-medical.md) — notes, traitements, vaccins, vermifuges, ordonnances, analyses et dépenses. +5. [Tournées de soins](05-tournees.md) — utilisation rapide sur tablette, salles, box et anomalies. +6. [Agenda partagé](14-agenda.md) — rendez-vous, transports, visites, tâches, responsables et rappels. +7. [Annuaire](06-annuaire.md) — personnes, structures, familles d’accueil, adoptants et partenaires. +8. [Statistiques et subventions](07-statistiques-et-administratif.md) — indicateurs, coûts, communes, registre, exports et dossiers annuels. +9. [Documents et mini-site public](08-documents-et-site-public.md). + +### Administrer l’installation + +10. [Utilisateurs, rôles et confidentialité](09-utilisateurs-et-rgpd.md). +11. [Paramètres et référentiels](10-parametres.md). +12. [Sauvegardes, import ASM3 et maintenance](11-sauvegardes-et-maintenance.md). +13. [Mobile, PWA et application Android](12-mobile.md). +14. [FAQ et dépannage](13-faq.md). + +## Parcours conseillés + +- **Je découvre le logiciel :** lisez [Premiers pas](01-premiers-pas.md), puis [Animaux](02-animaux.md). +- **Je réalise les soins :** consultez [Suivi médical](04-suivi-medical.md) et [Tournées de soins](05-tournees.md). +- **Je prépare un bilan :** ouvrez [Statistiques et subventions](07-statistiques-et-administratif.md). +- **J’administre Globinours :** commencez par [Paramètres](10-parametres.md), puis [Sauvegardes et maintenance](11-sauvegardes-et-maintenance.md). +- **Je déploie le projet :** consultez la section **Déploiement** du [README principal](../README.md) et les fichiers de déploiement fournis dans la distribution. + +## Conventions du manuel + +- Les chemins tels que **Paramètres → Sauvegardes** désignent les menus à ouvrir successivement. +- Les libellés peuvent légèrement varier si l’interface est configurée en anglais. +- Une action sensible signalée par Globinours doit être précédée d’une sauvegarde et relue avant confirmation. +- Les informations médicales et personnelles ne doivent jamais être copiées vers le mini-site public. + +## Aide et contribution + +Si une procédure ne correspond plus à l’écran, ouvrez un ticket sur le dépôt du projet en indiquant la page concernée, votre rôle et la version affichée dans le pied de page. Ne joignez jamais de données personnelles, de document médical ni de sauvegarde réelle à un ticket public. + +--- + +[Lire le premier chapitre →](01-premiers-pas.md) diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..baeede6 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,174 @@ +-- 001_init.sql +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Référentiels +CREATE TABLE IF NOT EXISTS ref_medications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, -- ex: Doxybactin + molecule TEXT, -- ex: doxycycline + form TEXT, -- ex: cp / sirop / inj + notes TEXT +); + +CREATE TABLE IF NOT EXISTS ref_vaccines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, -- ex: TCL / Rage + notes TEXT +); + +CREATE TABLE IF NOT EXISTS animals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + internal_code TEXT NOT NULL UNIQUE, -- ex: 2026-001-CHOCOLAT + name TEXT NOT NULL, + species TEXT NOT NULL DEFAULT 'chat', + sex TEXT CHECK (sex IN ('M','F','U')) NOT NULL DEFAULT 'U', + birth_date TEXT, -- YYYY-MM-DD + chip_id TEXT, -- puce + status TEXT NOT NULL DEFAULT 'refuge', -- refuge / fa / reserve / adopte / fugue / decede + fiv INTEGER NOT NULL DEFAULT 0, + felv INTEGER NOT NULL DEFAULT 0, + sterilized INTEGER NOT NULL DEFAULT 0, + color TEXT, + notes TEXT, + current_address TEXT, + current_lat REAL, + current_lng REAL, + rescue_address TEXT, + rescue_lat REAL, + rescue_lng REAL, + rescue_location_name TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_animals_status ON animals(status); +CREATE INDEX IF NOT EXISTS idx_animals_name ON animals(name); +CREATE INDEX IF NOT EXISTS idx_animals_code ON animals(internal_code); + +CREATE TABLE IF NOT EXISTS adoptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + adopter_name TEXT NOT NULL, + adopter_phone TEXT, + adopter_email TEXT, + adoption_date DATE NOT NULL, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + adopter_address TEXT, + adopter_postal_code TEXT, + adopter_city TEXT, + adopter_country TEXT DEFAULT 'France', + FOREIGN KEY(animal_id) REFERENCES animals(id) +); + +CREATE TABLE IF NOT EXISTS animal_movements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('entry','exit')), + place TEXT, + lieu TEXT, + contact_name TEXT, + contact_phone TEXT, + contact_email TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) +); + +-- Mesures (poids/temp/etc.) +CREATE TABLE IF NOT EXISTS measurements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + measured_at TEXT NOT NULL DEFAULT (datetime('now')), + type TEXT NOT NULL, -- weight / temp / rr / hr ... + value REAL NOT NULL, + unit TEXT NOT NULL, + notes TEXT, + created_by INTEGER, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_measurements_animal_date ON measurements(animal_id, measured_at); + +-- Notes médicales “style dossier patient” +CREATE TABLE IF NOT EXISTS medical_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + noted_at TEXT NOT NULL DEFAULT (datetime('now')), + kind TEXT NOT NULL, -- consult / controle / symptome / diagnostic / acte / hosp / autre + reason TEXT, -- motif + symptoms TEXT, -- symptômes + exam TEXT, -- examen clinique + diagnosis TEXT, -- diagnostic / hypothèses + plan TEXT, -- plan / conduite à tenir + temperature_c REAL, -- optionnel + weight_kg REAL, -- optionnel + created_by INTEGER, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_mednotes_animal_date ON medical_notes(animal_id, noted_at); + +-- Traitements (référentiel médocs) +CREATE TABLE IF NOT EXISTS treatments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + medication_id INTEGER NOT NULL, + route TEXT, -- PO/SC/IM/Topique... + dose_text TEXT NOT NULL, -- "1/2 cp matin/soir" + start_date TEXT NOT NULL, -- YYYY-MM-DD + end_date TEXT, -- YYYY-MM-DD + ongoing INTEGER NOT NULL DEFAULT 1, + notes TEXT, + created_by INTEGER, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(medication_id) REFERENCES ref_medications(id) ON DELETE RESTRICT, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_treatments_animal_ongoing ON treatments(animal_id, ongoing); + +-- Vaccins +CREATE TABLE IF NOT EXISTS vaccinations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + vaccine_id INTEGER NOT NULL, + done_date TEXT NOT NULL, -- YYYY-MM-DD + due_date TEXT, -- rappel + lot TEXT, + notes TEXT, + created_by INTEGER, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(vaccine_id) REFERENCES ref_vaccines(id) ON DELETE RESTRICT, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_vaccinations_due ON vaccinations(due_date); + +-- Vue résumé médical (liste animaux) +CREATE VIEW IF NOT EXISTS v_animals_med_summary AS +SELECT + a.id AS animal_id, + (SELECT mn.noted_at + FROM medical_notes mn + WHERE mn.animal_id = a.id + ORDER BY mn.noted_at DESC + LIMIT 1) AS last_med_at, + (SELECT COUNT(*) + FROM treatments t + WHERE t.animal_id = a.id AND t.ongoing = 1) AS ongoing_treatments, + (SELECT MIN(v.due_date) + FROM vaccinations v + WHERE v.animal_id = a.id AND v.due_date IS NOT NULL AND date(v.due_date) >= date('now')) AS next_vaccine_due +FROM animals a; diff --git a/migrations/002_vaccines_code.sql b/migrations/002_vaccines_code.sql new file mode 100644 index 0000000..7248921 --- /dev/null +++ b/migrations/002_vaccines_code.sql @@ -0,0 +1,5 @@ +ALTER TABLE ref_vaccines ADD COLUMN family TEXT; + +-- Mapping simple (à ajuster si tu as d'autres libellés) +UPDATE ref_vaccines SET family='TC' WHERE upper(name) LIKE '%TC%' OR upper(name) LIKE '%TCL%' OR upper(name) LIKE '%CORYZA%' OR upper(name) LIKE '%TYPHUS%'; +UPDATE ref_vaccines SET family='L' WHERE upper(name) LIKE '%LEUCO%' OR upper(name) LIKE '%FELV%' OR upper(name) = 'L'; diff --git a/migrations/003_clinics.sql b/migrations/003_clinics.sql new file mode 100644 index 0000000..e7a5fbd --- /dev/null +++ b/migrations/003_clinics.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS clinics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +-- Exemples (tu adapteras) +INSERT OR IGNORE INTO clinics(name) VALUES ('Clinique A'); +INSERT OR IGNORE INTO clinics(name) VALUES ('Clinique B'); diff --git a/migrations/004_add_clinic_vet_fields.sql b/migrations/004_add_clinic_vet_fields.sql new file mode 100644 index 0000000..dfbf4aa --- /dev/null +++ b/migrations/004_add_clinic_vet_fields.sql @@ -0,0 +1,8 @@ +ALTER TABLE medical_notes ADD COLUMN clinic_id INTEGER; +ALTER TABLE medical_notes ADD COLUMN vet_name TEXT; + +ALTER TABLE treatments ADD COLUMN clinic_id INTEGER; +ALTER TABLE treatments ADD COLUMN vet_name TEXT; + +ALTER TABLE vaccinations ADD COLUMN clinic_id INTEGER; +ALTER TABLE vaccinations ADD COLUMN vet_name TEXT; diff --git a/migrations/005_weight_trend.sql b/migrations/005_weight_trend.sql new file mode 100644 index 0000000..7d439e5 --- /dev/null +++ b/migrations/005_weight_trend.sql @@ -0,0 +1,16 @@ +CREATE VIEW IF NOT EXISTS v_animals_weight_trend AS +SELECT + a.id AS animal_id, + + (SELECT m.value + FROM measurements m + WHERE m.animal_id = a.id AND m.type = 'weight' + ORDER BY datetime(m.measured_at) DESC + LIMIT 1) AS last_weight, + + (SELECT m.value + FROM measurements m + WHERE m.animal_id = a.id AND m.type = 'weight' + ORDER BY datetime(m.measured_at) DESC + LIMIT 1 OFFSET 1) AS prev_weight +FROM animals a; diff --git a/migrations/006_fix_weight_trend_view.sql b/migrations/006_fix_weight_trend_view.sql new file mode 100644 index 0000000..05c9a20 --- /dev/null +++ b/migrations/006_fix_weight_trend_view.sql @@ -0,0 +1,18 @@ +DROP VIEW IF EXISTS v_animals_weight_trend; + +CREATE VIEW v_animals_weight_trend AS +SELECT + a.id AS animal_id, + + (SELECT m.value + FROM measurements m + WHERE m.animal_id = a.id AND m.type = 'weight' + ORDER BY datetime(m.measured_at) DESC, m.id DESC + LIMIT 1) AS last_weight, + + (SELECT m.value + FROM measurements m + WHERE m.animal_id = a.id AND m.type = 'weight' + ORDER BY datetime(m.measured_at) DESC, m.id DESC + LIMIT 1 OFFSET 1) AS prev_weight +FROM animals a; diff --git a/migrations/007_fiv_felv_status.sql b/migrations/007_fiv_felv_status.sql new file mode 100644 index 0000000..daa6326 --- /dev/null +++ b/migrations/007_fiv_felv_status.sql @@ -0,0 +1,7 @@ +ALTER TABLE animals ADD COLUMN fiv_status TEXT; +ALTER TABLE animals ADD COLUMN felv_status TEXT; + +UPDATE animals +SET fiv_status = CASE WHEN fiv = 1 THEN 'pos' ELSE 'neg' END, + felv_status = CASE WHEN felv = 1 THEN 'pos' ELSE 'neg' END +WHERE fiv_status IS NULL AND felv_status IS NULL; diff --git a/migrations/008_animal_photos.sql b/migrations/008_animal_photos.sql new file mode 100644 index 0000000..74565bb --- /dev/null +++ b/migrations/008_animal_photos.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS animal_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + filename TEXT NOT NULL, + original_name TEXT, + mime TEXT, + size_bytes INTEGER, + is_primary INTEGER DEFAULT 0, + is_public INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_animal_photos_animal ON animal_photos(animal_id); + +CREATE TABLE IF NOT EXISTS medical_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + medical_note_id INTEGER NOT NULL, + filename TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id), + FOREIGN KEY(medical_note_id) REFERENCES medical_notes(id) +); + +CREATE INDEX IF NOT EXISTS idx_medical_photos_animal ON medical_photos(animal_id); +CREATE INDEX IF NOT EXISTS idx_medical_photos_note ON medical_photos(medical_note_id); diff --git a/migrations/009_weight_trend_distinct.sql b/migrations/009_weight_trend_distinct.sql new file mode 100644 index 0000000..6a814c9 --- /dev/null +++ b/migrations/009_weight_trend_distinct.sql @@ -0,0 +1,30 @@ +DROP VIEW IF EXISTS v_animals_weight_trend; + +CREATE VIEW v_animals_weight_trend AS +SELECT + a.id AS animal_id, + + ( + SELECT m.value + FROM measurements m + WHERE m.animal_id = a.id AND m.type='weight' + ORDER BY m.id DESC + LIMIT 1 + ) AS last_weight, + + ( + SELECT m2.value + FROM measurements m2 + WHERE m2.animal_id = a.id + AND m2.type='weight' + AND m2.value != ( + SELECT m3.value + FROM measurements m3 + WHERE m3.animal_id = a.id AND m3.type='weight' + ORDER BY m3.id DESC + LIMIT 1 + ) + ORDER BY m2.id DESC + LIMIT 1 + ) AS prev_weight +FROM animals a; diff --git a/migrations/010_quarantine_dates.sql b/migrations/010_quarantine_dates.sql new file mode 100644 index 0000000..8d683b0 --- /dev/null +++ b/migrations/010_quarantine_dates.sql @@ -0,0 +1,2 @@ +ALTER TABLE animals ADD COLUMN intake_date TEXT; +ALTER TABLE animals ADD COLUMN quarantine_until TEXT; diff --git a/migrations/011_weight_alerts.sql b/migrations/011_weight_alerts.sql new file mode 100644 index 0000000..ed5bf64 --- /dev/null +++ b/migrations/011_weight_alerts.sql @@ -0,0 +1,34 @@ +DROP VIEW IF EXISTS v_animals_weight_alerts; + +CREATE VIEW v_animals_weight_alerts AS +SELECT + a.id AS animal_id, + + -- dernier poids + ( + SELECT m.value + FROM measurements m + WHERE m.animal_id=a.id AND m.type='weight' + ORDER BY m.id DESC + LIMIT 1 + ) AS last_weight, + + -- poids de référence ~30 jours avant : le plus récent <= J-30 + ( + SELECT m.value + FROM measurements m + WHERE m.animal_id=a.id AND m.type='weight' + AND date(m.measured_at) <= date('now','-30 day') + ORDER BY date(m.measured_at) DESC, m.id DESC + LIMIT 1 + ) AS w30_weight, + + ( + SELECT m.measured_at + FROM measurements m + WHERE m.animal_id=a.id AND m.type='weight' + AND date(m.measured_at) <= date('now','-30 day') + ORDER BY date(m.measured_at) DESC, m.id DESC + LIMIT 1 + ) AS w30_date +FROM animals a; diff --git a/migrations/012_add_chip.sql b/migrations/012_add_chip.sql new file mode 100644 index 0000000..0addb00 --- /dev/null +++ b/migrations/012_add_chip.sql @@ -0,0 +1 @@ +ALTER TABLE animals ADD COLUMN chip TEXT; diff --git a/migrations/013_animal_identity_v2.sql b/migrations/013_animal_identity_v2.sql new file mode 100644 index 0000000..0e802b9 --- /dev/null +++ b/migrations/013_animal_identity_v2.sql @@ -0,0 +1,4 @@ +ALTER TABLE animals ADD COLUMN breed TEXT DEFAULT 'Chat Européen'; + +ALTER TABLE animals ADD COLUMN birth_is_estimated INTEGER DEFAULT 0; +ALTER TABLE animals ADD COLUMN birth_estimated_months INTEGER; diff --git a/migrations/014_add_clinic_vet_to_treatments.sql b/migrations/014_add_clinic_vet_to_treatments.sql new file mode 100644 index 0000000..0398946 --- /dev/null +++ b/migrations/014_add_clinic_vet_to_treatments.sql @@ -0,0 +1,2 @@ +-- Conservé pour la compatibilité historique : ces colonnes sont déjà créées par 004. +SELECT 1; diff --git a/migrations/015_add_clinic_vet_to_vaccinations.sql b/migrations/015_add_clinic_vet_to_vaccinations.sql new file mode 100644 index 0000000..0398946 --- /dev/null +++ b/migrations/015_add_clinic_vet_to_vaccinations.sql @@ -0,0 +1,2 @@ +-- Conservé pour la compatibilité historique : ces colonnes sont déjà créées par 004. +SELECT 1; diff --git a/migrations/020_animal_history.sql b/migrations/020_animal_history.sql new file mode 100644 index 0000000..5710983 --- /dev/null +++ b/migrations/020_animal_history.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS animal_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + action TEXT NOT NULL, -- created, updated, status_changed, note_added... + field TEXT, -- name, status, etc (optionnel) + old_value TEXT, + new_value TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + meta TEXT -- JSON léger optionnel (ex: {"route":"..."} ) +); + +CREATE INDEX IF NOT EXISTS idx_animal_history_animal ON animal_history(animal_id, created_at DESC); diff --git a/migrations/021_add_hair_type.sql b/migrations/021_add_hair_type.sql new file mode 100644 index 0000000..d129f53 --- /dev/null +++ b/migrations/021_add_hair_type.sql @@ -0,0 +1 @@ +ALTER TABLE animals ADD COLUMN hair_type TEXT; diff --git a/migrations/022_rename_cat_species.sql b/migrations/022_rename_cat_species.sql new file mode 100644 index 0000000..49e84bc --- /dev/null +++ b/migrations/022_rename_cat_species.sql @@ -0,0 +1,3 @@ +UPDATE animals +SET species = 'chat' +WHERE lower(trim(species)) = 'cat'; diff --git a/migrations/023_animal_health_conditions.sql b/migrations/023_animal_health_conditions.sql new file mode 100644 index 0000000..d926fc3 --- /dev/null +++ b/migrations/023_animal_health_conditions.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS animal_health_conditions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'resolved')), + diagnosed_at TEXT, + resolved_at TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_health_conditions_animal_status +ON animal_health_conditions(animal_id, status); diff --git a/migrations/024_litters.sql b/migrations/024_litters.sql new file mode 100644 index 0000000..1bee441 --- /dev/null +++ b/migrations/024_litters.sql @@ -0,0 +1,26 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS litters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mother_id INTEGER, + father_id INTEGER, + birth_date TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(mother_id) REFERENCES animals(id) ON DELETE SET NULL, + FOREIGN KEY(father_id) REFERENCES animals(id) ON DELETE SET NULL, + CHECK (mother_id IS NULL OR father_id IS NULL OR mother_id != father_id) +); + +CREATE TABLE IF NOT EXISTS litter_kittens ( + litter_id INTEGER NOT NULL, + animal_id INTEGER NOT NULL UNIQUE, + position INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY(litter_id, animal_id), + FOREIGN KEY(litter_id) REFERENCES litters(id) ON DELETE CASCADE, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_litters_mother ON litters(mother_id); +CREATE INDEX IF NOT EXISTS idx_litters_father ON litters(father_id); +CREATE INDEX IF NOT EXISTS idx_litter_kittens_litter ON litter_kittens(litter_id, position); diff --git a/migrations/025_animal_archive_and_trash.sql b/migrations/025_animal_archive_and_trash.sql new file mode 100644 index 0000000..048d22b --- /dev/null +++ b/migrations/025_animal_archive_and_trash.sql @@ -0,0 +1,9 @@ +ALTER TABLE animals ADD COLUMN archived_at TEXT; +ALTER TABLE animals ADD COLUMN deletion_reason TEXT; + +-- L'ancien bouton « Archiver » utilisait deleted_at : on conserve donc ces fiches +-- comme archives et on libère la nouvelle corbeille. +UPDATE animals SET archived_at = deleted_at, deleted_at = NULL WHERE deleted_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_animals_archived_at ON animals(archived_at); +CREATE INDEX IF NOT EXISTS idx_animals_deleted_at ON animals(deleted_at); diff --git a/migrations/026_refuge_room.sql b/migrations/026_refuge_room.sql new file mode 100644 index 0000000..420d3e8 --- /dev/null +++ b/migrations/026_refuge_room.sql @@ -0,0 +1,2 @@ +ALTER TABLE animals ADD COLUMN refuge_room TEXT CHECK (refuge_room IN ('silver', 'twix') OR refuge_room IS NULL); +CREATE INDEX IF NOT EXISTS idx_animals_refuge_room ON animals(refuge_room); diff --git a/migrations/027_bonded_groups.sql b/migrations/027_bonded_groups.sql new file mode 100644 index 0000000..2e23f20 --- /dev/null +++ b/migrations/027_bonded_groups.sql @@ -0,0 +1,23 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS bonded_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + notes TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + dissolved_at TEXT +); + +CREATE TABLE IF NOT EXISTS bonded_group_members ( + group_id INTEGER NOT NULL, + animal_id INTEGER NOT NULL, + position INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY(group_id, animal_id), + FOREIGN KEY(group_id) REFERENCES bonded_groups(id) ON DELETE CASCADE, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_bonded_members_animal +ON bonded_group_members(animal_id); +CREATE INDEX IF NOT EXISTS idx_bonded_members_group ON bonded_group_members(group_id, position); diff --git a/migrations/028_directory.sql b/migrations/028_directory.sql new file mode 100644 index 0000000..0cb614d --- /dev/null +++ b/migrations/028_directory.sql @@ -0,0 +1,76 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS directory_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL DEFAULT 'person' CHECK(kind IN ('person','organization')), + name TEXT NOT NULL, + phone TEXT, + email TEXT, + address TEXT, + postal_code TEXT, + city TEXT, + country TEXT NOT NULL DEFAULT 'France', + organization_id INTEGER, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + FOREIGN KEY(organization_id) REFERENCES directory_contacts(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS directory_contact_roles ( + contact_id INTEGER NOT NULL, + role TEXT NOT NULL CHECK(role IN ('fa','benevole','dirigeant','veterinaire','cabinet','crematorium','adoptant')), + PRIMARY KEY(contact_id, role), + FOREIGN KEY(contact_id) REFERENCES directory_contacts(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_directory_name ON directory_contacts(name); +CREATE INDEX IF NOT EXISTS idx_directory_role ON directory_contact_roles(role, contact_id); + +ALTER TABLE medical_notes ADD COLUMN vet_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE medical_notes ADD COLUMN clinic_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE treatments ADD COLUMN vet_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE treatments ADD COLUMN clinic_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE vaccinations ADD COLUMN vet_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE vaccinations ADD COLUMN clinic_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE adoptions ADD COLUMN adopter_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; + +INSERT INTO directory_contacts(kind, name) +SELECT 'organization', c.name FROM clinics c +WHERE NOT EXISTS (SELECT 1 FROM directory_contacts dc WHERE dc.kind='organization' AND lower(dc.name)=lower(c.name)); +INSERT OR IGNORE INTO directory_contact_roles(contact_id, role) +SELECT dc.id, 'cabinet' FROM directory_contacts dc JOIN clinics c ON lower(c.name)=lower(dc.name); + +INSERT INTO directory_contacts(kind, name, phone, email, address, postal_code, city) +SELECT 'person', a.adopter_name, MAX(a.adopter_phone), MAX(a.adopter_email), MAX(a.adopter_address), MAX(a.adopter_postal_code), MAX(a.adopter_city) +FROM adoptions a +WHERE NOT EXISTS ( + SELECT 1 FROM directory_contacts dc + WHERE dc.kind='person' AND lower(dc.name)=lower(a.adopter_name) + AND lower(COALESCE(dc.email,''))=lower(COALESCE(a.adopter_email,'')) +) +GROUP BY lower(a.adopter_name), lower(COALESCE(a.adopter_email,'')); +INSERT OR IGNORE INTO directory_contact_roles(contact_id, role) +SELECT DISTINCT dc.id, 'adoptant' FROM directory_contacts dc JOIN adoptions a +ON dc.kind='person' AND lower(dc.name)=lower(a.adopter_name) +AND lower(COALESCE(dc.email,''))=lower(COALESCE(a.adopter_email,'')); +UPDATE adoptions SET adopter_contact_id = ( + SELECT dc.id FROM directory_contacts dc + WHERE dc.kind='person' AND lower(dc.name)=lower(adoptions.adopter_name) + AND lower(COALESCE(dc.email,''))=lower(COALESCE(adoptions.adopter_email,'')) LIMIT 1 +) WHERE adopter_contact_id IS NULL; + +INSERT INTO directory_contacts(kind, name) +SELECT 'person', vet_name FROM ( + SELECT vet_name FROM medical_notes UNION SELECT vet_name FROM treatments UNION SELECT vet_name FROM vaccinations +) vets +WHERE vet_name IS NOT NULL AND trim(vet_name)!='' +AND NOT EXISTS (SELECT 1 FROM directory_contacts dc WHERE dc.kind='person' AND lower(dc.name)=lower(vets.vet_name)); +INSERT OR IGNORE INTO directory_contact_roles(contact_id, role) +SELECT dc.id, 'veterinaire' FROM directory_contacts dc +WHERE EXISTS ( + SELECT 1 FROM medical_notes WHERE lower(vet_name)=lower(dc.name) + UNION SELECT 1 FROM treatments WHERE lower(vet_name)=lower(dc.name) + UNION SELECT 1 FROM vaccinations WHERE lower(vet_name)=lower(dc.name) +); diff --git a/migrations/029_care_rounds.sql b/migrations/029_care_rounds.sql new file mode 100644 index 0000000..ff8a5e1 --- /dev/null +++ b/migrations/029_care_rounds.sql @@ -0,0 +1,57 @@ +PRAGMA foreign_keys = ON; + +ALTER TABLE animals ADD COLUMN care_box_key TEXT; +ALTER TABLE treatments ADD COLUMN give_morning INTEGER NOT NULL DEFAULT 0; +ALTER TABLE treatments ADD COLUMN give_evening INTEGER NOT NULL DEFAULT 0; +ALTER TABLE treatments ADD COLUMN give_as_needed INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS care_room_layouts ( + room TEXT PRIMARY KEY CHECK(room IN ('care','quarantine')), + top_layout TEXT NOT NULL DEFAULT 'split' CHECK(top_layout IN ('split','merged')), + bottom_layout TEXT NOT NULL DEFAULT 'split' CHECK(bottom_layout IN ('split','left2','right2','merged')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT OR IGNORE INTO care_room_layouts(room) VALUES('care'),('quarantine'); + +CREATE TABLE IF NOT EXISTS care_rounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round_date TEXT NOT NULL, + period TEXT NOT NULL CHECK(period IN ('morning','evening')), + performed_by TEXT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + UNIQUE(round_date, period) +); + +CREATE TABLE IF NOT EXISTS care_round_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round_id INTEGER NOT NULL, + animal_id INTEGER NOT NULL, + food TEXT NOT NULL DEFAULT 'normal' CHECK(food IN ('normal','little','refused')), + water TEXT NOT NULL DEFAULT 'normal' CHECK(water IN ('normal','renewed','abnormal')), + urine TEXT NOT NULL DEFAULT 'normal' CHECK(urine IN ('normal','absent','abnormal')), + stool TEXT NOT NULL DEFAULT 'normal' CHECK(stool IN ('normal','absent','abnormal')), + general_state TEXT NOT NULL DEFAULT 'good' CHECK(general_state IN ('good','watch','alert')), + comment TEXT, + checked_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(round_id, animal_id), + FOREIGN KEY(round_id) REFERENCES care_rounds(id) ON DELETE CASCADE, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS treatment_administrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round_id INTEGER NOT NULL, + treatment_id INTEGER NOT NULL, + animal_id INTEGER NOT NULL, + status TEXT NOT NULL CHECK(status IN ('given','skipped')), + comment TEXT, + administered_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(round_id, treatment_id), + FOREIGN KEY(round_id) REFERENCES care_rounds(id) ON DELETE CASCADE, + FOREIGN KEY(treatment_id) REFERENCES treatments(id) ON DELETE CASCADE, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_care_observations_round ON care_round_observations(round_id); +CREATE INDEX IF NOT EXISTS idx_treatment_admin_round ON treatment_administrations(round_id); diff --git a/migrations/030_invert_care_box_rows.sql b/migrations/030_invert_care_box_rows.sql new file mode 100644 index 0000000..005db7a --- /dev/null +++ b/migrations/030_invert_care_box_rows.sql @@ -0,0 +1,28 @@ +CREATE TABLE care_room_layouts_new ( + room TEXT PRIMARY KEY CHECK(room IN ('care','quarantine')), + top_layout TEXT NOT NULL DEFAULT 'split' CHECK(top_layout IN ('split','left2','right2','merged')), + bottom_layout TEXT NOT NULL DEFAULT 'split' CHECK(bottom_layout IN ('split','merged')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO care_room_layouts_new(room, top_layout, bottom_layout, updated_at) +SELECT room, + top_layout, + CASE WHEN bottom_layout = 'merged' THEN 'merged' ELSE 'split' END, + updated_at +FROM care_room_layouts; + +DROP TABLE care_room_layouts; +ALTER TABLE care_room_layouts_new RENAME TO care_room_layouts; + +-- Les anciennes positions sont rapprochées de leur nouvelle position physique. +-- Plusieurs chats pouvant partager un box, aucun animal n'est perdu lors du regroupement. +UPDATE animals +SET care_box_key = CASE care_box_key + WHEN 'bottom_b' THEN 'bottom_a' + WHEN 'bottom_c' THEN 'bottom_b' + WHEN 'bottom_ab' THEN 'bottom_a' + WHEN 'bottom_bc' THEN 'bottom_b' + ELSE care_box_key +END +WHERE care_box_key IN ('bottom_b','bottom_c','bottom_ab','bottom_bc'); diff --git a/migrations/031_care_round_alert_history.sql b/migrations/031_care_round_alert_history.sql new file mode 100644 index 0000000..41dfe8e --- /dev/null +++ b/migrations/031_care_round_alert_history.sql @@ -0,0 +1 @@ +ALTER TABLE care_round_observations ADD COLUMN alert_signature TEXT; diff --git a/migrations/032_care_round_private_photos.sql b/migrations/032_care_round_private_photos.sql new file mode 100644 index 0000000..c7689f7 --- /dev/null +++ b/migrations/032_care_round_private_photos.sql @@ -0,0 +1,2 @@ +ALTER TABLE animal_photos ADD COLUMN caption TEXT; +ALTER TABLE animal_photos ADD COLUMN care_round_id INTEGER REFERENCES care_rounds(id) ON DELETE SET NULL; diff --git a/migrations/033_directory_depositor_role.sql b/migrations/033_directory_depositor_role.sql new file mode 100644 index 0000000..c3dbf9c --- /dev/null +++ b/migrations/033_directory_depositor_role.sql @@ -0,0 +1,13 @@ +CREATE TABLE directory_contact_roles_new ( + contact_id INTEGER NOT NULL, + role TEXT NOT NULL CHECK(role IN ('fa','benevole','dirigeant','veterinaire','cabinet','crematorium','adoptant','deposant')), + PRIMARY KEY(contact_id, role), + FOREIGN KEY(contact_id) REFERENCES directory_contacts(id) ON DELETE CASCADE +); + +INSERT INTO directory_contact_roles_new(contact_id,role) +SELECT contact_id,role FROM directory_contact_roles; + +DROP TABLE directory_contact_roles; +ALTER TABLE directory_contact_roles_new RENAME TO directory_contact_roles; +CREATE INDEX idx_directory_role ON directory_contact_roles(role,contact_id); diff --git a/migrations/034_authentication_and_audit.sql b/migrations/034_authentication_and_audit.sql new file mode 100644 index 0000000..553a524 --- /dev/null +++ b/migrations/034_authentication_and_audit.sql @@ -0,0 +1,26 @@ +ALTER TABLE users ADD COLUMN display_name TEXT; +ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN last_login_at TEXT; +ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now')); +ALTER TABLE users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN locked_until TEXT; + +ALTER TABLE animal_history ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE care_rounds ADD COLUMN performed_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL; + +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + entity_type TEXT, + entity_id INTEGER, + summary TEXT, + metadata TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC); +CREATE INDEX idx_audit_log_user ON audit_log(user_id,created_at DESC); diff --git a/migrations/035_animal_deaths.sql b/migrations/035_animal_deaths.sql new file mode 100644 index 0000000..bc9426c --- /dev/null +++ b/migrations/035_animal_deaths.sql @@ -0,0 +1,29 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS animal_deaths ( + animal_id INTEGER PRIMARY KEY, + deceased_date TEXT NOT NULL, + cause_code TEXT NOT NULL, + cause_details TEXT, + occurred_in_care INTEGER NOT NULL DEFAULT 1 CHECK(occurred_in_care IN (0,1)), + place_type TEXT NOT NULL DEFAULT 'refuge' CHECK(place_type IN ('refuge','fa','veterinaire','exterieur','inconnu','autre')), + place_details TEXT, + euthanized INTEGER NOT NULL DEFAULT 0 CHECK(euthanized IN (0,1)), + veterinarian_contact_id INTEGER, + crematorium_contact_id INTEGER, + body_disposition TEXT NOT NULL DEFAULT 'pending' CHECK(body_disposition IN ('pending','collective_cremation','individual_cremation','returned_family','burial','other')), + cremation_date TEXT, + notes TEXT, + created_by INTEGER, + updated_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(veterinarian_contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(crematorium_contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY(updated_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_animal_deaths_date ON animal_deaths(deceased_date); +CREATE INDEX IF NOT EXISTS idx_animal_deaths_cause ON animal_deaths(cause_code, occurred_in_care); diff --git a/migrations/036_app_settings.sql b/migrations/036_app_settings.sql new file mode 100644 index 0000000..3820761 --- /dev/null +++ b/migrations/036_app_settings.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS app_settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT NOT NULL, + updated_by INTEGER, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(updated_by) REFERENCES users(id) ON DELETE SET NULL +); + +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES +('association_name','Association'), +('association_address',''), +('association_postal_code',''), +('association_city',''), +('association_phone',''), +('association_email',''), +('association_siret',''), +('association_rna',''), +('adoption_fee_sterilized','220'), +('adoption_fee_unsterilized','200'), +('quarantine_days','15'), +('backup_retention','10'); diff --git a/migrations/037_footer_settings.sql b/migrations/037_footer_settings.sql new file mode 100644 index 0000000..41d97c2 --- /dev/null +++ b/migrations/037_footer_settings.sql @@ -0,0 +1,5 @@ +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES +('project_description','Né de l’histoire de Twix, conçu pour protéger les suivants.'), +('development_year','2026'), +('developer_name',''), +('app_version','0.1'); diff --git a/migrations/038_remove_footer_settings.sql b/migrations/038_remove_footer_settings.sql new file mode 100644 index 0000000..641bfe4 --- /dev/null +++ b/migrations/038_remove_footer_settings.sql @@ -0,0 +1,2 @@ +DELETE FROM app_settings +WHERE setting_key IN ('project_description','development_year','developer_name','app_version'); diff --git a/migrations/039_sync_death_movements.sql b/migrations/039_sync_death_movements.sql new file mode 100644 index 0000000..91f4a2e --- /dev/null +++ b/migrations/039_sync_death_movements.sql @@ -0,0 +1,9 @@ +UPDATE animal_movements +SET created_at=(SELECT d.deceased_date||' 12:00:00' FROM animal_deaths d WHERE d.animal_id=animal_movements.animal_id), + place=(SELECT CASE d.place_type WHEN 'refuge' THEN 'Au refuge' WHEN 'fa' THEN 'En famille d''accueil' WHEN 'veterinaire' THEN 'Chez le vétérinaire' WHEN 'exterieur' THEN 'À l''extérieur' WHEN 'inconnu' THEN 'Lieu inconnu' ELSE 'Autre lieu' END FROM animal_deaths d WHERE d.animal_id=animal_movements.animal_id), + contact_name=(SELECT v.name FROM animal_deaths d LEFT JOIN directory_contacts v ON v.id=d.veterinarian_contact_id WHERE d.animal_id=animal_movements.animal_id), + note=(SELECT 'Cause : '||CASE d.cause_code WHEN 'disease' THEN 'Maladie' WHEN 'accident' THEN 'Accident' WHEN 'old_age' THEN 'Vieillesse' WHEN 'congenital' THEN 'Malformation ou affection congénitale' WHEN 'surgery_complication' THEN 'Complication médicale ou chirurgicale' WHEN 'poisoning' THEN 'Empoisonnement' WHEN 'unknown' THEN 'Cause inconnue' ELSE 'Autre cause' END||CASE WHEN d.euthanized=1 THEN char(10)||'Euthanasie : oui' ELSE '' END||CASE WHEN c.name IS NOT NULL THEN char(10)||'Crématorium : '||c.name ELSE '' END FROM animal_deaths d LEFT JOIN directory_contacts c ON c.id=d.crematorium_contact_id WHERE d.animal_id=animal_movements.animal_id) +WHERE id IN ( + SELECT MAX(m.id) FROM animal_movements m JOIN animal_deaths d ON d.animal_id=m.animal_id + WHERE m.kind='exit' AND lower(m.lieu) IN ('décès','deces','décès de l''animal','deces de l''animal') GROUP BY m.animal_id +); diff --git a/migrations/040_normalize_death_movement_label.sql b/migrations/040_normalize_death_movement_label.sql new file mode 100644 index 0000000..5791681 --- /dev/null +++ b/migrations/040_normalize_death_movement_label.sql @@ -0,0 +1,3 @@ +UPDATE animal_movements +SET lieu='Décès de l''animal' +WHERE kind='exit' AND lower(lieu) IN ('décès','deces'); diff --git a/migrations/041_grants.sql b/migrations/041_grants.sql new file mode 100644 index 0000000..81c0ed8 --- /dev/null +++ b/migrations/041_grants.sql @@ -0,0 +1,55 @@ +CREATE TABLE grant_years ( + year INTEGER PRIMARY KEY CHECK(year BETWEEN 2000 AND 2200), + members_count INTEGER NOT NULL DEFAULT 0, + volunteers_count INTEGER NOT NULL DEFAULT 0, + employees_count INTEGER NOT NULL DEFAULT 0, + employees_fte REAL NOT NULL DEFAULT 0, + last_ag_date TEXT, + annual_report TEXT, + completed_actions TEXT, + planned_actions TEXT, + beneficiaries TEXT, + officers_notes TEXT, + in_kind_support TEXT, + treasury_amount REAL NOT NULL DEFAULT 0, + actual_expenses REAL NOT NULL DEFAULT 0, + actual_income REAL NOT NULL DEFAULT 0, + forecast_expenses REAL NOT NULL DEFAULT 0, + forecast_income REAL NOT NULL DEFAULT 0, + voluntary_contributions REAL NOT NULL DEFAULT 0, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE grant_applications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER NOT NULL REFERENCES grant_years(year) ON DELETE CASCADE, + organization_name TEXT NOT NULL, + deadline TEXT, + requested_amount REAL NOT NULL DEFAULT 0, + previous_amount REAL NOT NULL DEFAULT 0, + awarded_amount REAL, + status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','ready','submitted','awarded','refused','withdrawn')), + notes TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_grant_applications_year ON grant_applications(year, deadline); + +CREATE TABLE grant_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER REFERENCES grant_years(year) ON DELETE SET NULL, + category TEXT NOT NULL, + title TEXT NOT NULL, + stored_name TEXT NOT NULL UNIQUE, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + valid_until TEXT, + notes TEXT, + uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_grant_documents_year ON grant_documents(year, category); diff --git a/migrations/042_role_permissions.sql b/migrations/042_role_permissions.sql new file mode 100644 index 0000000..edca159 --- /dev/null +++ b/migrations/042_role_permissions.sql @@ -0,0 +1,15 @@ +CREATE TABLE role_permissions ( + role TEXT NOT NULL CHECK(role IN ('admin','responsable','benevole','lecture')), + module TEXT NOT NULL CHECK(module IN ('dashboard','animals','medical','care','directory','statistics','administrative','grants')), + can_view INTEGER NOT NULL DEFAULT 0 CHECK(can_view IN (0,1)), + can_edit INTEGER NOT NULL DEFAULT 0 CHECK(can_edit IN (0,1)), + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(role,module) +); + +INSERT INTO role_permissions(role,module,can_view,can_edit) VALUES +('admin','dashboard',1,1),('admin','animals',1,1),('admin','medical',1,1),('admin','care',1,1),('admin','directory',1,1),('admin','statistics',1,1),('admin','administrative',1,1),('admin','grants',1,1), +('responsable','dashboard',1,1),('responsable','animals',1,1),('responsable','medical',1,1),('responsable','care',1,1),('responsable','directory',1,1),('responsable','statistics',1,0),('responsable','administrative',1,1),('responsable','grants',1,1), +('benevole','dashboard',1,0),('benevole','animals',1,0),('benevole','medical',1,0),('benevole','care',1,1),('benevole','directory',1,0),('benevole','statistics',1,0),('benevole','administrative',0,0),('benevole','grants',0,0), +('lecture','dashboard',1,0),('lecture','animals',1,0),('lecture','medical',1,0),('lecture','care',0,0),('lecture','directory',0,0),('lecture','statistics',1,0),('lecture','administrative',0,0),('lecture','grants',0,0); diff --git a/migrations/043_grant_document_applications.sql b/migrations/043_grant_document_applications.sql new file mode 100644 index 0000000..753c414 --- /dev/null +++ b/migrations/043_grant_document_applications.sql @@ -0,0 +1,2 @@ +ALTER TABLE grant_documents ADD COLUMN application_id INTEGER REFERENCES grant_applications(id) ON DELETE SET NULL; +CREATE INDEX idx_grant_documents_application ON grant_documents(application_id, category); diff --git a/migrations/044_animal_location_history.sql b/migrations/044_animal_location_history.sql new file mode 100644 index 0000000..d6a5773 --- /dev/null +++ b/migrations/044_animal_location_history.sql @@ -0,0 +1,32 @@ +CREATE TABLE animal_location_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + from_status TEXT, + from_refuge_room TEXT, + from_care_box_key TEXT, + from_address TEXT, + to_status TEXT, + to_refuge_room TEXT, + to_care_box_key TEXT, + to_address TEXT, + reason TEXT, + source TEXT NOT NULL DEFAULT 'animal_form', + moved_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER, + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX idx_animal_location_history_animal_date + ON animal_location_history(animal_id, moved_at DESC, id DESC); + +-- Les animaux déjà présents commencent avec un état de référence. Il ne s'agit +-- pas d'un déplacement inventé, mais du point de départ de leur future chronologie. +INSERT INTO animal_location_history ( + animal_id, to_status, to_refuge_room, to_care_box_key, to_address, + reason, source, moved_at +) +SELECT id, status, refuge_room, care_box_key, current_address, + 'État initial lors de l’activation de l’historique', 'migration', + datetime('now') +FROM animals; diff --git a/migrations/045_animal_life_profile.sql b/migrations/045_animal_life_profile.sql new file mode 100644 index 0000000..2bd66cd --- /dev/null +++ b/migrations/045_animal_life_profile.sql @@ -0,0 +1,8 @@ +ALTER TABLE animals ADD COLUMN compatibility_dogs TEXT NOT NULL DEFAULT 'unknown' + CHECK (compatibility_dogs IN ('unknown','yes','no')); +ALTER TABLE animals ADD COLUMN compatibility_cats TEXT NOT NULL DEFAULT 'unknown' + CHECK (compatibility_cats IN ('unknown','yes','no')); +ALTER TABLE animals ADD COLUMN compatibility_children TEXT NOT NULL DEFAULT 'unknown' + CHECK (compatibility_children IN ('unknown','yes','no')); +ALTER TABLE animals ADD COLUMN house_trained TEXT NOT NULL DEFAULT 'unknown' + CHECK (house_trained IN ('unknown','yes','no')); diff --git a/migrations/046_structured_animal_intake.sql b/migrations/046_structured_animal_intake.sql new file mode 100644 index 0000000..12caa0d --- /dev/null +++ b/migrations/046_structured_animal_intake.sql @@ -0,0 +1,16 @@ +ALTER TABLE animals ADD COLUMN intake_type TEXT NOT NULL DEFAULT 'unknown' + CHECK (intake_type IN ('unknown','found','owner_surrender','transfer','born_in_care','return','seizure','other')); +ALTER TABLE animals ADD COLUMN intake_reason TEXT NOT NULL DEFAULT 'unknown' + CHECK (intake_reason IN ('unknown','stray_found','unwanted_litter','owner_health','owner_death','allergy','housing','financial','behavior','abandonment','danger','transfer','birth','other')); +ALTER TABLE animals ADD COLUMN depositor_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE animals ADD COLUMN intake_circumstances TEXT; +CREATE INDEX idx_animals_intake_type ON animals(intake_type); +CREATE INDEX idx_animals_intake_reason ON animals(intake_reason); +CREATE INDEX idx_animals_depositor ON animals(depositor_contact_id); + +INSERT INTO animal_movements(animal_id,kind,place,lieu,note,created_at) +SELECT a.id,'entry',COALESCE(NULLIF(trim(a.rescue_location_name),''),NULLIF(trim(a.rescue_address),'')), + 'Entrée historique','Informations d’entrée à compléter dans la fiche animale', + COALESCE(NULLIF(a.intake_date,'')||' 12:00:00',a.created_at,datetime('now')) +FROM animals a +WHERE NOT EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry'); diff --git a/migrations/047_backfill_missing_animal_entries.sql b/migrations/047_backfill_missing_animal_entries.sql new file mode 100644 index 0000000..49158f8 --- /dev/null +++ b/migrations/047_backfill_missing_animal_entries.sql @@ -0,0 +1,6 @@ +INSERT INTO animal_movements(animal_id,kind,place,lieu,note,created_at) +SELECT a.id,'entry',COALESCE(NULLIF(trim(a.rescue_location_name),''),NULLIF(trim(a.rescue_address),'')), + 'Entrée historique','Informations d’entrée à compléter dans la fiche animale', + COALESCE(NULLIF(a.intake_date,'')||' 12:00:00',a.created_at,datetime('now')) +FROM animals a +WHERE NOT EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry'); diff --git a/migrations/048_vaccination_traceability.sql b/migrations/048_vaccination_traceability.sql new file mode 100644 index 0000000..804a9b9 --- /dev/null +++ b/migrations/048_vaccination_traceability.sql @@ -0,0 +1,6 @@ +ALTER TABLE vaccinations ADD COLUMN manufacturer TEXT; +ALTER TABLE vaccinations ADD COLUMN batch_expires_on TEXT; +ALTER TABLE vaccinations ADD COLUMN administered_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE vaccinations ADD COLUMN administered_by_name TEXT; +CREATE INDEX idx_vaccinations_batch_expiry ON vaccinations(batch_expires_on); +CREATE INDEX idx_vaccinations_administrator ON vaccinations(administered_by_user_id); diff --git a/migrations/049_animal_placements.sql b/migrations/049_animal_placements.sql new file mode 100644 index 0000000..60d48df --- /dev/null +++ b/migrations/049_animal_placements.sql @@ -0,0 +1,21 @@ +CREATE TABLE animal_placements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + event_type TEXT NOT NULL CHECK(event_type IN ('reservation','foster','adoption','return','cancellation')), + event_date TEXT NOT NULL, + contact_id INTEGER, + reason TEXT, + notes TEXT, + adoption_id INTEGER, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(adoption_id) REFERENCES adoptions(id) ON DELETE SET NULL, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX idx_animal_placements_animal_date ON animal_placements(animal_id,event_date DESC,id DESC); +CREATE UNIQUE INDEX idx_animal_placements_adoption ON animal_placements(adoption_id) WHERE adoption_id IS NOT NULL; + +INSERT OR IGNORE INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id,created_at) +SELECT animal_id,'adoption',adoption_date,adopter_contact_id,'Adoption définitive',notes,id,COALESCE(created_at,datetime('now')) FROM adoptions; diff --git a/migrations/050_structured_sterilization.sql b/migrations/050_structured_sterilization.sql new file mode 100644 index 0000000..e21a296 --- /dev/null +++ b/migrations/050_structured_sterilization.sql @@ -0,0 +1,7 @@ +ALTER TABLE animals ADD COLUMN sterilization_status TEXT NOT NULL DEFAULT 'unknown' + CHECK(sterilization_status IN ('unknown','no','yes')); +ALTER TABLE animals ADD COLUMN sterilization_date TEXT; +ALTER TABLE animals ADD COLUMN sterilization_vet_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE animals ADD COLUMN sterilization_clinic_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +UPDATE animals SET sterilization_status=CASE WHEN sterilized=1 THEN 'yes' ELSE 'unknown' END; +CREATE INDEX idx_animals_sterilization_status ON animals(sterilization_status); diff --git a/migrations/051_identification_adoption_availability.sql b/migrations/051_identification_adoption_availability.sql new file mode 100644 index 0000000..920eb60 --- /dev/null +++ b/migrations/051_identification_adoption_availability.sql @@ -0,0 +1,14 @@ +ALTER TABLE animals ADD COLUMN identification_type TEXT NOT NULL DEFAULT 'unknown' + CHECK(identification_type IN ('unknown','microchip','tattoo')); +ALTER TABLE animals ADD COLUMN identification_date TEXT; +ALTER TABLE animals ADD COLUMN identification_registration_status TEXT NOT NULL DEFAULT 'unknown' + CHECK(identification_registration_status IN ('unknown','pending','registered')); +ALTER TABLE animals ADD COLUMN adoption_availability TEXT NOT NULL DEFAULT 'unknown' + CHECK(adoption_availability IN ('unknown','not_available','available')); +ALTER TABLE animals ADD COLUMN adoption_available_from TEXT; +ALTER TABLE animals ADD COLUMN adoption_unavailability_reason TEXT; +UPDATE animals SET identification_type='microchip' + WHERE COALESCE(NULLIF(TRIM(chip_id),''),NULLIF(TRIM(chip),'')) IS NOT NULL; +CREATE INDEX idx_animals_identification_type ON animals(identification_type); +CREATE INDEX idx_animals_identification_registration ON animals(identification_registration_status); +CREATE INDEX idx_animals_adoption_availability ON animals(adoption_availability); diff --git a/migrations/052_asm3_import_tracking.sql b/migrations/052_asm3_import_tracking.sql new file mode 100644 index 0000000..1cc93d6 --- /dev/null +++ b/migrations/052_asm3_import_tracking.sql @@ -0,0 +1,21 @@ +CREATE TABLE asm3_import_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_name TEXT NOT NULL, + source_sha256 TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('completed','failed')), + summary_json TEXT NOT NULL, + backup_name TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE asm3_import_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_sha256 TEXT NOT NULL, + entity_type TEXT NOT NULL, + source_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + import_run_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(source_sha256,entity_type,source_id), + FOREIGN KEY(import_run_id) REFERENCES asm3_import_runs(id) ON DELETE CASCADE +); +CREATE INDEX idx_asm3_import_links_target ON asm3_import_links(entity_type,target_id); diff --git a/migrations/053_asm3_import_jobs.sql b/migrations/053_asm3_import_jobs.sql new file mode 100644 index 0000000..d7b0a2b --- /dev/null +++ b/migrations/053_asm3_import_jobs.sql @@ -0,0 +1,13 @@ +CREATE TABLE asm3_import_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL UNIQUE, + source_sha256 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'analyzed' CHECK(status IN ('analyzed','completed','failed')), + analysis_json TEXT NOT NULL, + result_json TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + completed_at TEXT, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); diff --git a/migrations/054_configurable_species.sql b/migrations/054_configurable_species.sql new file mode 100644 index 0000000..9b3f264 --- /dev/null +++ b/migrations/054_configurable_species.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS ref_species ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE COLLATE NOCASE, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'autre', + icon TEXT NOT NULL DEFAULT '🐾', + color TEXT NOT NULL DEFAULT '#6c757d', + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + sort_order INTEGER NOT NULL DEFAULT 100, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_ref_species_active_order +ON ref_species(active, sort_order, name); + +INSERT OR IGNORE INTO ref_species(code,name,category,icon,color,sort_order) VALUES +('chat','Chat','animaux_compagnie','🐱','#6f8fb3',10), +('chien','Chien','animaux_compagnie','🐶','#b7791f',20), +('lapin','Lapin','nac','🐰','#8b6f47',30), +('furet','Furet','nac','🦦','#7c6651',40), +('cobaye','Cochon d''Inde','nac','🐹','#a06c46',50), +('oiseau','Oiseau','nac','🐦','#3b82a0',60), +('reptile','Reptile','nac','🦎','#56875a',70), +('animal-ferme','Animal de ferme','animal_ferme','🐐','#9a6b3f',80), +('faune-sauvage','Faune sauvage','faune_sauvage','🦅','#496b45',90), +('autre','Autre espèce','autre','🐾','#6c757d',100); + +INSERT OR IGNORE INTO ref_species(code,name,category,icon,color,sort_order) +SELECT lower(trim(species)), + CASE WHEN trim(species)='' THEN 'Espèce inconnue' ELSE upper(substr(trim(species),1,1))||substr(trim(species),2) END, + 'autre','🐾','#6c757d',95 +FROM animals +WHERE trim(COALESCE(species,''))<>''; + +UPDATE animals SET species='chat' WHERE lower(trim(species))='cat'; +UPDATE animals SET species='chien' WHERE lower(trim(species))='dog'; diff --git a/migrations/055_species_system_codes.sql b/migrations/055_species_system_codes.sql new file mode 100644 index 0000000..e27785b --- /dev/null +++ b/migrations/055_species_system_codes.sql @@ -0,0 +1,8 @@ +ALTER TABLE ref_species ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0 CHECK(is_system IN (0,1)); + +UPDATE ref_species SET is_system=1 +WHERE code IN ('chat','chien','lapin','furet','cobaye','oiseau','reptile','animal-ferme','faune-sauvage','autre'); + +DELETE FROM ref_species +WHERE code IN ('cat','dog') + AND NOT EXISTS (SELECT 1 FROM animals a WHERE lower(trim(a.species))=ref_species.code); diff --git a/migrations/056_recovered_deaths.sql b/migrations/056_recovered_deaths.sql new file mode 100644 index 0000000..1bb60aa --- /dev/null +++ b/migrations/056_recovered_deaths.sql @@ -0,0 +1,60 @@ +ALTER TABLE animal_deaths RENAME TO animal_deaths_before_recovery; + +CREATE TABLE animal_deaths ( + animal_id INTEGER PRIMARY KEY, + deceased_date TEXT, + date_precision TEXT NOT NULL DEFAULT 'exact' CHECK(date_precision IN ('exact','approximate','unknown')), + death_age_value INTEGER, + death_age_unit TEXT CHECK(death_age_unit IN ('day','week','month','year')), + cause_code TEXT NOT NULL, + cause_details TEXT, + occurred_in_care INTEGER NOT NULL DEFAULT 1 CHECK(occurred_in_care IN (0,1)), + place_type TEXT NOT NULL DEFAULT 'refuge' CHECK(place_type IN ('refuge','fa','veterinaire','exterieur','inconnu','autre')), + place_details TEXT, + euthanized INTEGER NOT NULL DEFAULT 0 CHECK(euthanized IN (0,1)), + veterinarian_contact_id INTEGER, + crematorium_contact_id INTEGER, + body_disposition TEXT NOT NULL DEFAULT 'pending' CHECK(body_disposition IN ('pending','collective_cremation','individual_cremation','returned_family','burial','other')), + cremation_date TEXT, + memorial_medal_color TEXT CHECK(memorial_medal_color IN ('blue','pink')), + memorial_medal_count INTEGER NOT NULL DEFAULT 1 CHECK(memorial_medal_count >= 1), + recovered_source TEXT, + notes TEXT, + created_by INTEGER, + updated_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(veterinarian_contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(crematorium_contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY(updated_by) REFERENCES users(id) ON DELETE SET NULL +); + +INSERT INTO animal_deaths(animal_id,deceased_date,date_precision,cause_code,cause_details,occurred_in_care,place_type,place_details,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,cremation_date,notes,created_by,updated_by,created_at,updated_at) +SELECT animal_id,deceased_date,'exact',cause_code,cause_details,occurred_in_care,place_type,place_details,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,cremation_date,notes,created_by,updated_by,created_at,updated_at +FROM animal_deaths_before_recovery; + +DROP TABLE animal_deaths_before_recovery; +CREATE INDEX idx_animal_deaths_date ON animal_deaths(deceased_date); +CREATE INDEX idx_animal_deaths_cause ON animal_deaths(cause_code, occurred_in_care); + +CREATE TABLE recovery_import_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + backup_name TEXT, + summary_json TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE recovery_import_links ( + source_key TEXT PRIMARY KEY, + animal_id INTEGER NOT NULL, + run_id INTEGER NOT NULL, + action TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(run_id) REFERENCES recovery_import_runs(id) ON DELETE CASCADE +); diff --git a/migrations/057_user_animal_list_view.sql b/migrations/057_user_animal_list_view.sql new file mode 100644 index 0000000..c1b4fcc --- /dev/null +++ b/migrations/057_user_animal_list_view.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN animal_list_view TEXT NOT NULL DEFAULT 'table' CHECK(animal_list_view IN ('table','mosaic')); diff --git a/migrations/058_free_roaming_cats.sql b/migrations/058_free_roaming_cats.sql new file mode 100644 index 0000000..1dd65c6 --- /dev/null +++ b/migrations/058_free_roaming_cats.sql @@ -0,0 +1,12 @@ +-- Parcours des chats libres suivis par l'association. +ALTER TABLE animals ADD COLUMN free_cat_site_name TEXT; +ALTER TABLE animals ADD COLUMN free_cat_city TEXT; +ALTER TABLE animals ADD COLUMN free_cat_address TEXT; +ALTER TABLE animals ADD COLUMN free_cat_caretaker_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL; +ALTER TABLE animals ADD COLUMN free_cat_captured_at TEXT; +ALTER TABLE animals ADD COLUMN free_cat_released_at TEXT; +ALTER TABLE animals ADD COLUMN free_cat_tracking_status TEXT NOT NULL DEFAULT 'present' + CHECK(free_cat_tracking_status IN ('present','missing','moved','deceased')); + +CREATE INDEX IF NOT EXISTS idx_animals_free_cat_site ON animals(free_cat_site_name, free_cat_city); +CREATE INDEX IF NOT EXISTS idx_animals_free_cat_caretaker ON animals(free_cat_caretaker_contact_id); diff --git a/migrations/059_owner_care_home_intake.sql b/migrations/059_owner_care_home_intake.sql new file mode 100644 index 0000000..5180845 --- /dev/null +++ b/migrations/059_owner_care_home_intake.sql @@ -0,0 +1,5 @@ +-- Précise le départ durable du propriétaire en établissement sans casser la +-- contrainte historique de la famille de motif owner_health. +ALTER TABLE animals ADD COLUMN intake_owner_care_home INTEGER NOT NULL DEFAULT 0 + CHECK(intake_owner_care_home IN (0,1)); +CREATE INDEX IF NOT EXISTS idx_animals_intake_owner_care_home ON animals(intake_owner_care_home); diff --git a/migrations/060_death_year_precision.sql b/migrations/060_death_year_precision.sql new file mode 100644 index 0000000..4df509e --- /dev/null +++ b/migrations/060_death_year_precision.sql @@ -0,0 +1,2 @@ +ALTER TABLE animal_deaths ADD COLUMN death_year INTEGER; +CREATE INDEX idx_animal_deaths_year ON animal_deaths(death_year); diff --git a/migrations/061_allow_year_death_precision.sql b/migrations/061_allow_year_death_precision.sql new file mode 100644 index 0000000..fa0addb --- /dev/null +++ b/migrations/061_allow_year_death_precision.sql @@ -0,0 +1,39 @@ +ALTER TABLE animal_deaths RENAME TO animal_deaths_before_year_precision; + +CREATE TABLE animal_deaths ( + animal_id INTEGER PRIMARY KEY, + deceased_date TEXT, + date_precision TEXT NOT NULL DEFAULT 'exact' CHECK(date_precision IN ('exact','approximate','year','unknown')), + death_age_value INTEGER, + death_age_unit TEXT CHECK(death_age_unit IN ('day','week','month','year')), + cause_code TEXT NOT NULL, + cause_details TEXT, + occurred_in_care INTEGER NOT NULL DEFAULT 1 CHECK(occurred_in_care IN (0,1)), + place_type TEXT NOT NULL DEFAULT 'refuge' CHECK(place_type IN ('refuge','fa','veterinaire','exterieur','inconnu','autre')), + place_details TEXT, + euthanized INTEGER NOT NULL DEFAULT 0 CHECK(euthanized IN (0,1)), + veterinarian_contact_id INTEGER, + crematorium_contact_id INTEGER, + body_disposition TEXT NOT NULL DEFAULT 'pending' CHECK(body_disposition IN ('pending','collective_cremation','individual_cremation','returned_family','burial','other')), + cremation_date TEXT, + memorial_medal_color TEXT CHECK(memorial_medal_color IN ('blue','pink')), + memorial_medal_count INTEGER NOT NULL DEFAULT 1 CHECK(memorial_medal_count >= 1), + recovered_source TEXT, + notes TEXT, + created_by INTEGER, + updated_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + death_year INTEGER CHECK(death_year BETWEEN 1900 AND 2200), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(veterinarian_contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(crematorium_contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY(updated_by) REFERENCES users(id) ON DELETE SET NULL +); + +INSERT INTO animal_deaths SELECT * FROM animal_deaths_before_year_precision; +DROP TABLE animal_deaths_before_year_precision; +CREATE INDEX idx_animal_deaths_date ON animal_deaths(deceased_date); +CREATE INDEX idx_animal_deaths_year ON animal_deaths(death_year); +CREATE INDEX idx_animal_deaths_cause ON animal_deaths(cause_code,occurred_in_care); diff --git a/migrations/062_dewormings.sql b/migrations/062_dewormings.sql new file mode 100644 index 0000000..beadde8 --- /dev/null +++ b/migrations/062_dewormings.sql @@ -0,0 +1,20 @@ +CREATE TABLE dewormings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + medication_id INTEGER, + administered_on TEXT NOT NULL, + dose_text TEXT, + weight_kg REAL, + next_due_date TEXT, + notes TEXT, + source_litter_id INTEGER, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, + FOREIGN KEY(medication_id) REFERENCES ref_medications(id) ON DELETE SET NULL, + FOREIGN KEY(source_litter_id) REFERENCES litters(id) ON DELETE SET NULL, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX idx_dewormings_animal_date ON dewormings(animal_id,administered_on DESC); +CREATE INDEX idx_dewormings_next_due ON dewormings(next_due_date); diff --git a/migrations/063_separate_dewormer_catalog.sql b/migrations/063_separate_dewormer_catalog.sql new file mode 100644 index 0000000..6f7b319 --- /dev/null +++ b/migrations/063_separate_dewormer_catalog.sql @@ -0,0 +1,18 @@ +CREATE TABLE ref_dewormers (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL COLLATE NOCASE UNIQUE,active_ingredient TEXT,form TEXT,notes TEXT,active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),created_at TEXT NOT NULL DEFAULT(datetime('now'))); +INSERT INTO ref_dewormers(name,active_ingredient,form,notes) VALUES +('NexGard Combo',NULL,'Pipette','Couvre principalement : puces, tiques, acariens, vers ronds, ténias et certains vers pulmonaires.'), +('Broadline',NULL,'Pipette','Couvre principalement : puces, tiques, vers ronds et ténias.'), +('Milbemax Chat / Chaton',NULL,'Comprimé','Couvre principalement : vers ronds et ténias.'), +('Milprazon Chat / Chaton',NULL,'Comprimé','Équivalent générique du Milbemax ; couvre principalement les vers ronds et ténias.'), +('Drontal Chat',NULL,'Comprimé','Couvre principalement : vers ronds et ténias.'), +('Profender',NULL,'Pipette','Couvre principalement les vers ronds et ténias, sans action contre les puces ou les tiques.'), +('Panacur','Fenbendazole','Pâte / comprimé','Couvre principalement les vers ronds ; utilisé dans certains protocoles contre Giardia.'), +('Stronghold Plus',NULL,'Pipette','Couvre principalement les puces, tiques, gale ou acariens et certains vers ronds, mais pas les ténias.'), +('Advocate',NULL,'Pipette','Couvre principalement les puces, acariens et certains vers ronds, mais pas les ténias.'); +INSERT OR IGNORE INTO ref_dewormers(name,active_ingredient,form) SELECT DISTINCT m.name,m.molecule,m.form FROM dewormings d JOIN ref_medications m ON m.id=d.medication_id; +ALTER TABLE dewormings RENAME TO dewormings_with_medications; +CREATE TABLE dewormings (id INTEGER PRIMARY KEY AUTOINCREMENT,animal_id INTEGER NOT NULL,dewormer_id INTEGER,administered_on TEXT NOT NULL,dose_text TEXT,weight_kg REAL,next_due_date TEXT,notes TEXT,source_litter_id INTEGER,created_by INTEGER,created_at TEXT NOT NULL DEFAULT(datetime('now')),updated_at TEXT NOT NULL DEFAULT(datetime('now')),FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE,FOREIGN KEY(dewormer_id) REFERENCES ref_dewormers(id) ON DELETE SET NULL,FOREIGN KEY(source_litter_id) REFERENCES litters(id) ON DELETE SET NULL,FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL); +INSERT INTO dewormings(id,animal_id,dewormer_id,administered_on,dose_text,weight_kg,next_due_date,notes,source_litter_id,created_by,created_at,updated_at) SELECT d.id,d.animal_id,r.id,d.administered_on,d.dose_text,d.weight_kg,d.next_due_date,d.notes,d.source_litter_id,d.created_by,d.created_at,d.updated_at FROM dewormings_with_medications d LEFT JOIN ref_medications m ON m.id=d.medication_id LEFT JOIN ref_dewormers r ON r.name=m.name COLLATE NOCASE; +DROP TABLE dewormings_with_medications; +CREATE INDEX idx_dewormings_animal_date ON dewormings(animal_id,administered_on DESC); +CREATE INDEX idx_dewormings_next_due ON dewormings(next_due_date); diff --git a/migrations/064_seed_dewormers.sql b/migrations/064_seed_dewormers.sql new file mode 100644 index 0000000..9b9ee29 --- /dev/null +++ b/migrations/064_seed_dewormers.sql @@ -0,0 +1,10 @@ +INSERT OR IGNORE INTO ref_dewormers(name,active_ingredient,form,notes) VALUES +('NexGard Combo',NULL,'Pipette','Couvre principalement : puces, tiques, acariens, vers ronds, ténias et certains vers pulmonaires.'), +('Broadline',NULL,'Pipette','Couvre principalement : puces, tiques, vers ronds et ténias.'), +('Milbemax Chat / Chaton',NULL,'Comprimé','Couvre principalement : vers ronds et ténias.'), +('Milprazon Chat / Chaton',NULL,'Comprimé','Équivalent générique du Milbemax ; couvre principalement les vers ronds et ténias.'), +('Drontal Chat',NULL,'Comprimé','Couvre principalement : vers ronds et ténias.'), +('Profender',NULL,'Pipette','Couvre principalement les vers ronds et ténias, sans action contre les puces ou les tiques.'), +('Panacur','Fenbendazole','Pâte / comprimé','Couvre principalement les vers ronds ; utilisé dans certains protocoles contre Giardia.'), +('Stronghold Plus',NULL,'Pipette','Couvre principalement les puces, tiques, gale ou acariens et certains vers ronds, mais pas les ténias.'), +('Advocate',NULL,'Pipette','Couvre principalement les puces, acariens et certains vers ronds, mais pas les ténias.'); diff --git a/migrations/065_pricing_and_animal_expenses.sql b/migrations/065_pricing_and_animal_expenses.sql new file mode 100644 index 0000000..16b22b2 --- /dev/null +++ b/migrations/065_pricing_and_animal_expenses.sql @@ -0,0 +1,25 @@ +CREATE TABLE clinic_discount_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, clinic_contact_id INTEGER NOT NULL, + discount_group TEXT NOT NULL, label TEXT NOT NULL, percent REAL NOT NULL DEFAULT 0 CHECK(percent BETWEEN 0 AND 100), + UNIQUE(clinic_contact_id,discount_group), FOREIGN KEY(clinic_contact_id) REFERENCES directory_contacts(id) ON DELETE CASCADE +); +CREATE TABLE clinic_tariffs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, clinic_contact_id INTEGER NOT NULL, label TEXT NOT NULL COLLATE NOCASE, + category TEXT NOT NULL DEFAULT 'other', discount_group TEXT NOT NULL DEFAULT 'act', unit_label TEXT NOT NULL DEFAULT 'acte', + medication_id INTEGER, vaccine_id INTEGER, dewormer_id INTEGER, amount_cents INTEGER NOT NULL CHECK(amount_cents>=0), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN(0,1)), notes TEXT, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')), + FOREIGN KEY(clinic_contact_id) REFERENCES directory_contacts(id) ON DELETE CASCADE, + FOREIGN KEY(medication_id) REFERENCES ref_medications(id) ON DELETE SET NULL, + FOREIGN KEY(vaccine_id) REFERENCES ref_vaccines(id) ON DELETE SET NULL, + FOREIGN KEY(dewormer_id) REFERENCES ref_dewormers(id) ON DELETE SET NULL, UNIQUE(clinic_contact_id,label) +); +CREATE INDEX idx_clinic_tariffs_clinic ON clinic_tariffs(clinic_contact_id,active,label); +CREATE TABLE animal_expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, animal_id INTEGER NOT NULL, clinic_contact_id INTEGER, tariff_id INTEGER, + source_type TEXT, source_id INTEGER, label TEXT NOT NULL, occurred_on TEXT NOT NULL, quantity REAL NOT NULL DEFAULT 1 CHECK(quantity>0), + catalog_unit_cents INTEGER NOT NULL CHECK(catalog_unit_cents>=0), discount_percent REAL NOT NULL DEFAULT 0, + total_cents INTEGER NOT NULL CHECK(total_cents>=0), notes TEXT, created_by INTEGER, created_at TEXT NOT NULL DEFAULT(datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id) ON DELETE CASCADE, FOREIGN KEY(clinic_contact_id) REFERENCES directory_contacts(id) ON DELETE SET NULL, + FOREIGN KEY(tariff_id) REFERENCES clinic_tariffs(id) ON DELETE SET NULL, FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX idx_animal_expenses_animal_date ON animal_expenses(animal_id,occurred_on); diff --git a/migrations/066_clear_organization_parent_links.sql b/migrations/066_clear_organization_parent_links.sql new file mode 100644 index 0000000..cad49ad --- /dev/null +++ b/migrations/066_clear_organization_parent_links.sql @@ -0,0 +1,2 @@ +UPDATE directory_contacts SET organization_id=NULL,updated_at=datetime('now') +WHERE kind='organization' AND organization_id IS NOT NULL; diff --git a/migrations/067_sync_medical_veterinary_clinics.sql b/migrations/067_sync_medical_veterinary_clinics.sql new file mode 100644 index 0000000..df40fba --- /dev/null +++ b/migrations/067_sync_medical_veterinary_clinics.sql @@ -0,0 +1,6 @@ +UPDATE medical_notes SET clinic_contact_id=(SELECT organization_id FROM directory_contacts WHERE id=medical_notes.vet_contact_id) +WHERE vet_contact_id IS NOT NULL AND EXISTS(SELECT 1 FROM directory_contacts WHERE id=medical_notes.vet_contact_id AND organization_id IS NOT NULL); +UPDATE treatments SET clinic_contact_id=(SELECT organization_id FROM directory_contacts WHERE id=treatments.vet_contact_id) +WHERE vet_contact_id IS NOT NULL AND EXISTS(SELECT 1 FROM directory_contacts WHERE id=treatments.vet_contact_id AND organization_id IS NOT NULL); +UPDATE vaccinations SET clinic_contact_id=(SELECT organization_id FROM directory_contacts WHERE id=vaccinations.vet_contact_id) +WHERE vet_contact_id IS NOT NULL AND EXISTS(SELECT 1 FROM directory_contacts WHERE id=vaccinations.vet_contact_id AND organization_id IS NOT NULL); diff --git a/migrations/068_directory_pound_role.sql b/migrations/068_directory_pound_role.sql new file mode 100644 index 0000000..239e5d9 --- /dev/null +++ b/migrations/068_directory_pound_role.sql @@ -0,0 +1,10 @@ +CREATE TABLE directory_contact_roles_new ( + contact_id INTEGER NOT NULL, + role TEXT NOT NULL CHECK(role IN ('fa','benevole','dirigeant','veterinaire','cabinet','crematorium','adoptant','deposant','fourriere')), + PRIMARY KEY(contact_id,role), + FOREIGN KEY(contact_id) REFERENCES directory_contacts(id) ON DELETE CASCADE +); +INSERT INTO directory_contact_roles_new(contact_id,role) SELECT contact_id,role FROM directory_contact_roles; +DROP TABLE directory_contact_roles; +ALTER TABLE directory_contact_roles_new RENAME TO directory_contact_roles; +CREATE INDEX idx_directory_role ON directory_contact_roles(role,contact_id); diff --git a/migrations/069_application_language.sql b/migrations/069_application_language.sql new file mode 100644 index 0000000..ed16afc --- /dev/null +++ b/migrations/069_application_language.sql @@ -0,0 +1 @@ +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES('app_language','fr'); diff --git a/migrations/070_medical_documents_and_lab_results.sql b/migrations/070_medical_documents_and_lab_results.sql new file mode 100644 index 0000000..b8c233a --- /dev/null +++ b/migrations/070_medical_documents_and_lab_results.sql @@ -0,0 +1,54 @@ +CREATE TABLE medical_prescriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL REFERENCES animals(id) ON DELETE CASCADE, + prescribed_on TEXT NOT NULL, + veterinarian_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + clinic_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','finished','replaced')), + notes TEXT, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE INDEX idx_prescriptions_animal_date ON medical_prescriptions(animal_id,prescribed_on DESC,id DESC); + +CREATE TABLE prescription_treatments ( + prescription_id INTEGER NOT NULL REFERENCES medical_prescriptions(id) ON DELETE CASCADE, + treatment_id INTEGER NOT NULL REFERENCES treatments(id) ON DELETE CASCADE, + PRIMARY KEY(prescription_id,treatment_id) +); + +CREATE TABLE lab_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL REFERENCES animals(id) ON DELETE CASCADE, + sampled_on TEXT NOT NULL, + report_type TEXT, + laboratory_name TEXT, + veterinarian_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + clinic_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + notes TEXT, + original_name TEXT, + stored_name TEXT, + mime_type TEXT, + size_bytes INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE INDEX idx_lab_reports_animal_date ON lab_reports(animal_id,sampled_on DESC,id DESC); + +CREATE TABLE lab_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_id INTEGER NOT NULL REFERENCES lab_reports(id) ON DELETE CASCADE, + parameter_name TEXT NOT NULL, + value REAL NOT NULL, + unit TEXT, + reference_min REAL, + reference_max REAL, + notes TEXT, + position INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_lab_results_report ON lab_results(report_id,position,id); +CREATE INDEX idx_lab_results_parameter ON lab_results(parameter_name COLLATE NOCASE); diff --git a/migrations/071_lab_reference_ranges.sql b/migrations/071_lab_reference_ranges.sql new file mode 100644 index 0000000..a2628be --- /dev/null +++ b/migrations/071_lab_reference_ranges.sql @@ -0,0 +1,22 @@ +CREATE TABLE lab_reference_ranges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + analyzer_code TEXT NOT NULL, + analyzer_name TEXT NOT NULL, + species_code TEXT NOT NULL, + parameter_code TEXT NOT NULL, + parameter_name TEXT NOT NULL, + unit TEXT NOT NULL, + reference_min REAL, + reference_max REAL, + source_note TEXT, + active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 100, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(analyzer_code,species_code,parameter_code) +); +CREATE INDEX idx_lab_reference_lookup ON lab_reference_ranges(analyzer_code,species_code,active,sort_order); + +INSERT INTO lab_reference_ranges(analyzer_code,analyzer_name,species_code,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,sort_order) VALUES +('vetscan-hm5','Zoetis VETSCAN HM5','chat','GB','Leucocytes','10⁹/L',3.50,20.70,'Fiche Zoetis actuelle',10),('vetscan-hm5','Zoetis VETSCAN HM5','chat','LYM','Lymphocytes','10⁹/L',0.83,9.10,'Fiche Zoetis actuelle',20),('vetscan-hm5','Zoetis VETSCAN HM5','chat','MON','Monocytes','10⁹/L',0.09,1.21,'Fiche Zoetis actuelle',30),('vetscan-hm5','Zoetis VETSCAN HM5','chat','NEU','Neutrophiles','10⁹/L',1.63,13.37,'Fiche Zoetis actuelle',40),('vetscan-hm5','Zoetis VETSCAN HM5','chat','EOS','Éosinophiles','10⁹/L',0.02,0.49,'Fiche Zoetis actuelle',50),('vetscan-hm5','Zoetis VETSCAN HM5','chat','BAS','Basophiles','10⁹/L',NULL,NULL,'Aucun intervalle constructeur publié',60),('vetscan-hm5','Zoetis VETSCAN HM5','chat','GR','Érythrocytes','10¹²/L',7.70,12.80,'Fiche Zoetis actuelle',70),('vetscan-hm5','Zoetis VETSCAN HM5','chat','HGB','Hémoglobine','g/dL',10,17,'Fiche Zoetis actuelle',80),('vetscan-hm5','Zoetis VETSCAN HM5','chat','HCT','Hématocrite','%',33.7,55.4,'Fiche Zoetis actuelle',90),('vetscan-hm5','Zoetis VETSCAN HM5','chat','VCM','Volume corpusculaire moyen','fL',35,52,'Fiche Zoetis actuelle',100),('vetscan-hm5','Zoetis VETSCAN HM5','chat','HCM','Hémoglobine corpusculaire moyenne','pg',10,16.9,'Fiche Zoetis actuelle',110),('vetscan-hm5','Zoetis VETSCAN HM5','chat','CCMH','Concentration corpusculaire moyenne en Hb','g/dL',27,35,'Fiche Zoetis actuelle',120),('vetscan-hm5','Zoetis VETSCAN HM5','chat','IDRc','RDW-CV','%',18.3,24.1,'Fiche Zoetis actuelle',130),('vetscan-hm5','Zoetis VETSCAN HM5','chat','IDRs','RDW-SD','fL',NULL,NULL,'Aucun intervalle constructeur publié',140),('vetscan-hm5','Zoetis VETSCAN HM5','chat','PLT','Plaquettes','10⁹/L',125,618,'Fiche Zoetis actuelle',150),('vetscan-hm5','Zoetis VETSCAN HM5','chat','VPM','Volume plaquettaire moyen','fL',8.6,14.9,'Fiche Zoetis actuelle',160),('vetscan-hm5','Zoetis VETSCAN HM5','chat','PCT','Thrombocrite','%',NULL,NULL,'Calculé sans intervalle constructeur publié',170),('vetscan-hm5','Zoetis VETSCAN HM5','chat','IDPc','PDW-CV','%',NULL,NULL,'Calculé sans intervalle constructeur publié',180),('vetscan-hm5','Zoetis VETSCAN HM5','chat','IDPs','PDW-SD','fL',NULL,NULL,'Calculé sans intervalle constructeur publié',190), +('vetscan-hm5','Zoetis VETSCAN HM5','chien','GB','Leucocytes','10⁹/L',6,17,'Fiche Zoetis actuelle',10),('vetscan-hm5','Zoetis VETSCAN HM5','chien','LYM','Lymphocytes','10⁹/L',1,4.8,'Fiche Zoetis actuelle',20),('vetscan-hm5','Zoetis VETSCAN HM5','chien','MON','Monocytes','10⁹/L',0.2,1.5,'Fiche Zoetis actuelle',30),('vetscan-hm5','Zoetis VETSCAN HM5','chien','NEU','Neutrophiles','10⁹/L',3,12,'Fiche Zoetis actuelle',40),('vetscan-hm5','Zoetis VETSCAN HM5','chien','EOS','Éosinophiles','10⁹/L',0,0.8,'Fiche Zoetis actuelle',50),('vetscan-hm5','Zoetis VETSCAN HM5','chien','BAS','Basophiles','10⁹/L',NULL,NULL,'Aucun intervalle constructeur publié',60),('vetscan-hm5','Zoetis VETSCAN HM5','chien','GR','Érythrocytes','10¹²/L',5.5,8.5,'Fiche Zoetis actuelle',70),('vetscan-hm5','Zoetis VETSCAN HM5','chien','HGB','Hémoglobine','g/dL',12,18,'Fiche Zoetis actuelle',80),('vetscan-hm5','Zoetis VETSCAN HM5','chien','HCT','Hématocrite','%',37,55,'Fiche Zoetis actuelle',90),('vetscan-hm5','Zoetis VETSCAN HM5','chien','VCM','Volume corpusculaire moyen','fL',60,77,'Fiche Zoetis actuelle',100),('vetscan-hm5','Zoetis VETSCAN HM5','chien','HCM','Hémoglobine corpusculaire moyenne','pg',19.5,24.5,'Fiche Zoetis actuelle',110),('vetscan-hm5','Zoetis VETSCAN HM5','chien','CCMH','Concentration corpusculaire moyenne en Hb','g/dL',31,39,'Fiche Zoetis actuelle',120),('vetscan-hm5','Zoetis VETSCAN HM5','chien','IDRc','RDW-CV','%',14,20,'Fiche Zoetis actuelle',130),('vetscan-hm5','Zoetis VETSCAN HM5','chien','IDRs','RDW-SD','fL',NULL,NULL,'Aucun intervalle constructeur publié',140),('vetscan-hm5','Zoetis VETSCAN HM5','chien','PLT','Plaquettes','10⁹/L',165,500,'Fiche Zoetis actuelle',150),('vetscan-hm5','Zoetis VETSCAN HM5','chien','VPM','Volume plaquettaire moyen','fL',3.9,11.1,'Fiche Zoetis actuelle',160),('vetscan-hm5','Zoetis VETSCAN HM5','chien','PCT','Thrombocrite','%',NULL,NULL,'Calculé sans intervalle constructeur publié',170),('vetscan-hm5','Zoetis VETSCAN HM5','chien','IDPc','PDW-CV','%',NULL,NULL,'Calculé sans intervalle constructeur publié',180),('vetscan-hm5','Zoetis VETSCAN HM5','chien','IDPs','PDW-SD','fL',NULL,NULL,'Calculé sans intervalle constructeur publié',190); diff --git a/migrations/072_public_website.sql b/migrations/072_public_website.sql new file mode 100644 index 0000000..e37a18a --- /dev/null +++ b/migrations/072_public_website.sql @@ -0,0 +1,11 @@ +ALTER TABLE animals ADD COLUMN website_published INTEGER NOT NULL DEFAULT 0 CHECK(website_published IN (0,1)); +ALTER TABLE animals ADD COLUMN website_description TEXT; +CREATE INDEX idx_animals_website ON animals(website_published,adoption_availability,species); + +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES +('public_site_enabled','0'),('public_site_theme','warm'), +('public_site_home_title','Ils attendent une famille'),('public_site_home_text','Découvrez les animaux actuellement proposés à l’adoption par notre association.'), +('public_site_about_enabled','1'),('public_site_about_title','Notre association'),('public_site_about_text','Présentez ici votre association, son histoire et ses missions.'), +('public_site_donation_title','Nous soutenir'),('public_site_donation_text','Chaque don contribue directement aux soins, à l’alimentation et à la protection des animaux.'),('public_site_donation_url',''), +('public_site_contact_text','Une question sur une adoption ou sur l’association ? Contactez-nous.'), +('public_site_facebook_url',''),('public_site_instagram_url',''); diff --git a/migrations/073_public_website_routing.sql b/migrations/073_public_website_routing.sql new file mode 100644 index 0000000..c2bcfad --- /dev/null +++ b/migrations/073_public_website_routing.sql @@ -0,0 +1 @@ +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES('public_site_routing','integrated'); diff --git a/migrations/074_public_site_map.sql b/migrations/074_public_site_map.sql new file mode 100644 index 0000000..1f98ecd --- /dev/null +++ b/migrations/074_public_site_map.sql @@ -0,0 +1,3 @@ +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES +('association_latitude',''), +('association_longitude',''); diff --git a/migrations/075_notifications.sql b/migrations/075_notifications.sql new file mode 100644 index 0000000..9d079d2 --- /dev/null +++ b/migrations/075_notifications.sql @@ -0,0 +1,5 @@ +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES +('notification_email_enabled','0'), +('notification_email_frequency','weekly'), +('notification_email_recipient',''), +('notification_last_sent_at',''); diff --git a/migrations/076_privacy_and_access_log.sql b/migrations/076_privacy_and_access_log.sql new file mode 100644 index 0000000..87a9522 --- /dev/null +++ b/migrations/076_privacy_and_access_log.sql @@ -0,0 +1,17 @@ +ALTER TABLE directory_contacts ADD COLUMN anonymized_at TEXT; +ALTER TABLE directory_contacts ADD COLUMN anonymization_reason TEXT; + +CREATE TABLE IF NOT EXISTS private_file_access_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + resource_type TEXT NOT NULL, + resource_id INTEGER, + animal_id INTEGER REFERENCES animals(id) ON DELETE SET NULL, + action TEXT NOT NULL DEFAULT 'view', + original_name TEXT, + ip_address TEXT, + user_agent TEXT, + accessed_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_private_access_date ON private_file_access_log(accessed_at DESC); +CREATE INDEX IF NOT EXISTS idx_private_access_resource ON private_file_access_log(resource_type,resource_id); diff --git a/migrations/077_trusted_devices.sql b/migrations/077_trusted_devices.sql new file mode 100644 index 0000000..f7d5823 --- /dev/null +++ b/migrations/077_trusted_devices.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS trusted_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + selector TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, + device_name TEXT NOT NULL, + user_agent TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + last_used_at TEXT NOT NULL DEFAULT(datetime('now')), + expires_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_trusted_devices_user ON trusted_devices(user_id,revoked_at,expires_at); diff --git a/migrations/078_additional_lab_reference_ranges.sql b/migrations/078_additional_lab_reference_ranges.sql new file mode 100644 index 0000000..5dd5988 --- /dev/null +++ b/migrations/078_additional_lab_reference_ranges.sql @@ -0,0 +1,23 @@ +UPDATE lab_reference_ranges SET analyzer_name = 'Bilan complet' WHERE analyzer_code = 'vetscan-hm5'; + +INSERT INTO lab_reference_ranges(analyzer_code,analyzer_name,species_code,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,sort_order) VALUES +('renal','Bilan rénal','chat','CRE','Créatinine','mg/L',3,21,'Zoetis VETSCAN VS2 — conversion depuis mg/dL',10), +('renal','Bilan rénal','chat','UREE','Urée','g/L',0.214,0.642,'Zoetis VETSCAN VS2 — conversion du BUN',20), +('renal','Bilan rénal','chat','SDMA','SDMA','µg/dL',0,14,'IDEXX SDMA — chat adulte',30), +('renal','Bilan rénal','chat','PHOS','Phosphore','mg/L',34,85,'Zoetis VETSCAN VS2 — conversion depuis mg/dL',40), +('renal','Bilan rénal','chien','CRE','Créatinine','mg/L',3,14,'Zoetis VETSCAN VS2 — conversion depuis mg/dL',10), +('renal','Bilan rénal','chien','UREE','Urée','g/L',0.150,0.535,'Zoetis VETSCAN VS2 — conversion du BUN',20), +('renal','Bilan rénal','chien','SDMA','SDMA','µg/dL',0,14,'IDEXX SDMA — chien adulte',30), +('renal','Bilan rénal','chien','PHOS','Phosphore','mg/L',29,66,'Zoetis VETSCAN VS2 — conversion depuis mg/dL',40), +('hepatic','Bilan hépatique','chat','ALT','ALAT','U/L',20,100,'Zoetis VETSCAN VS2',10), +('hepatic','Bilan hépatique','chat','ALP','PAL','U/L',10,90,'Zoetis VETSCAN VS2',20), +('hepatic','Bilan hépatique','chat','TBIL','Bilirubine totale','mg/L',1,6,'Zoetis VETSCAN VS2 — conversion depuis mg/dL',30), +('hepatic','Bilan hépatique','chat','ALB','Albumine','g/L',22,44,'Zoetis VETSCAN VS2',40), +('hepatic','Bilan hépatique','chien','ALT','ALAT','U/L',10,118,'Zoetis VETSCAN VS2',10), +('hepatic','Bilan hépatique','chien','ALP','PAL','U/L',20,150,'Zoetis VETSCAN VS2',20), +('hepatic','Bilan hépatique','chien','TBIL','Bilirubine totale','mg/L',1,6,'Zoetis VETSCAN VS2 — conversion depuis mg/dL',30), +('hepatic','Bilan hépatique','chien','ALB','Albumine','g/L',25,44,'Zoetis VETSCAN VS2',40), +('thyroid','Bilan thyroïdien','chat','T4','T4 totale','µg/dL',1.5,4.8,'Zoetis VETSCAN VS2',10), +('thyroid','Bilan thyroïdien','chat','TSH','TSH','ng/mL',NULL,NULL,'Aucun intervalle VETSCAN VS2 publié',20), +('thyroid','Bilan thyroïdien','chien','T4','T4 totale','µg/dL',1.1,4.0,'Zoetis VETSCAN VS2',10), +('thyroid','Bilan thyroïdien','chien','TSH','TSH','ng/mL',NULL,NULL,'Aucun intervalle VETSCAN VS2 publié',20); diff --git a/migrations/079_inventory_and_maintenance.sql b/migrations/079_inventory_and_maintenance.sql new file mode 100644 index 0000000..e8fb909 --- /dev/null +++ b/migrations/079_inventory_and_maintenance.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS inventory_products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL CHECK(category IN ('medication','dewormer','vaccine')), + reference_id INTEGER, + name TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'unité', + minimum_quantity REAL NOT NULL DEFAULT 0 CHECK(minimum_quantity >= 0), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(category,reference_id), + UNIQUE(category,name) +); + +CREATE TABLE IF NOT EXISTS inventory_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL REFERENCES inventory_products(id) ON DELETE CASCADE, + batch_number TEXT, + expires_on TEXT, + supplier_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + unit_cost_cents INTEGER CHECK(unit_cost_cents IS NULL OR unit_cost_cents >= 0), + quantity REAL NOT NULL DEFAULT 0, + opened_on TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE TABLE IF NOT EXISTS inventory_movements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL REFERENCES inventory_products(id) ON DELETE CASCADE, + batch_id INTEGER REFERENCES inventory_batches(id) ON DELETE SET NULL, + movement_type TEXT NOT NULL CHECK(movement_type IN ('entry','administration','loss','correction')), + quantity_delta REAL NOT NULL CHECK(quantity_delta <> 0), + animal_id INTEGER REFERENCES animals(id) ON DELETE SET NULL, + occurred_at TEXT NOT NULL DEFAULT(datetime('now')), + reason TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_inventory_batches_product ON inventory_batches(product_id,expires_on); +CREATE INDEX IF NOT EXISTS idx_inventory_movements_product ON inventory_movements(product_id,occurred_at DESC); + +INSERT OR IGNORE INTO app_settings(setting_key,setting_value) VALUES +('backup_schedule_enabled','0'),('backup_schedule_frequency','daily'),('backup_last_run_at',''),('backup_last_verified_at',''), +('privacy_retention_adopter_years','5'),('privacy_retention_foster_years','5'),('privacy_retention_volunteer_years','5'),('privacy_access_log_months','24'); diff --git a/migrations/080_agenda.sql b/migrations/080_agenda.sql new file mode 100644 index 0000000..95ea08d --- /dev/null +++ b/migrations/080_agenda.sql @@ -0,0 +1,41 @@ +ALTER TABLE role_permissions RENAME TO role_permissions_legacy; +CREATE TABLE role_permissions ( + role TEXT NOT NULL CHECK(role IN ('admin','responsable','benevole','lecture')), + module TEXT NOT NULL CHECK(module IN ('dashboard','animals','medical','care','directory','agenda','statistics','administrative','grants')), + can_view INTEGER NOT NULL DEFAULT 0 CHECK(can_view IN (0,1)), + can_edit INTEGER NOT NULL DEFAULT 0 CHECK(can_edit IN (0,1)), + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + PRIMARY KEY(role,module) +); +INSERT INTO role_permissions SELECT * FROM role_permissions_legacy; +DROP TABLE role_permissions_legacy; +INSERT INTO role_permissions(role,module,can_view,can_edit) VALUES +('admin','agenda',1,1),('responsable','agenda',1,1),('benevole','agenda',1,1),('lecture','agenda',1,0); + +CREATE TABLE agenda_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_type TEXT NOT NULL CHECK(item_type IN ('appointment','veterinary','transport','pre_adoption','task','other')), + title TEXT NOT NULL, + description TEXT, + starts_at TEXT NOT NULL, + ends_at TEXT, + all_day INTEGER NOT NULL DEFAULT 0 CHECK(all_day IN (0,1)), + location TEXT, + status TEXT NOT NULL DEFAULT 'planned' CHECK(status IN ('planned','completed','cancelled')), + priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')), + assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + animal_id INTEGER REFERENCES animals(id) ON DELETE SET NULL, + contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + reminder_minutes INTEGER CHECK(reminder_minutes IS NULL OR reminder_minutes >= 0), + repeat_rule TEXT NOT NULL DEFAULT 'none' CHECK(repeat_rule IN ('none','daily','weekly','monthly')), + repeat_until TEXT, + completed_at TEXT, + completed_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE INDEX idx_agenda_starts ON agenda_items(starts_at,status); +CREATE INDEX idx_agenda_assignee ON agenda_items(assigned_user_id,status,starts_at); +CREATE INDEX idx_agenda_animal ON agenda_items(animal_id,starts_at); diff --git a/migrations/081_user_agenda_view.sql b/migrations/081_user_agenda_view.sql new file mode 100644 index 0000000..f705f41 --- /dev/null +++ b/migrations/081_user_agenda_view.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN agenda_view TEXT NOT NULL DEFAULT 'month' CHECK(agenda_view IN ('month','week')); diff --git a/migrations/082_agenda_feed.sql b/migrations/082_agenda_feed.sql new file mode 100644 index 0000000..a2fb62f --- /dev/null +++ b/migrations/082_agenda_feed.sql @@ -0,0 +1,3 @@ +ALTER TABLE users ADD COLUMN agenda_feed_token_hash TEXT; +ALTER TABLE users ADD COLUMN agenda_feed_created_at TEXT; +CREATE UNIQUE INDEX idx_users_agenda_feed_token ON users(agenda_feed_token_hash) WHERE agenda_feed_token_hash IS NOT NULL; diff --git a/migrations/083_agenda_volunteers.sql b/migrations/083_agenda_volunteers.sql new file mode 100644 index 0000000..07e0545 --- /dev/null +++ b/migrations/083_agenda_volunteers.sql @@ -0,0 +1,6 @@ +CREATE TABLE agenda_volunteers ( + agenda_item_id INTEGER NOT NULL REFERENCES agenda_items(id) ON DELETE CASCADE, + contact_id INTEGER NOT NULL REFERENCES directory_contacts(id) ON DELETE CASCADE, + PRIMARY KEY(agenda_item_id,contact_id) +); +CREATE INDEX idx_agenda_volunteers_contact ON agenda_volunteers(contact_id,agenda_item_id); diff --git a/migrations/084_configurable_shelter_rooms.sql b/migrations/084_configurable_shelter_rooms.sql new file mode 100644 index 0000000..d360237 --- /dev/null +++ b/migrations/084_configurable_shelter_rooms.sql @@ -0,0 +1,45 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS shelter_rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + room_type TEXT NOT NULL DEFAULT 'collective' CHECK(room_type IN ('collective','boxes')), + status_code TEXT NOT NULL DEFAULT 'refuge' CHECK(status_code IN ('refuge','soin','quarantaine')), + color TEXT NOT NULL DEFAULT '#6c757d', + bulk_validation INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 100, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS shelter_boxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 100, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(room_id, code), + FOREIGN KEY(room_id) REFERENCES shelter_rooms(id) ON DELETE RESTRICT +); + +INSERT OR IGNORE INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,active,sort_order) VALUES + ('silver','Salle collective 1','collective','refuge','#adb5bd',1,1,10), + ('twix','Salle collective 2','collective','refuge','#f0a35e',1,1,20), + ('care','Infirmerie','boxes','soin','#e58a8a',0,1,30), + ('quarantine','Salle de quarantaine','boxes','quarantaine','#d8bd52',0,1,40); + +INSERT OR IGNORE INTO shelter_boxes(room_id,code,name,sort_order) +SELECT id,'top_a','Box supérieur gauche',10 FROM shelter_rooms WHERE code IN ('care','quarantine'); +INSERT OR IGNORE INTO shelter_boxes(room_id,code,name,sort_order) +SELECT id,'top_b','Box supérieur central',20 FROM shelter_rooms WHERE code IN ('care','quarantine'); +INSERT OR IGNORE INTO shelter_boxes(room_id,code,name,sort_order) +SELECT id,'top_c','Box supérieur droit',30 FROM shelter_rooms WHERE code IN ('care','quarantine'); +INSERT OR IGNORE INTO shelter_boxes(room_id,code,name,sort_order) +SELECT id,'bottom_a','Box inférieur gauche',40 FROM shelter_rooms WHERE code IN ('care','quarantine'); +INSERT OR IGNORE INTO shelter_boxes(room_id,code,name,sort_order) +SELECT id,'bottom_b','Box inférieur droit',50 FROM shelter_rooms WHERE code IN ('care','quarantine'); diff --git a/migrations/085_login_security.sql b/migrations/085_login_security.sql new file mode 100644 index 0000000..a0ff147 --- /dev/null +++ b/migrations/085_login_security.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username_hash TEXT NOT NULL, + ip_address TEXT, + succeeded INTEGER NOT NULL DEFAULT 0, + attempted_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_login_attempts_time_ip ON login_attempts(attempted_at,ip_address); diff --git a/migrations/086_rescue_location_provenance.sql b/migrations/086_rescue_location_provenance.sql new file mode 100644 index 0000000..3481094 --- /dev/null +++ b/migrations/086_rescue_location_provenance.sql @@ -0,0 +1,8 @@ +ALTER TABLE animals ADD COLUMN rescue_location_confidence TEXT; +ALTER TABLE animals ADD COLUMN rescue_location_evidence TEXT; + +UPDATE animals +SET rescue_location_confidence='documented', + rescue_location_evidence=COALESCE(NULLIF(TRIM(rescue_location_evidence),''),'Lieu saisi ou repris depuis une source historique explicite') +WHERE (TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'') + AND TRIM(COALESCE(rescue_location_confidence,''))=''; diff --git a/migrations/087_surgeries_and_accounting.sql b/migrations/087_surgeries_and_accounting.sql new file mode 100644 index 0000000..de1df99 --- /dev/null +++ b/migrations/087_surgeries_and_accounting.sql @@ -0,0 +1,52 @@ +CREATE TABLE animal_surgeries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL REFERENCES animals(id) ON DELETE CASCADE, + category TEXT NOT NULL, + procedure_name TEXT NOT NULL, + surgery_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'completed' CHECK(status IN ('planned','completed','cancelled')), + clinic_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + veterinarian_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + tariff_id INTEGER REFERENCES clinic_tariffs(id) ON DELETE SET NULL, + amount_cents INTEGER CHECK(amount_cents IS NULL OR amount_cents>=0), + anesthesia TEXT, notes TEXT, follow_up TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE INDEX idx_animal_surgeries_animal_date ON animal_surgeries(animal_id,surgery_date); + +CREATE TABLE accounting_invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vendor_contact_id INTEGER REFERENCES directory_contacts(id) ON DELETE SET NULL, + reference TEXT NOT NULL COLLATE NOCASE, + invoice_date TEXT NOT NULL, due_date TEXT, + document_type TEXT NOT NULL DEFAULT 'invoice' CHECK(document_type IN ('invoice','credit','adjustment')), + status TEXT NOT NULL DEFAULT 'to_pay' CHECK(status IN ('to_pay','partial','paid','disputed','cancelled','credit')), + payment_method TEXT CHECK(payment_method IS NULL OR payment_method IN ('transfer','card','check','cash','direct_debit','other')), + paid_on TEXT, + total_ht_cents INTEGER, + total_ttc_cents INTEGER NOT NULL, + amount_due_cents INTEGER, + notes TEXT, source TEXT, + original_filename TEXT, stored_filename TEXT, mime_type TEXT, file_size INTEGER, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(vendor_contact_id,reference) +); +CREATE INDEX idx_accounting_invoice_date ON accounting_invoices(invoice_date,status); +CREATE INDEX idx_accounting_invoice_vendor ON accounting_invoices(vendor_contact_id,invoice_date); + +ALTER TABLE role_permissions RENAME TO role_permissions_legacy; +CREATE TABLE role_permissions ( + role TEXT NOT NULL CHECK(role IN ('admin','responsable','benevole','lecture')), + module TEXT NOT NULL CHECK(module IN ('dashboard','animals','medical','care','directory','agenda','statistics','administrative','accounting','grants')), + can_view INTEGER NOT NULL DEFAULT 0 CHECK(can_view IN (0,1)), + can_edit INTEGER NOT NULL DEFAULT 0 CHECK(can_edit IN (0,1)), + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + PRIMARY KEY(role,module) +); +INSERT INTO role_permissions SELECT * FROM role_permissions_legacy; +DROP TABLE role_permissions_legacy; +INSERT INTO role_permissions(role,module,can_view,can_edit) VALUES +('admin','accounting',1,1),('responsable','accounting',1,1),('benevole','accounting',0,0),('lecture','accounting',0,0); diff --git a/migrations/088_accounting_invoice_editing.sql b/migrations/088_accounting_invoice_editing.sql new file mode 100644 index 0000000..15516f5 --- /dev/null +++ b/migrations/088_accounting_invoice_editing.sql @@ -0,0 +1,3 @@ +ALTER TABLE accounting_invoices ADD COLUMN deleted_at TEXT; +ALTER TABLE accounting_invoices ADD COLUMN deleted_by INTEGER REFERENCES users(id) ON DELETE SET NULL; +CREATE INDEX idx_accounting_invoices_deleted ON accounting_invoices(deleted_at,invoice_date); diff --git a/migrations/089_media_schema_repair.sql b/migrations/089_media_schema_repair.sql new file mode 100644 index 0000000..0005d95 --- /dev/null +++ b/migrations/089_media_schema_repair.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS medical_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + animal_id INTEGER NOT NULL, + medical_note_id INTEGER NOT NULL, + filename TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY(animal_id) REFERENCES animals(id), + FOREIGN KEY(medical_note_id) REFERENCES medical_notes(id) +); + +CREATE INDEX IF NOT EXISTS idx_medical_photos_animal ON medical_photos(animal_id); +CREATE INDEX IF NOT EXISTS idx_medical_photos_note ON medical_photos(medical_note_id); diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..f061476 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,16 @@ +DirectoryIndex index.php + + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L,QSA] + + + + Options -MultiViews + + + + Require all denied + diff --git a/public/.user.ini b/public/.user.ini new file mode 100644 index 0000000..f11ac0e --- /dev/null +++ b/public/.user.ini @@ -0,0 +1,5 @@ +upload_max_filesize = 250M +post_max_size = 260M +max_execution_time = 600 +max_input_time = 600 +memory_limit = 512M diff --git a/public/assets/accounting.js b/public/assets/accounting.js new file mode 100644 index 0000000..68c281e --- /dev/null +++ b/public/assets/accounting.js @@ -0,0 +1,16 @@ +document.addEventListener('DOMContentLoaded',()=>{ + if(location.pathname!=='/accounting')return; + const filterForm=document.querySelector('form[action="/accounting"]'); + const invoiceForm=document.querySelector('form[action="/accounting/save"]'); + if(filterForm&&invoiceForm)invoiceForm.closest('.card')?.after(filterForm); + const structuresTitle=[...document.querySelectorAll('.card-title')].find(node=>node.textContent.trim()==='Coût et paiements par structure'); + structuresTitle?.closest('.col-xl-7')?.classList.add('accounting-dashboard-structures'); + const canvas=document.getElementById('accountingAnnualChart'); + if(canvas&&window.Chart){const rows=JSON.parse(canvas.dataset.series||'[]');new Chart(canvas,{type:'bar',data:{labels:rows.map(r=>r.year),datasets:[{label:'Payé',data:rows.map(r=>(r.billed-r.due)/100),backgroundColor:'rgba(47,179,68,.72)'},{label:'Reste dû',data:rows.map(r=>r.due/100),backgroundColor:'rgba(245,159,0,.75)'}]},options:{responsive:true,maintainAspectRatio:false,scales:{x:{stacked:true},y:{stacked:true,beginAtZero:true,ticks:{callback:v=>v.toLocaleString('fr-FR')+' €'}}},plugins:{tooltip:{callbacks:{label:c=>c.dataset.label+' : '+c.parsed.y.toLocaleString('fr-FR',{minimumFractionDigits:2})+' €'}}}}});} + document.querySelectorAll('.table tbody tr').forEach(row=>{ + const reference=row.cells[1]?.textContent.trim();if(!reference)return; + row.classList.add('cursor-pointer');row.tabIndex=0; + const open=event=>{if(event.target.closest('a,button,input,select'))return;if(event.type==='keydown'&&!['Enter',' '].includes(event.key))return;location.href='/accounting/edit-by-reference?reference='+encodeURIComponent(reference);}; + row.addEventListener('click',open);row.addEventListener('keydown',open); + }); +}); diff --git a/public/assets/app-shell.css b/public/assets/app-shell.css new file mode 100644 index 0000000..b02200c --- /dev/null +++ b/public/assets/app-shell.css @@ -0,0 +1,251 @@ +/* Globinours — structure visuelle commune */ +.coat-swatch { + display:inline-block; + width:1rem; + height:1rem; + flex:0 0 1rem; + border:1px solid rgba(98,105,118,.35); + border-radius:50%; + box-shadow:inset 0 0 0 1px rgba(255,255,255,.25); + vertical-align:-.15rem; +} + +body, +.page { + background: + radial-gradient(circle at 10% 10%, rgba(205, 167, 98, .14), transparent 32rem), + radial-gradient(circle at 90% 90%, rgba(180, 139, 78, .09), transparent 28rem), + #f7f3eb; + background-attachment: fixed; +} + +.page > .navbar { + position: relative; + z-index: 1050; + overflow: visible; + background: transparent; + border: 0; + box-shadow: none; + padding-top: .75rem; +} + +.topbar-surface { + height: 80px; + background: rgba(255, 255, 255, .94); + border: 1px solid rgba(98, 105, 118, .14); + border-radius: .75rem; + box-shadow: 0 3px 14px rgba(31, 42, 68, .06); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + padding-left: 1rem; + padding-right: 1rem; + overflow: visible; +} + +.settings-dropdown { + position: relative; + z-index: 1060; +} + +.search-trigger-wrap { + display: inline-flex; + align-items: center; + margin-right: .65rem; +} + +.search-trigger { + width: 42px; + height: 42px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--tblr-secondary, #626976); + background: rgba(98, 105, 118, .06); + border: 0; + border-radius: 50%; + transition: color .15s ease, background-color .15s ease, transform .15s ease; +} + +.search-trigger:hover, +.search-trigger:focus-visible { + color: var(--tblr-primary, #206bc4); + background: rgba(32, 107, 196, .12); + transform: translateY(-1px); +} + +.search-trigger .icon { + width: 21px; + height: 21px; + margin: 0; +} + +.settings-trigger { + width: 48px; + height: 48px; + min-height: 48px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--tblr-dark, #1d273b); + background: transparent; + border-color: var(--tblr-dark, #1d273b); +} + +.settings-trigger:focus { + color: var(--tblr-dark, #1d273b); + background: transparent; + border-color: var(--tblr-dark, #1d273b); +} + +.settings-trigger:hover, +.settings-trigger.show, +.settings-trigger.show:focus { + color: #fff; + background: var(--tblr-dark, #1d273b); + border-color: var(--tblr-dark, #1d273b); +} + +.settings-dropdown .dropdown-menu { + z-index: 2000; + min-width: 220px; +} +.settings-dropdown .dropdown-menu.settings-menu-wide { + width: min(560px, calc(100vw - 1.5rem)); +} +.settings-menu-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + padding: .25rem .5rem; +} +.settings-menu-column { + min-width: 0; + padding: 0 .35rem; +} +.settings-menu-column + .settings-menu-column { + border-left: 1px solid rgba(98,105,118,.16); + padding-left: .85rem; +} +.settings-menu-group + .settings-menu-group { + margin-top: .65rem; + padding-top: .55rem; + border-top: 1px solid rgba(98,105,118,.12); +} +.settings-menu-group .dropdown-header { + padding-inline: .65rem; +} +.settings-menu-group .dropdown-item { + border-radius: var(--tblr-border-radius); + white-space: normal; +} + +.settings-section-nav .btn { + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + padding-inline: 1rem; +} +.settings-nav-group{padding-right:1rem;border-right:1px solid rgba(98,105,118,.16)}.settings-nav-group:last-child{padding-right:0;border-right:0} + +.paw-footer { + height: 130px; + flex: 0 0 130px; + margin-top: 1.5rem; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='84' viewBox='0 0 120 84'%3E%3Cg fill='%23a97f45' opacity='.22' transform='translate(12 9) rotate(-12 22 22)'%3E%3Cellipse cx='21' cy='31' rx='13' ry='10'/%3E%3Cellipse cx='8' cy='17' rx='5' ry='7'/%3E%3Cellipse cx='19' cy='10' rx='5' ry='7'/%3E%3Cellipse cx='31' cy='12' rx='5' ry='7'/%3E%3Cellipse cx='39' cy='23' rx='5' ry='7'/%3E%3C/g%3E%3Cg fill='%23a97f45' opacity='.14' transform='translate(72 43) rotate(14 18 18) scale(.78)'%3E%3Cellipse cx='21' cy='31' rx='13' ry='10'/%3E%3Cellipse cx='8' cy='17' rx='5' ry='7'/%3E%3Cellipse cx='19' cy='10' rx='5' ry='7'/%3E%3Cellipse cx='31' cy='12' rx='5' ry='7'/%3E%3Cellipse cx='39' cy='23' rx='5' ry='7'/%3E%3C/g%3E%3C/svg%3E"); + background-repeat: repeat; + background-position: center bottom; + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 45%, #000); + mask-image: linear-gradient(to bottom, transparent, #000 45%, #000); +} + +.globinours-footer { margin-top: 2rem; } +.footer-surface { position: relative; z-index: 1; padding: .85rem 1rem; border: 1px solid rgba(169,127,69,.2); border-radius: .8rem; background: rgba(255,255,255,.78); box-shadow: 0 .2rem .8rem rgba(75,57,36,.05); backdrop-filter: blur(5px); } +.globinours-footer .paw-footer { margin-top: -1.5rem; } +.footer-meta { font-size: .78rem; color: var(--tblr-secondary); } +.footer-version { background: rgba(169,127,69,.12); color: #76552d; border: 1px solid rgba(169,127,69,.2); } +.footer-source { display: inline-flex; align-items: center; gap: .3rem; color: inherit; text-decoration: none; } +.footer-source:hover { color: var(--tblr-primary); text-decoration: none; } +.footer-source .icon { width: 16px; height: 16px; margin: 0; } +.breton-flag { position: relative; display: inline-block; width: 23px; height: 15px; overflow: hidden; border: 1px solid rgba(0,0,0,.3); border-radius: 2px; vertical-align: middle; box-shadow: 0 1px 2px rgba(0,0,0,.12); background: repeating-linear-gradient(to bottom,#111 0,#111 1.66px,#fff 1.66px,#fff 3.32px); } +.breton-flag::before { content: ""; position: absolute; inset: 0 auto auto 0; width: 9px; height: 8.3px; background-color: #fff; background-image: radial-gradient(circle,#111 0,#111 .8px,transparent .9px); background-size: 3px 3px; } + +.page-header { + position: relative; + isolation: isolate; +} + +.page-header::before { + content: ""; + position: absolute; + inset: -.35rem 0 -.75rem; + z-index: -1; + pointer-events: none; + opacity: .42; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='84' viewBox='0 0 120 84'%3E%3Cg fill='%23a97f45' opacity='.22' transform='translate(12 9) rotate(-12 22 22)'%3E%3Cellipse cx='21' cy='31' rx='13' ry='10'/%3E%3Cellipse cx='8' cy='17' rx='5' ry='7'/%3E%3Cellipse cx='19' cy='10' rx='5' ry='7'/%3E%3Cellipse cx='31' cy='12' rx='5' ry='7'/%3E%3Cellipse cx='39' cy='23' rx='5' ry='7'/%3E%3C/g%3E%3Cg fill='%23a97f45' opacity='.14' transform='translate(72 43) rotate(14 18 18) scale(.78)'%3E%3Cellipse cx='21' cy='31' rx='13' ry='10'/%3E%3Cellipse cx='8' cy='17' rx='5' ry='7'/%3E%3Cellipse cx='19' cy='10' rx='5' ry='7'/%3E%3Cellipse cx='31' cy='12' rx='5' ry='7'/%3E%3Cellipse cx='39' cy='23' rx='5' ry='7'/%3E%3C/g%3E%3C/svg%3E"); + background-repeat: repeat; + background-position: 37px top; + -webkit-mask-image: linear-gradient(to bottom, #000, transparent 90%); + mask-image: linear-gradient(to bottom, #000, transparent 90%); +} + +.page-header > .container-xl { + position: relative; + z-index: 1; +} + +/* pins custom façon “pastille” */ +.twix-pin{ + width:18px; height:18px; + border-radius:50%; + border:3px solid #fff; + box-shadow: 0 2px 8px rgba(0,0,0,.25); +} +.twix-pin.rescue{ background:#1e88e5; } /* bleu */ +.twix-pin.current{ background:#43a047; } /* vert */ + +.leaflet-div-icon{ + background: transparent; + border: none; +} + +/* petite légende dans la carte */ +.twix-map-legend{ + background: rgba(255,255,255,.92); + border-radius: 10px; + padding: 8px 10px; + box-shadow: 0 2px 10px rgba(0,0,0,.12); + font-size: 12px; +} +.twix-map-legend .row{ + display:flex; align-items:center; gap:8px; margin:4px 0; +} +.twix-map-legend .dot{ + width:20px; height:20px; border-radius:50%; + border:2px solid #fff; + box-shadow: 0 1px 4px rgba(0,0,0,.2); +} +.twix-map-legend .dot.rescue{ background:#1e88e5; } +.twix-map-legend .dot.current{ background:#43a047; } +.twix-map-legend .line{ + width:18px; height:0; border-top:3px dashed #1e88e5; + opacity:.8; +} + +/****** CUSTOM *****/ + +.nav-tabs-alt .nav-link { + font-weight: 500; + padding: 0.75rem 1.1rem; +} + +.nav-tabs-alt .nav-link.active { + border-bottom: 3px solid var(--tblr-primary); + background: #fff; +} + +/****** END CUSTOM *****/ + diff --git a/public/assets/cat_placeholder.svg b/public/assets/cat_placeholder.svg new file mode 100644 index 0000000..b5fb207 --- /dev/null +++ b/public/assets/cat_placeholder.svg @@ -0,0 +1 @@ + diff --git a/public/assets/global-search.js b/public/assets/global-search.js new file mode 100644 index 0000000..c705d78 --- /dev/null +++ b/public/assets/global-search.js @@ -0,0 +1,61 @@ +(() => { + const modal = document.getElementById('globalSearchModal'); + const input = document.getElementById('globalSearchInput'); + const results = document.getElementById('globalSearchResults'); + if (!modal || !input || !results) return; + + const labels = modal.dataset; + let groups = {}; + try { groups = JSON.parse(labels.groups || '{}'); } catch {} + let timer; + let request; + const escapeText = value => { + const node = document.createElement('span'); + node.textContent = String(value ?? ''); + return node.innerHTML; + }; + const notice = (message, danger = false) => + '
' + escapeText(message) + '
'; + + document.addEventListener('keydown', event => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + bootstrap.Modal.getOrCreateInstance(modal).show(); + } + }); + modal.addEventListener('shown.bs.modal', () => input.focus()); + input.addEventListener('input', () => { + clearTimeout(timer); + const query = input.value.trim(); + if (query.length < 2) { + results.innerHTML = notice(labels.hint); + return; + } + timer = setTimeout(async () => { + request?.abort(); + request = new AbortController(); + results.innerHTML = notice(labels.loading); + try { + const response = await fetch('/search.json?q=' + encodeURIComponent(query), { + signal: request.signal, + headers: {Accept: 'application/json'} + }); + if (!response.ok) throw new Error('Search failed'); + const data = await response.json(); + const entries = Object.entries(data.groups || {}); + if (!entries.length) { + results.innerHTML = notice(labels.none); + return; + } + results.innerHTML = entries.map(([group, items]) => + '
' + escapeText(groups[group] || group) + '
' + + items.map(item => '' + escapeText(item.title) + '
' + escapeText(item.detail) + '
').join('') + + '
' + ).join('') + '' + escapeText(labels.allResults) + ''; + } catch (error) { + if (error.name !== 'AbortError') results.innerHTML = notice(labels.error, true); + } + }, 220); + }); +})(); + diff --git a/public/assets/logo-globinours-64.png b/public/assets/logo-globinours-64.png new file mode 100644 index 0000000..e544052 Binary files /dev/null and b/public/assets/logo-globinours-64.png differ diff --git a/public/assets/mobile.css b/public/assets/mobile.css new file mode 100644 index 0000000..fcf9e5c --- /dev/null +++ b/public/assets/mobile.css @@ -0,0 +1,5 @@ +html{scroll-padding-top:1rem}img{max-width:100%}.table-responsive{overscroll-behavior-inline:contain}.nav-tabs{flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:thin}.nav-tabs .nav-link{white-space:nowrap}.modal-dialog-scrollable{max-height:calc(100dvh - 1rem)} +@media(max-width:991.98px){.page>.navbar{padding:.5rem}.topbar-surface{height:auto;min-height:72px;padding:.55rem;gap:.5rem}.topbar-surface>.navbar-brand{flex:0 0 auto}.topbar-surface>.navbar-nav{min-width:0;overflow-x:auto;overscroll-behavior-inline:contain;padding-bottom:.2rem}.topbar-surface>.navbar-nav>.btn,.topbar-surface>.navbar-nav>.dropdown{flex:0 0 auto}.page-header{padding-top:.5rem}.page-body{margin-top:.75rem}.settings-section-nav{flex-wrap:nowrap!important;overflow-x:auto;padding-bottom:.35rem}.footer-surface{align-items:flex-start!important}.page-title{font-size:1.15rem}} +@media(max-width:767.98px){body,.page{background-attachment:scroll}.container-xl{padding-left:.75rem;padding-right:.75rem}.topbar-surface .navbar-brand img{width:42px;height:44px}.topbar-surface .navbar-brand span span:last-child{display:none}.topbar-surface>.navbar-nav{gap:.25rem}.topbar-surface>.navbar-nav>.btn{min-width:44px;height:46px;padding:.45rem;white-space:nowrap;margin-right:.25rem!important}.settings-trigger{width:46px;height:46px;margin-right:0!important}.settings-dropdown .dropdown-menu{position:fixed!important;inset:auto .75rem .75rem .75rem!important;width:auto!important;max-height:75dvh;overflow-y:auto;transform:none!important}.settings-menu-columns{grid-template-columns:1fr}.settings-menu-column{padding:.15rem .35rem}.settings-menu-column+.settings-menu-column{border-left:0;border-top:1px solid rgba(98,105,118,.16);margin-top:.65rem;padding-top:.65rem;padding-left:.35rem}.card-header,.card-body,.card-footer{padding-left:.85rem;padding-right:.85rem}.btn{min-height:44px}.btn-sm{min-height:40px}input.form-control,select.form-select,textarea.form-control{font-size:16px}.modal-dialog{margin:.5rem}.modal-content{max-height:calc(100dvh - 1rem)}.modal-body{overflow-y:auto}.footer-meta{gap:.65rem!important}.paw-footer{height:80px;flex-basis:80px}} +@media(max-width:420px){.topbar-surface .navbar-brand span{display:none}.topbar-surface .navbar-brand img{margin-right:0!important}.footer-surface{font-size:.9rem}} +@media(display-mode:standalone){body{padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom)}.page>.navbar{position:sticky;top:env(safe-area-inset-top);background:#f7f3eb;z-index:1050}} diff --git a/public/assets/pwa.js b/public/assets/pwa.js new file mode 100644 index 0000000..388e6ae --- /dev/null +++ b/public/assets/pwa.js @@ -0,0 +1,2 @@ +if('serviceWorker'in navigator&&location.protocol==='https:')window.addEventListener('load',()=>navigator.serviceWorker.register('/service-worker.js',{scope:'/'}).catch(()=>{})); +document.addEventListener('DOMContentLoaded',()=>{const logout=document.querySelector('.settings-dropdown form[action="/logout"]');if(!logout||logout.parentElement.querySelector('[href="/account/devices"]'))return;const link=document.createElement('a');link.className='dropdown-item';link.href='/account/devices';link.textContent=(document.documentElement.lang==='en'?'📱 Trusted devices':'📱 Appareils connectés');logout.before(link);}); diff --git a/public/assets/vendor/chartjs/LICENSE.md b/public/assets/vendor/chartjs/LICENSE.md new file mode 100644 index 0000000..9182b8e --- /dev/null +++ b/public/assets/vendor/chartjs/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2014-2022 Chart.js Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/public/assets/vendor/chartjs/chart.umd.min.js b/public/assets/vendor/chartjs/chart.umd.min.js new file mode 100644 index 0000000..78c4e5d --- /dev/null +++ b/public/assets/vendor/chartjs/chart.umd.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.1/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.1 + * https://www.chartjs.org + * (c) 2023 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}b.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return b}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.1";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=t=>{const i=t.controller.getParsed(e),n=i&&i[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a)>=O||Z(n,a,r),g=tt(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n;if(!i.forceOverride&&(Ko(s)||(a=n)&&(a.borderColor||a.backgroundColor)||o&&Ko(o)))return;var a;const r=qo(t);s.forEach(r)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/public/assets/vendor/flatpickr/LICENSE.md b/public/assets/vendor/flatpickr/LICENSE.md new file mode 100644 index 0000000..5bce366 --- /dev/null +++ b/public/assets/vendor/flatpickr/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Gregory Petrosyan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/public/assets/vendor/flatpickr/flatpickr.min.css b/public/assets/vendor/flatpickr/flatpickr.min.css new file mode 100644 index 0000000..a10acc6 --- /dev/null +++ b/public/assets/vendor/flatpickr/flatpickr.min.css @@ -0,0 +1,13 @@ +.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/* + /*rtl:begin:ignore*/left:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/* + /*rtl:begin:ignore*/right:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:"";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:"";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}} \ No newline at end of file diff --git a/public/assets/vendor/flatpickr/flatpickr.min.js b/public/assets/vendor/flatpickr/flatpickr.min.js new file mode 100644 index 0000000..b0f59ec --- /dev/null +++ b/public/assets/vendor/flatpickr/flatpickr.min.js @@ -0,0 +1,2 @@ +/* flatpickr v4.6.13,, @license MIT */ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e="undefined"!=typeof globalThis?globalThis:e||self).flatpickr=n()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var n,t=1,a=arguments.length;t",noCalendar:!1,now:new Date,onChange:[],onClose:[],onDayCreate:[],onDestroy:[],onKeyDown:[],onMonthChange:[],onOpen:[],onParseConfig:[],onReady:[],onValueUpdate:[],onYearChange:[],onPreCalendarPosition:[],plugins:[],position:"auto",positionElement:void 0,prevArrow:"",shorthandCurrentMonth:!1,showMonths:1,static:!1,time_24hr:!1,weekNumbers:!1,wrap:!1},i={weekdays:{shorthand:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],longhand:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},months:{shorthand:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],longhand:["January","February","March","April","May","June","July","August","September","October","November","December"]},daysInMonth:[31,28,31,30,31,30,31,31,30,31,30,31],firstDayOfWeek:0,ordinal:function(e){var n=e%100;if(n>3&&n<21)return"th";switch(n%10){case 1:return"st";case 2:return"nd";case 3:return"rd";default:return"th"}},rangeSeparator:" to ",weekAbbreviation:"Wk",scrollTitle:"Scroll to increment",toggleTitle:"Click to toggle",amPM:["AM","PM"],yearAriaLabel:"Year",monthAriaLabel:"Month",hourAriaLabel:"Hour",minuteAriaLabel:"Minute",time_24hr:!1},o=function(e,n){return void 0===n&&(n=2),("000"+e).slice(-1*n)},r=function(e){return!0===e?1:0};function l(e,n){var t;return function(){var a=this,i=arguments;clearTimeout(t),t=setTimeout((function(){return e.apply(a,i)}),n)}}var c=function(e){return e instanceof Array?e:[e]};function s(e,n,t){if(!0===t)return e.classList.add(n);e.classList.remove(n)}function d(e,n,t){var a=window.document.createElement(e);return n=n||"",t=t||"",a.className=n,void 0!==t&&(a.textContent=t),a}function u(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function f(e,n){return n(e)?e:e.parentNode?f(e.parentNode,n):void 0}function m(e,n){var t=d("div","numInputWrapper"),a=d("input","numInput "+e),i=d("span","arrowUp"),o=d("span","arrowDown");if(-1===navigator.userAgent.indexOf("MSIE 9.0")?a.type="number":(a.type="text",a.pattern="\\d*"),void 0!==n)for(var r in n)a.setAttribute(r,n[r]);return t.appendChild(a),t.appendChild(i),t.appendChild(o),t}function g(e){try{return"function"==typeof e.composedPath?e.composedPath()[0]:e.target}catch(n){return e.target}}var p=function(){},h=function(e,n,t){return t.months[n?"shorthand":"longhand"][e]},v={D:p,F:function(e,n,t){e.setMonth(t.months.longhand.indexOf(n))},G:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},H:function(e,n){e.setHours(parseFloat(n))},J:function(e,n){e.setDate(parseFloat(n))},K:function(e,n,t){e.setHours(e.getHours()%12+12*r(new RegExp(t.amPM[1],"i").test(n)))},M:function(e,n,t){e.setMonth(t.months.shorthand.indexOf(n))},S:function(e,n){e.setSeconds(parseFloat(n))},U:function(e,n){return new Date(1e3*parseFloat(n))},W:function(e,n,t){var a=parseInt(n),i=new Date(e.getFullYear(),0,2+7*(a-1),0,0,0,0);return i.setDate(i.getDate()-i.getDay()+t.firstDayOfWeek),i},Y:function(e,n){e.setFullYear(parseFloat(n))},Z:function(e,n){return new Date(n)},d:function(e,n){e.setDate(parseFloat(n))},h:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},i:function(e,n){e.setMinutes(parseFloat(n))},j:function(e,n){e.setDate(parseFloat(n))},l:p,m:function(e,n){e.setMonth(parseFloat(n)-1)},n:function(e,n){e.setMonth(parseFloat(n)-1)},s:function(e,n){e.setSeconds(parseFloat(n))},u:function(e,n){return new Date(parseFloat(n))},w:p,y:function(e,n){e.setFullYear(2e3+parseFloat(n))}},D={D:"",F:"",G:"(\\d\\d|\\d)",H:"(\\d\\d|\\d)",J:"(\\d\\d|\\d)\\w+",K:"",M:"",S:"(\\d\\d|\\d)",U:"(.+)",W:"(\\d\\d|\\d)",Y:"(\\d{4})",Z:"(.+)",d:"(\\d\\d|\\d)",h:"(\\d\\d|\\d)",i:"(\\d\\d|\\d)",j:"(\\d\\d|\\d)",l:"",m:"(\\d\\d|\\d)",n:"(\\d\\d|\\d)",s:"(\\d\\d|\\d)",u:"(.+)",w:"(\\d\\d|\\d)",y:"(\\d{2})"},w={Z:function(e){return e.toISOString()},D:function(e,n,t){return n.weekdays.shorthand[w.w(e,n,t)]},F:function(e,n,t){return h(w.n(e,n,t)-1,!1,n)},G:function(e,n,t){return o(w.h(e,n,t))},H:function(e){return o(e.getHours())},J:function(e,n){return void 0!==n.ordinal?e.getDate()+n.ordinal(e.getDate()):e.getDate()},K:function(e,n){return n.amPM[r(e.getHours()>11)]},M:function(e,n){return h(e.getMonth(),!0,n)},S:function(e){return o(e.getSeconds())},U:function(e){return e.getTime()/1e3},W:function(e,n,t){return t.getWeek(e)},Y:function(e){return o(e.getFullYear(),4)},d:function(e){return o(e.getDate())},h:function(e){return e.getHours()%12?e.getHours()%12:12},i:function(e){return o(e.getMinutes())},j:function(e){return e.getDate()},l:function(e,n){return n.weekdays.longhand[e.getDay()]},m:function(e){return o(e.getMonth()+1)},n:function(e){return e.getMonth()+1},s:function(e){return e.getSeconds()},u:function(e){return e.getTime()},w:function(e){return e.getDay()},y:function(e){return String(e.getFullYear()).substring(2)}},b=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o,l=e.isMobile,c=void 0!==l&&l;return function(e,n,a){var i=a||r;return void 0===t.formatDate||c?n.split("").map((function(n,a,o){return w[n]&&"\\"!==o[a-1]?w[n](e,i,t):"\\"!==n?n:""})).join(""):t.formatDate(e,n,i)}},C=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o;return function(e,n,i,o){if(0===e||e){var l,c=o||r,s=e;if(e instanceof Date)l=new Date(e.getTime());else if("string"!=typeof e&&void 0!==e.toFixed)l=new Date(e);else if("string"==typeof e){var d=n||(t||a).dateFormat,u=String(e).trim();if("today"===u)l=new Date,i=!0;else if(t&&t.parseDate)l=t.parseDate(e,d);else if(/Z$/.test(u)||/GMT$/.test(u))l=new Date(e);else{for(var f=void 0,m=[],g=0,p=0,h="";g=0?new Date:new Date(w.config.minDate.getTime()),t=E(w.config);n.setHours(t.hours,t.minutes,t.seconds,n.getMilliseconds()),w.selectedDates=[n],w.latestSelectedDateObj=n}void 0!==e&&"blur"!==e.type&&function(e){e.preventDefault();var n="keydown"===e.type,t=g(e),a=t;void 0!==w.amPM&&t===w.amPM&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]);var i=parseFloat(a.getAttribute("min")),l=parseFloat(a.getAttribute("max")),c=parseFloat(a.getAttribute("step")),s=parseInt(a.value,10),d=e.delta||(n?38===e.which?1:-1:0),u=s+c*d;if(void 0!==a.value&&2===a.value.length){var f=a===w.hourElement,m=a===w.minuteElement;ul&&(u=a===w.hourElement?u-l-r(!w.amPM):i,m&&L(void 0,1,w.hourElement)),w.amPM&&f&&(1===c?u+s===23:Math.abs(u-s)>c)&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]),a.value=o(u)}}(e);var a=w._input.value;O(),ye(),w._input.value!==a&&w._debouncedChange()}function O(){if(void 0!==w.hourElement&&void 0!==w.minuteElement){var e,n,t=(parseInt(w.hourElement.value.slice(-2),10)||0)%24,a=(parseInt(w.minuteElement.value,10)||0)%60,i=void 0!==w.secondElement?(parseInt(w.secondElement.value,10)||0)%60:0;void 0!==w.amPM&&(e=t,n=w.amPM.textContent,t=e%12+12*r(n===w.l10n.amPM[1]));var o=void 0!==w.config.minTime||w.config.minDate&&w.minDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.minDate,!0),l=void 0!==w.config.maxTime||w.config.maxDate&&w.maxDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.maxDate,!0);if(void 0!==w.config.maxTime&&void 0!==w.config.minTime&&w.config.minTime>w.config.maxTime){var c=y(w.config.minTime.getHours(),w.config.minTime.getMinutes(),w.config.minTime.getSeconds()),s=y(w.config.maxTime.getHours(),w.config.maxTime.getMinutes(),w.config.maxTime.getSeconds()),d=y(t,a,i);if(d>s&&d=12)]),void 0!==w.secondElement&&(w.secondElement.value=o(t)))}function N(e){var n=g(e),t=parseInt(n.value)+(e.delta||0);(t/1e3>1||"Enter"===e.key&&!/[^\d]/.test(t.toString()))&&ee(t)}function P(e,n,t,a){return n instanceof Array?n.forEach((function(n){return P(e,n,t,a)})):e instanceof Array?e.forEach((function(e){return P(e,n,t,a)})):(e.addEventListener(n,t,a),void w._handlers.push({remove:function(){return e.removeEventListener(n,t,a)}}))}function Y(){De("onChange")}function j(e,n){var t=void 0!==e?w.parseDate(e):w.latestSelectedDateObj||(w.config.minDate&&w.config.minDate>w.now?w.config.minDate:w.config.maxDate&&w.config.maxDate=0&&M(e,w.selectedDates[1])<=0)}(n)&&!be(n)&&o.classList.add("inRange"),w.weekNumbers&&1===w.config.showMonths&&"prevMonthDay"!==e&&a%7==6&&w.weekNumbers.insertAdjacentHTML("beforeend",""+w.config.getWeek(n)+""),De("onDayCreate",o),o}function W(e){e.focus(),"range"===w.config.mode&&oe(e)}function B(e){for(var n=e>0?0:w.config.showMonths-1,t=e>0?w.config.showMonths:-1,a=n;a!=t;a+=e)for(var i=w.daysContainer.children[a],o=e>0?0:i.children.length-1,r=e>0?i.children.length:-1,l=o;l!=r;l+=e){var c=i.children[l];if(-1===c.className.indexOf("hidden")&&ne(c.dateObj))return c}}function J(e,n){var t=k(),a=te(t||document.body),i=void 0!==e?e:a?t:void 0!==w.selectedDateElem&&te(w.selectedDateElem)?w.selectedDateElem:void 0!==w.todayDateElem&&te(w.todayDateElem)?w.todayDateElem:B(n>0?1:-1);void 0===i?w._input.focus():a?function(e,n){for(var t=-1===e.className.indexOf("Month")?e.dateObj.getMonth():w.currentMonth,a=n>0?w.config.showMonths:-1,i=n>0?1:-1,o=t-w.currentMonth;o!=a;o+=i)for(var r=w.daysContainer.children[o],l=t-w.currentMonth===o?e.$i+n:n<0?r.children.length-1:0,c=r.children.length,s=l;s>=0&&s0?c:-1);s+=i){var d=r.children[s];if(-1===d.className.indexOf("hidden")&&ne(d.dateObj)&&Math.abs(e.$i-s)>=Math.abs(n))return W(d)}w.changeMonth(i),J(B(i),0)}(i,n):W(i)}function K(e,n){for(var t=(new Date(e,n,1).getDay()-w.l10n.firstDayOfWeek+7)%7,a=w.utils.getDaysInMonth((n-1+12)%12,e),i=w.utils.getDaysInMonth(n,e),o=window.document.createDocumentFragment(),r=w.config.showMonths>1,l=r?"prevMonthDay hidden":"prevMonthDay",c=r?"nextMonthDay hidden":"nextMonthDay",s=a+1-t,u=0;s<=a;s++,u++)o.appendChild(R("flatpickr-day "+l,new Date(e,n-1,s),0,u));for(s=1;s<=i;s++,u++)o.appendChild(R("flatpickr-day",new Date(e,n,s),0,u));for(var f=i+1;f<=42-t&&(1===w.config.showMonths||u%7!=0);f++,u++)o.appendChild(R("flatpickr-day "+c,new Date(e,n+1,f%i),0,u));var m=d("div","dayContainer");return m.appendChild(o),m}function U(){if(void 0!==w.daysContainer){u(w.daysContainer),w.weekNumbers&&u(w.weekNumbers);for(var e=document.createDocumentFragment(),n=0;n1||"dropdown"!==w.config.monthSelectorType)){var e=function(e){return!(void 0!==w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&ew.config.maxDate.getMonth())};w.monthsDropdownContainer.tabIndex=-1,w.monthsDropdownContainer.innerHTML="";for(var n=0;n<12;n++)if(e(n)){var t=d("option","flatpickr-monthDropdown-month");t.value=new Date(w.currentYear,n).getMonth().toString(),t.textContent=h(n,w.config.shorthandCurrentMonth,w.l10n),t.tabIndex=-1,w.currentMonth===n&&(t.selected=!0),w.monthsDropdownContainer.appendChild(t)}}}function $(){var e,n=d("div","flatpickr-month"),t=window.document.createDocumentFragment();w.config.showMonths>1||"static"===w.config.monthSelectorType?e=d("span","cur-month"):(w.monthsDropdownContainer=d("select","flatpickr-monthDropdown-months"),w.monthsDropdownContainer.setAttribute("aria-label",w.l10n.monthAriaLabel),P(w.monthsDropdownContainer,"change",(function(e){var n=g(e),t=parseInt(n.value,10);w.changeMonth(t-w.currentMonth),De("onMonthChange")})),q(),e=w.monthsDropdownContainer);var a=m("cur-year",{tabindex:"-1"}),i=a.getElementsByTagName("input")[0];i.setAttribute("aria-label",w.l10n.yearAriaLabel),w.config.minDate&&i.setAttribute("min",w.config.minDate.getFullYear().toString()),w.config.maxDate&&(i.setAttribute("max",w.config.maxDate.getFullYear().toString()),i.disabled=!!w.config.minDate&&w.config.minDate.getFullYear()===w.config.maxDate.getFullYear());var o=d("div","flatpickr-current-month");return o.appendChild(e),o.appendChild(a),t.appendChild(o),n.appendChild(t),{container:n,yearElement:i,monthElement:e}}function V(){u(w.monthNav),w.monthNav.appendChild(w.prevMonthNav),w.config.showMonths&&(w.yearElements=[],w.monthElements=[]);for(var e=w.config.showMonths;e--;){var n=$();w.yearElements.push(n.yearElement),w.monthElements.push(n.monthElement),w.monthNav.appendChild(n.container)}w.monthNav.appendChild(w.nextMonthNav)}function z(){w.weekdayContainer?u(w.weekdayContainer):w.weekdayContainer=d("div","flatpickr-weekdays");for(var e=w.config.showMonths;e--;){var n=d("div","flatpickr-weekdaycontainer");w.weekdayContainer.appendChild(n)}return G(),w.weekdayContainer}function G(){if(w.weekdayContainer){var e=w.l10n.firstDayOfWeek,t=n(w.l10n.weekdays.shorthand);e>0&&e\n "+t.join("")+"\n \n "}}function Z(e,n){void 0===n&&(n=!0);var t=n?e:e-w.currentMonth;t<0&&!0===w._hidePrevMonthArrow||t>0&&!0===w._hideNextMonthArrow||(w.currentMonth+=t,(w.currentMonth<0||w.currentMonth>11)&&(w.currentYear+=w.currentMonth>11?1:-1,w.currentMonth=(w.currentMonth+12)%12,De("onYearChange"),q()),U(),De("onMonthChange"),Ce())}function Q(e){return w.calendarContainer.contains(e)}function X(e){if(w.isOpen&&!w.config.inline){var n=g(e),t=Q(n),a=!(n===w.input||n===w.altInput||w.element.contains(n)||e.path&&e.path.indexOf&&(~e.path.indexOf(w.input)||~e.path.indexOf(w.altInput)))&&!t&&!Q(e.relatedTarget),i=!w.config.ignoredFocusElements.some((function(e){return e.contains(n)}));a&&i&&(w.config.allowInput&&w.setDate(w._input.value,!1,w.config.altInput?w.config.altFormat:w.config.dateFormat),void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement&&""!==w.input.value&&void 0!==w.input.value&&_(),w.close(),w.config&&"range"===w.config.mode&&1===w.selectedDates.length&&w.clear(!1))}}function ee(e){if(!(!e||w.config.minDate&&ew.config.maxDate.getFullYear())){var n=e,t=w.currentYear!==n;w.currentYear=n||w.currentYear,w.config.maxDate&&w.currentYear===w.config.maxDate.getFullYear()?w.currentMonth=Math.min(w.config.maxDate.getMonth(),w.currentMonth):w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&(w.currentMonth=Math.max(w.config.minDate.getMonth(),w.currentMonth)),t&&(w.redraw(),De("onYearChange"),q())}}function ne(e,n){var t;void 0===n&&(n=!0);var a=w.parseDate(e,void 0,n);if(w.config.minDate&&a&&M(a,w.config.minDate,void 0!==n?n:!w.minDateHasTime)<0||w.config.maxDate&&a&&M(a,w.config.maxDate,void 0!==n?n:!w.maxDateHasTime)>0)return!1;if(!w.config.enable&&0===w.config.disable.length)return!0;if(void 0===a)return!1;for(var i=!!w.config.enable,o=null!==(t=w.config.enable)&&void 0!==t?t:w.config.disable,r=0,l=void 0;r=l.from.getTime()&&a.getTime()<=l.to.getTime())return i}return!i}function te(e){return void 0!==w.daysContainer&&(-1===e.className.indexOf("hidden")&&-1===e.className.indexOf("flatpickr-disabled")&&w.daysContainer.contains(e))}function ae(e){var n=e.target===w._input,t=w._input.value.trimEnd()!==Me();!n||!t||e.relatedTarget&&Q(e.relatedTarget)||w.setDate(w._input.value,!0,e.target===w.altInput?w.config.altFormat:w.config.dateFormat)}function ie(e){var n=g(e),t=w.config.wrap?p.contains(n):n===w._input,a=w.config.allowInput,i=w.isOpen&&(!a||!t),o=w.config.inline&&t&&!a;if(13===e.keyCode&&t){if(a)return w.setDate(w._input.value,!0,n===w.altInput?w.config.altFormat:w.config.dateFormat),w.close(),n.blur();w.open()}else if(Q(n)||i||o){var r=!!w.timeContainer&&w.timeContainer.contains(n);switch(e.keyCode){case 13:r?(e.preventDefault(),_(),fe()):me(e);break;case 27:e.preventDefault(),fe();break;case 8:case 46:t&&!w.config.allowInput&&(e.preventDefault(),w.clear());break;case 37:case 39:if(r||t)w.hourElement&&w.hourElement.focus();else{e.preventDefault();var l=k();if(void 0!==w.daysContainer&&(!1===a||l&&te(l))){var c=39===e.keyCode?1:-1;e.ctrlKey?(e.stopPropagation(),Z(c),J(B(1),0)):J(void 0,c)}}break;case 38:case 40:e.preventDefault();var s=40===e.keyCode?1:-1;w.daysContainer&&void 0!==n.$i||n===w.input||n===w.altInput?e.ctrlKey?(e.stopPropagation(),ee(w.currentYear-s),J(B(1),0)):r||J(void 0,7*s):n===w.currentYearElement?ee(w.currentYear-s):w.config.enableTime&&(!r&&w.hourElement&&w.hourElement.focus(),_(e),w._debouncedChange());break;case 9:if(r){var d=[w.hourElement,w.minuteElement,w.secondElement,w.amPM].concat(w.pluginElements).filter((function(e){return e})),u=d.indexOf(n);if(-1!==u){var f=d[u+(e.shiftKey?-1:1)];e.preventDefault(),(f||w._input).focus()}}else!w.config.noCalendar&&w.daysContainer&&w.daysContainer.contains(n)&&e.shiftKey&&(e.preventDefault(),w._input.focus())}}if(void 0!==w.amPM&&n===w.amPM)switch(e.key){case w.l10n.amPM[0].charAt(0):case w.l10n.amPM[0].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[0],O(),ye();break;case w.l10n.amPM[1].charAt(0):case w.l10n.amPM[1].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[1],O(),ye()}(t||Q(n))&&De("onKeyDown",e)}function oe(e,n){if(void 0===n&&(n="flatpickr-day"),1===w.selectedDates.length&&(!e||e.classList.contains(n)&&!e.classList.contains("flatpickr-disabled"))){for(var t=e?e.dateObj.getTime():w.days.firstElementChild.dateObj.getTime(),a=w.parseDate(w.selectedDates[0],void 0,!0).getTime(),i=Math.min(t,w.selectedDates[0].getTime()),o=Math.max(t,w.selectedDates[0].getTime()),r=!1,l=0,c=0,s=i;si&&sl)?l=s:s>a&&(!c||s ."+n)).forEach((function(n){var i,o,s,d=n.dateObj.getTime(),u=l>0&&d0&&d>c;if(u)return n.classList.add("notAllowed"),void["inRange","startRange","endRange"].forEach((function(e){n.classList.remove(e)}));r&&!u||(["startRange","inRange","endRange","notAllowed"].forEach((function(e){n.classList.remove(e)})),void 0!==e&&(e.classList.add(t<=w.selectedDates[0].getTime()?"startRange":"endRange"),at&&d===a&&n.classList.add("endRange"),d>=l&&(0===c||d<=c)&&(o=a,s=t,(i=d)>Math.min(o,s)&&i0||t.getMinutes()>0||t.getSeconds()>0),w.selectedDates&&(w.selectedDates=w.selectedDates.filter((function(e){return ne(e)})),w.selectedDates.length||"min"!==e||F(t),ye()),w.daysContainer&&(ue(),void 0!==t?w.currentYearElement[e]=t.getFullYear().toString():w.currentYearElement.removeAttribute(e),w.currentYearElement.disabled=!!a&&void 0!==t&&a.getFullYear()===t.getFullYear())}}function ce(){return w.config.wrap?p.querySelector("[data-input]"):p}function se(){"object"!=typeof w.config.locale&&void 0===I.l10ns[w.config.locale]&&w.config.errorHandler(new Error("flatpickr: invalid locale "+w.config.locale)),w.l10n=e(e({},I.l10ns.default),"object"==typeof w.config.locale?w.config.locale:"default"!==w.config.locale?I.l10ns[w.config.locale]:void 0),D.D="("+w.l10n.weekdays.shorthand.join("|")+")",D.l="("+w.l10n.weekdays.longhand.join("|")+")",D.M="("+w.l10n.months.shorthand.join("|")+")",D.F="("+w.l10n.months.longhand.join("|")+")",D.K="("+w.l10n.amPM[0]+"|"+w.l10n.amPM[1]+"|"+w.l10n.amPM[0].toLowerCase()+"|"+w.l10n.amPM[1].toLowerCase()+")",void 0===e(e({},v),JSON.parse(JSON.stringify(p.dataset||{}))).time_24hr&&void 0===I.defaultConfig.time_24hr&&(w.config.time_24hr=w.l10n.time_24hr),w.formatDate=b(w),w.parseDate=C({config:w.config,l10n:w.l10n})}function de(e){if("function"!=typeof w.config.position){if(void 0!==w.calendarContainer){De("onPreCalendarPosition");var n=e||w._positionElement,t=Array.prototype.reduce.call(w.calendarContainer.children,(function(e,n){return e+n.offsetHeight}),0),a=w.calendarContainer.offsetWidth,i=w.config.position.split(" "),o=i[0],r=i.length>1?i[1]:null,l=n.getBoundingClientRect(),c=window.innerHeight-l.bottom,d="above"===o||"below"!==o&&ct,u=window.pageYOffset+l.top+(d?-t-2:n.offsetHeight+2);if(s(w.calendarContainer,"arrowTop",!d),s(w.calendarContainer,"arrowBottom",d),!w.config.inline){var f=window.pageXOffset+l.left,m=!1,g=!1;"center"===r?(f-=(a-l.width)/2,m=!0):"right"===r&&(f-=a-l.width,g=!0),s(w.calendarContainer,"arrowLeft",!m&&!g),s(w.calendarContainer,"arrowCenter",m),s(w.calendarContainer,"arrowRight",g);var p=window.document.body.offsetWidth-(window.pageXOffset+l.right),h=f+a>window.document.body.offsetWidth,v=p+a>window.document.body.offsetWidth;if(s(w.calendarContainer,"rightMost",h),!w.config.static)if(w.calendarContainer.style.top=u+"px",h)if(v){var D=function(){for(var e=null,n=0;nw.currentMonth+w.config.showMonths-1)&&"range"!==w.config.mode;if(w.selectedDateElem=t,"single"===w.config.mode)w.selectedDates=[a];else if("multiple"===w.config.mode){var o=be(a);o?w.selectedDates.splice(parseInt(o),1):w.selectedDates.push(a)}else"range"===w.config.mode&&(2===w.selectedDates.length&&w.clear(!1,!1),w.latestSelectedDateObj=a,w.selectedDates.push(a),0!==M(a,w.selectedDates[0],!0)&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()})));if(O(),i){var r=w.currentYear!==a.getFullYear();w.currentYear=a.getFullYear(),w.currentMonth=a.getMonth(),r&&(De("onYearChange"),q()),De("onMonthChange")}if(Ce(),U(),ye(),i||"range"===w.config.mode||1!==w.config.showMonths?void 0!==w.selectedDateElem&&void 0===w.hourElement&&w.selectedDateElem&&w.selectedDateElem.focus():W(t),void 0!==w.hourElement&&void 0!==w.hourElement&&w.hourElement.focus(),w.config.closeOnSelect){var l="single"===w.config.mode&&!w.config.enableTime,c="range"===w.config.mode&&2===w.selectedDates.length&&!w.config.enableTime;(l||c)&&fe()}Y()}}w.parseDate=C({config:w.config,l10n:w.l10n}),w._handlers=[],w.pluginElements=[],w.loadedPlugins=[],w._bind=P,w._setHoursFromDate=F,w._positionCalendar=de,w.changeMonth=Z,w.changeYear=ee,w.clear=function(e,n){void 0===e&&(e=!0);void 0===n&&(n=!0);w.input.value="",void 0!==w.altInput&&(w.altInput.value="");void 0!==w.mobileInput&&(w.mobileInput.value="");w.selectedDates=[],w.latestSelectedDateObj=void 0,!0===n&&(w.currentYear=w._initialDate.getFullYear(),w.currentMonth=w._initialDate.getMonth());if(!0===w.config.enableTime){var t=E(w.config),a=t.hours,i=t.minutes,o=t.seconds;A(a,i,o)}w.redraw(),e&&De("onChange")},w.close=function(){w.isOpen=!1,w.isMobile||(void 0!==w.calendarContainer&&w.calendarContainer.classList.remove("open"),void 0!==w._input&&w._input.classList.remove("active"));De("onClose")},w.onMouseOver=oe,w._createElement=d,w.createDay=R,w.destroy=function(){void 0!==w.config&&De("onDestroy");for(var e=w._handlers.length;e--;)w._handlers[e].remove();if(w._handlers=[],w.mobileInput)w.mobileInput.parentNode&&w.mobileInput.parentNode.removeChild(w.mobileInput),w.mobileInput=void 0;else if(w.calendarContainer&&w.calendarContainer.parentNode)if(w.config.static&&w.calendarContainer.parentNode){var n=w.calendarContainer.parentNode;if(n.lastChild&&n.removeChild(n.lastChild),n.parentNode){for(;n.firstChild;)n.parentNode.insertBefore(n.firstChild,n);n.parentNode.removeChild(n)}}else w.calendarContainer.parentNode.removeChild(w.calendarContainer);w.altInput&&(w.input.type="text",w.altInput.parentNode&&w.altInput.parentNode.removeChild(w.altInput),delete w.altInput);w.input&&(w.input.type=w.input._type,w.input.classList.remove("flatpickr-input"),w.input.removeAttribute("readonly"));["_showTimeInput","latestSelectedDateObj","_hideNextMonthArrow","_hidePrevMonthArrow","__hideNextMonthArrow","__hidePrevMonthArrow","isMobile","isOpen","selectedDateElem","minDateHasTime","maxDateHasTime","days","daysContainer","_input","_positionElement","innerContainer","rContainer","monthNav","todayDateElem","calendarContainer","weekdayContainer","prevMonthNav","nextMonthNav","monthsDropdownContainer","currentMonthElement","currentYearElement","navigationCurrentMonth","selectedDateElem","config"].forEach((function(e){try{delete w[e]}catch(e){}}))},w.isEnabled=ne,w.jumpToDate=j,w.updateValue=ye,w.open=function(e,n){void 0===n&&(n=w._positionElement);if(!0===w.isMobile){if(e){e.preventDefault();var t=g(e);t&&t.blur()}return void 0!==w.mobileInput&&(w.mobileInput.focus(),w.mobileInput.click()),void De("onOpen")}if(w._input.disabled||w.config.inline)return;var a=w.isOpen;w.isOpen=!0,a||(w.calendarContainer.classList.add("open"),w._input.classList.add("active"),De("onOpen"),de(n));!0===w.config.enableTime&&!0===w.config.noCalendar&&(!1!==w.config.allowInput||void 0!==e&&w.timeContainer.contains(e.relatedTarget)||setTimeout((function(){return w.hourElement.select()}),50))},w.redraw=ue,w.set=function(e,n){if(null!==e&&"object"==typeof e)for(var a in Object.assign(w.config,e),e)void 0!==ge[a]&&ge[a].forEach((function(e){return e()}));else w.config[e]=n,void 0!==ge[e]?ge[e].forEach((function(e){return e()})):t.indexOf(e)>-1&&(w.config[e]=c(n));w.redraw(),ye(!0)},w.setDate=function(e,n,t){void 0===n&&(n=!1);void 0===t&&(t=w.config.dateFormat);if(0!==e&&!e||e instanceof Array&&0===e.length)return w.clear(n);pe(e,t),w.latestSelectedDateObj=w.selectedDates[w.selectedDates.length-1],w.redraw(),j(void 0,n),F(),0===w.selectedDates.length&&w.clear(!1);ye(n),n&&De("onChange")},w.toggle=function(e){if(!0===w.isOpen)return w.close();w.open(e)};var ge={locale:[se,G],showMonths:[V,S,z],minDate:[j],maxDate:[j],positionElement:[ve],clickOpens:[function(){!0===w.config.clickOpens?(P(w._input,"focus",w.open),P(w._input,"click",w.open)):(w._input.removeEventListener("focus",w.open),w._input.removeEventListener("click",w.open))}]};function pe(e,n){var t=[];if(e instanceof Array)t=e.map((function(e){return w.parseDate(e,n)}));else if(e instanceof Date||"number"==typeof e)t=[w.parseDate(e,n)];else if("string"==typeof e)switch(w.config.mode){case"single":case"time":t=[w.parseDate(e,n)];break;case"multiple":t=e.split(w.config.conjunction).map((function(e){return w.parseDate(e,n)}));break;case"range":t=e.split(w.l10n.rangeSeparator).map((function(e){return w.parseDate(e,n)}))}else w.config.errorHandler(new Error("Invalid date supplied: "+JSON.stringify(e)));w.selectedDates=w.config.allowInvalidPreload?t:t.filter((function(e){return e instanceof Date&&ne(e,!1)})),"range"===w.config.mode&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()}))}function he(e){return e.slice().map((function(e){return"string"==typeof e||"number"==typeof e||e instanceof Date?w.parseDate(e,void 0,!0):e&&"object"==typeof e&&e.from&&e.to?{from:w.parseDate(e.from,void 0),to:w.parseDate(e.to,void 0)}:e})).filter((function(e){return e}))}function ve(){w._positionElement=w.config.positionElement||w._input}function De(e,n){if(void 0!==w.config){var t=w.config[e];if(void 0!==t&&t.length>0)for(var a=0;t[a]&&a1||"static"===w.config.monthSelectorType?w.monthElements[n].textContent=h(t.getMonth(),w.config.shorthandCurrentMonth,w.l10n)+" ":w.monthsDropdownContainer.value=t.getMonth().toString(),e.value=t.getFullYear().toString()})),w._hidePrevMonthArrow=void 0!==w.config.minDate&&(w.currentYear===w.config.minDate.getFullYear()?w.currentMonth<=w.config.minDate.getMonth():w.currentYearw.config.maxDate.getMonth():w.currentYear>w.config.maxDate.getFullYear()))}function Me(e){var n=e||(w.config.altInput?w.config.altFormat:w.config.dateFormat);return w.selectedDates.map((function(e){return w.formatDate(e,n)})).filter((function(e,n,t){return"range"!==w.config.mode||w.config.enableTime||t.indexOf(e)===n})).join("range"!==w.config.mode?w.config.conjunction:w.l10n.rangeSeparator)}function ye(e){void 0===e&&(e=!0),void 0!==w.mobileInput&&w.mobileFormatStr&&(w.mobileInput.value=void 0!==w.latestSelectedDateObj?w.formatDate(w.latestSelectedDateObj,w.mobileFormatStr):""),w.input.value=Me(w.config.dateFormat),void 0!==w.altInput&&(w.altInput.value=Me(w.config.altFormat)),!1!==e&&De("onValueUpdate")}function xe(e){var n=g(e),t=w.prevMonthNav.contains(n),a=w.nextMonthNav.contains(n);t||a?Z(t?-1:1):w.yearElements.indexOf(n)>=0?n.select():n.classList.contains("arrowUp")?w.changeYear(w.currentYear+1):n.classList.contains("arrowDown")&&w.changeYear(w.currentYear-1)}return function(){w.element=w.input=p,w.isOpen=!1,function(){var n=["wrap","weekNumbers","allowInput","allowInvalidPreload","clickOpens","time_24hr","enableTime","noCalendar","altInput","shorthandCurrentMonth","inline","static","enableSeconds","disableMobile"],i=e(e({},JSON.parse(JSON.stringify(p.dataset||{}))),v),o={};w.config.parseDate=i.parseDate,w.config.formatDate=i.formatDate,Object.defineProperty(w.config,"enable",{get:function(){return w.config._enable},set:function(e){w.config._enable=he(e)}}),Object.defineProperty(w.config,"disable",{get:function(){return w.config._disable},set:function(e){w.config._disable=he(e)}});var r="time"===i.mode;if(!i.dateFormat&&(i.enableTime||r)){var l=I.defaultConfig.dateFormat||a.dateFormat;o.dateFormat=i.noCalendar||r?"H:i"+(i.enableSeconds?":S":""):l+" H:i"+(i.enableSeconds?":S":"")}if(i.altInput&&(i.enableTime||r)&&!i.altFormat){var s=I.defaultConfig.altFormat||a.altFormat;o.altFormat=i.noCalendar||r?"h:i"+(i.enableSeconds?":S K":" K"):s+" h:i"+(i.enableSeconds?":S":"")+" K"}Object.defineProperty(w.config,"minDate",{get:function(){return w.config._minDate},set:le("min")}),Object.defineProperty(w.config,"maxDate",{get:function(){return w.config._maxDate},set:le("max")});var d=function(e){return function(n){w.config["min"===e?"_minTime":"_maxTime"]=w.parseDate(n,"H:i:S")}};Object.defineProperty(w.config,"minTime",{get:function(){return w.config._minTime},set:d("min")}),Object.defineProperty(w.config,"maxTime",{get:function(){return w.config._maxTime},set:d("max")}),"time"===i.mode&&(w.config.noCalendar=!0,w.config.enableTime=!0);Object.assign(w.config,o,i);for(var u=0;u-1?w.config[m]=c(f[m]).map(T).concat(w.config[m]):void 0===i[m]&&(w.config[m]=f[m])}i.altInputClass||(w.config.altInputClass=ce().className+" "+w.config.altInputClass);De("onParseConfig")}(),se(),function(){if(w.input=ce(),!w.input)return void w.config.errorHandler(new Error("Invalid input element specified"));w.input._type=w.input.type,w.input.type="text",w.input.classList.add("flatpickr-input"),w._input=w.input,w.config.altInput&&(w.altInput=d(w.input.nodeName,w.config.altInputClass),w._input=w.altInput,w.altInput.placeholder=w.input.placeholder,w.altInput.disabled=w.input.disabled,w.altInput.required=w.input.required,w.altInput.tabIndex=w.input.tabIndex,w.altInput.type="text",w.input.setAttribute("type","hidden"),!w.config.static&&w.input.parentNode&&w.input.parentNode.insertBefore(w.altInput,w.input.nextSibling));w.config.allowInput||w._input.setAttribute("readonly","readonly");ve()}(),function(){w.selectedDates=[],w.now=w.parseDate(w.config.now)||new Date;var e=w.config.defaultDate||("INPUT"!==w.input.nodeName&&"TEXTAREA"!==w.input.nodeName||!w.input.placeholder||w.input.value!==w.input.placeholder?w.input.value:null);e&&pe(e,w.config.dateFormat);w._initialDate=w.selectedDates.length>0?w.selectedDates[0]:w.config.minDate&&w.config.minDate.getTime()>w.now.getTime()?w.config.minDate:w.config.maxDate&&w.config.maxDate.getTime()0&&(w.latestSelectedDateObj=w.selectedDates[0]);void 0!==w.config.minTime&&(w.config.minTime=w.parseDate(w.config.minTime,"H:i"));void 0!==w.config.maxTime&&(w.config.maxTime=w.parseDate(w.config.maxTime,"H:i"));w.minDateHasTime=!!w.config.minDate&&(w.config.minDate.getHours()>0||w.config.minDate.getMinutes()>0||w.config.minDate.getSeconds()>0),w.maxDateHasTime=!!w.config.maxDate&&(w.config.maxDate.getHours()>0||w.config.maxDate.getMinutes()>0||w.config.maxDate.getSeconds()>0)}(),w.utils={getDaysInMonth:function(e,n){return void 0===e&&(e=w.currentMonth),void 0===n&&(n=w.currentYear),1===e&&(n%4==0&&n%100!=0||n%400==0)?29:w.l10n.daysInMonth[e]}},w.isMobile||function(){var e=window.document.createDocumentFragment();if(w.calendarContainer=d("div","flatpickr-calendar"),w.calendarContainer.tabIndex=-1,!w.config.noCalendar){if(e.appendChild((w.monthNav=d("div","flatpickr-months"),w.yearElements=[],w.monthElements=[],w.prevMonthNav=d("span","flatpickr-prev-month"),w.prevMonthNav.innerHTML=w.config.prevArrow,w.nextMonthNav=d("span","flatpickr-next-month"),w.nextMonthNav.innerHTML=w.config.nextArrow,V(),Object.defineProperty(w,"_hidePrevMonthArrow",{get:function(){return w.__hidePrevMonthArrow},set:function(e){w.__hidePrevMonthArrow!==e&&(s(w.prevMonthNav,"flatpickr-disabled",e),w.__hidePrevMonthArrow=e)}}),Object.defineProperty(w,"_hideNextMonthArrow",{get:function(){return w.__hideNextMonthArrow},set:function(e){w.__hideNextMonthArrow!==e&&(s(w.nextMonthNav,"flatpickr-disabled",e),w.__hideNextMonthArrow=e)}}),w.currentYearElement=w.yearElements[0],Ce(),w.monthNav)),w.innerContainer=d("div","flatpickr-innerContainer"),w.config.weekNumbers){var n=function(){w.calendarContainer.classList.add("hasWeeks");var e=d("div","flatpickr-weekwrapper");e.appendChild(d("span","flatpickr-weekday",w.l10n.weekAbbreviation));var n=d("div","flatpickr-weeks");return e.appendChild(n),{weekWrapper:e,weekNumbers:n}}(),t=n.weekWrapper,a=n.weekNumbers;w.innerContainer.appendChild(t),w.weekNumbers=a,w.weekWrapper=t}w.rContainer=d("div","flatpickr-rContainer"),w.rContainer.appendChild(z()),w.daysContainer||(w.daysContainer=d("div","flatpickr-days"),w.daysContainer.tabIndex=-1),U(),w.rContainer.appendChild(w.daysContainer),w.innerContainer.appendChild(w.rContainer),e.appendChild(w.innerContainer)}w.config.enableTime&&e.appendChild(function(){w.calendarContainer.classList.add("hasTime"),w.config.noCalendar&&w.calendarContainer.classList.add("noCalendar");var e=E(w.config);w.timeContainer=d("div","flatpickr-time"),w.timeContainer.tabIndex=-1;var n=d("span","flatpickr-time-separator",":"),t=m("flatpickr-hour",{"aria-label":w.l10n.hourAriaLabel});w.hourElement=t.getElementsByTagName("input")[0];var a=m("flatpickr-minute",{"aria-label":w.l10n.minuteAriaLabel});w.minuteElement=a.getElementsByTagName("input")[0],w.hourElement.tabIndex=w.minuteElement.tabIndex=-1,w.hourElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getHours():w.config.time_24hr?e.hours:function(e){switch(e%24){case 0:case 12:return 12;default:return e%12}}(e.hours)),w.minuteElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getMinutes():e.minutes),w.hourElement.setAttribute("step",w.config.hourIncrement.toString()),w.minuteElement.setAttribute("step",w.config.minuteIncrement.toString()),w.hourElement.setAttribute("min",w.config.time_24hr?"0":"1"),w.hourElement.setAttribute("max",w.config.time_24hr?"23":"12"),w.hourElement.setAttribute("maxlength","2"),w.minuteElement.setAttribute("min","0"),w.minuteElement.setAttribute("max","59"),w.minuteElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(t),w.timeContainer.appendChild(n),w.timeContainer.appendChild(a),w.config.time_24hr&&w.timeContainer.classList.add("time24hr");if(w.config.enableSeconds){w.timeContainer.classList.add("hasSeconds");var i=m("flatpickr-second");w.secondElement=i.getElementsByTagName("input")[0],w.secondElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getSeconds():e.seconds),w.secondElement.setAttribute("step",w.minuteElement.getAttribute("step")),w.secondElement.setAttribute("min","0"),w.secondElement.setAttribute("max","59"),w.secondElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(d("span","flatpickr-time-separator",":")),w.timeContainer.appendChild(i)}w.config.time_24hr||(w.amPM=d("span","flatpickr-am-pm",w.l10n.amPM[r((w.latestSelectedDateObj?w.hourElement.value:w.config.defaultHour)>11)]),w.amPM.title=w.l10n.toggleTitle,w.amPM.tabIndex=-1,w.timeContainer.appendChild(w.amPM));return w.timeContainer}());s(w.calendarContainer,"rangeMode","range"===w.config.mode),s(w.calendarContainer,"animate",!0===w.config.animate),s(w.calendarContainer,"multiMonth",w.config.showMonths>1),w.calendarContainer.appendChild(e);var i=void 0!==w.config.appendTo&&void 0!==w.config.appendTo.nodeType;if((w.config.inline||w.config.static)&&(w.calendarContainer.classList.add(w.config.inline?"inline":"static"),w.config.inline&&(!i&&w.element.parentNode?w.element.parentNode.insertBefore(w.calendarContainer,w._input.nextSibling):void 0!==w.config.appendTo&&w.config.appendTo.appendChild(w.calendarContainer)),w.config.static)){var l=d("div","flatpickr-wrapper");w.element.parentNode&&w.element.parentNode.insertBefore(l,w.element),l.appendChild(w.element),w.altInput&&l.appendChild(w.altInput),l.appendChild(w.calendarContainer)}w.config.static||w.config.inline||(void 0!==w.config.appendTo?w.config.appendTo:window.document.body).appendChild(w.calendarContainer)}(),function(){w.config.wrap&&["open","close","toggle","clear"].forEach((function(e){Array.prototype.forEach.call(w.element.querySelectorAll("[data-"+e+"]"),(function(n){return P(n,"click",w[e])}))}));if(w.isMobile)return void function(){var e=w.config.enableTime?w.config.noCalendar?"time":"datetime-local":"date";w.mobileInput=d("input",w.input.className+" flatpickr-mobile"),w.mobileInput.tabIndex=1,w.mobileInput.type=e,w.mobileInput.disabled=w.input.disabled,w.mobileInput.required=w.input.required,w.mobileInput.placeholder=w.input.placeholder,w.mobileFormatStr="datetime-local"===e?"Y-m-d\\TH:i:S":"date"===e?"Y-m-d":"H:i:S",w.selectedDates.length>0&&(w.mobileInput.defaultValue=w.mobileInput.value=w.formatDate(w.selectedDates[0],w.mobileFormatStr));w.config.minDate&&(w.mobileInput.min=w.formatDate(w.config.minDate,"Y-m-d"));w.config.maxDate&&(w.mobileInput.max=w.formatDate(w.config.maxDate,"Y-m-d"));w.input.getAttribute("step")&&(w.mobileInput.step=String(w.input.getAttribute("step")));w.input.type="hidden",void 0!==w.altInput&&(w.altInput.type="hidden");try{w.input.parentNode&&w.input.parentNode.insertBefore(w.mobileInput,w.input.nextSibling)}catch(e){}P(w.mobileInput,"change",(function(e){w.setDate(g(e).value,!1,w.mobileFormatStr),De("onChange"),De("onClose")}))}();var e=l(re,50);w._debouncedChange=l(Y,300),w.daysContainer&&!/iPhone|iPad|iPod/i.test(navigator.userAgent)&&P(w.daysContainer,"mouseover",(function(e){"range"===w.config.mode&&oe(g(e))}));P(w._input,"keydown",ie),void 0!==w.calendarContainer&&P(w.calendarContainer,"keydown",ie);w.config.inline||w.config.static||P(window,"resize",e);void 0!==window.ontouchstart?P(window.document,"touchstart",X):P(window.document,"mousedown",X);P(window.document,"focus",X,{capture:!0}),!0===w.config.clickOpens&&(P(w._input,"focus",w.open),P(w._input,"click",w.open));void 0!==w.daysContainer&&(P(w.monthNav,"click",xe),P(w.monthNav,["keyup","increment"],N),P(w.daysContainer,"click",me));if(void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement){var n=function(e){return g(e).select()};P(w.timeContainer,["increment"],_),P(w.timeContainer,"blur",_,{capture:!0}),P(w.timeContainer,"click",H),P([w.hourElement,w.minuteElement],["focus","click"],n),void 0!==w.secondElement&&P(w.secondElement,"focus",(function(){return w.secondElement&&w.secondElement.select()})),void 0!==w.amPM&&P(w.amPM,"click",(function(e){_(e)}))}w.config.allowInput&&P(w._input,"blur",ae)}(),(w.selectedDates.length||w.config.noCalendar)&&(w.config.enableTime&&F(w.config.noCalendar?w.latestSelectedDateObj:void 0),ye(!1)),S();var n=/^((?!chrome|android).)*safari/i.test(navigator.userAgent);!w.isMobile&&n&&de(),De("onReady")}(),w}function T(e,n){for(var t=Array.prototype.slice.call(e).filter((function(e){return e instanceof HTMLElement})),a=[],i=0;i 1) + return ""; + return "er"; + }, + rangeSeparator: " au ", + weekAbbreviation: "Sem", + scrollTitle: "Défiler pour augmenter la valeur", + toggleTitle: "Cliquer pour basculer", + time_24hr: true, + }; + fp.l10ns.fr = French; + var fr = fp.l10ns; + + exports.French = French; + exports.default = fr; + + Object.defineProperty(exports, '__esModule', { value: true }); + +}))); diff --git a/public/assets/vendor/leaflet/LICENSE b/public/assets/vendor/leaflet/LICENSE new file mode 100644 index 0000000..bcb3ba1 --- /dev/null +++ b/public/assets/vendor/leaflet/LICENSE @@ -0,0 +1,26 @@ +BSD 2-Clause License + +Copyright (c) 2010-2023, Volodymyr Agafonkin +Copyright (c) 2010-2011, CloudMade +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/public/assets/vendor/leaflet/images/layers-2x.png b/public/assets/vendor/leaflet/images/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/public/assets/vendor/leaflet/images/layers-2x.png differ diff --git a/public/assets/vendor/leaflet/images/layers.png b/public/assets/vendor/leaflet/images/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/public/assets/vendor/leaflet/images/layers.png differ diff --git a/public/assets/vendor/leaflet/images/marker-icon-2x.png b/public/assets/vendor/leaflet/images/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/public/assets/vendor/leaflet/images/marker-icon-2x.png differ diff --git a/public/assets/vendor/leaflet/images/marker-icon.png b/public/assets/vendor/leaflet/images/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/public/assets/vendor/leaflet/images/marker-icon.png differ diff --git a/public/assets/vendor/leaflet/images/marker-shadow.png b/public/assets/vendor/leaflet/images/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/public/assets/vendor/leaflet/images/marker-shadow.png differ diff --git a/public/assets/vendor/leaflet/leaflet.css b/public/assets/vendor/leaflet/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/public/assets/vendor/leaflet/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/public/assets/vendor/leaflet/leaflet.js b/public/assets/vendor/leaflet/leaflet.js new file mode 100644 index 0000000..976177e --- /dev/null +++ b/public/assets/vendor/leaflet/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4+v1.d15112c, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1code{color:inherit}kbd{padding:.25rem .5rem;font-size:var(--tblr-font-size-h5);color:var(--tblr-text-secondary-dark);background-color:var(--tblr-code-bg);border-radius:4px}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:var(--tblr-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;line-height:inherit;font-size:1.5rem}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:.875rem;font-weight:var(--tblr-font-weight-normal)}.display-1{font-weight:300;line-height:1.2;font-size:5rem}.display-2{font-weight:300;line-height:1.2;font-size:4.5rem}.display-3{font-weight:300;line-height:1.2;font-size:4rem}.display-4{font-weight:300;line-height:1.2;font-size:3.5rem}.display-5{font-weight:300;line-height:1.2;font-size:3rem}.display-6{font-weight:300;line-height:1.2;font-size:2rem}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:.875rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#4b5563}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--tblr-body-bg);border:var(--tblr-border-width) solid var(--tblr-border-color);border-radius:var(--tblr-border-radius);box-shadow:var(--tblr-box-shadow-sm);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--tblr-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--tblr-gutter-x:calc(var(--tblr-page-padding) * 2);--tblr-gutter-y:0;width:100%;padding-right:calc(var(--tblr-gutter-x) * .5);padding-left:calc(var(--tblr-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--tblr-breakpoint-xs:0;--tblr-breakpoint-sm:576px;--tblr-breakpoint-md:768px;--tblr-breakpoint-lg:992px;--tblr-breakpoint-xl:1200px;--tblr-breakpoint-xxl:1400px}.row{--tblr-gutter-x:var(--tblr-page-padding);--tblr-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--tblr-gutter-y));margin-right:calc(-.5 * var(--tblr-gutter-x));margin-left:calc(-.5 * var(--tblr-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--tblr-gutter-x) * .5);padding-left:calc(var(--tblr-gutter-x) * .5);margin-top:var(--tblr-gutter-y)}.grid{display:grid;grid-template-rows:repeat(var(--tblr-rows,1),1fr);grid-template-columns:repeat(var(--tblr-columns,12),1fr);gap:var(--tblr-gap,var(--tblr-page-padding))}.grid .g-col-1{grid-column:auto/span 1}.grid .g-col-2{grid-column:auto/span 2}.grid .g-col-3{grid-column:auto/span 3}.grid .g-col-4{grid-column:auto/span 4}.grid .g-col-5{grid-column:auto/span 5}.grid .g-col-6{grid-column:auto/span 6}.grid .g-col-7{grid-column:auto/span 7}.grid .g-col-8{grid-column:auto/span 8}.grid .g-col-9{grid-column:auto/span 9}.grid .g-col-10{grid-column:auto/span 10}.grid .g-col-11{grid-column:auto/span 11}.grid .g-col-12{grid-column:auto/span 12}.grid .g-start-1{grid-column-start:1}.grid .g-start-2{grid-column-start:2}.grid .g-start-3{grid-column-start:3}.grid .g-start-4{grid-column-start:4}.grid .g-start-5{grid-column-start:5}.grid .g-start-6{grid-column-start:6}.grid .g-start-7{grid-column-start:7}.grid .g-start-8{grid-column-start:8}.grid .g-start-9{grid-column-start:9}.grid .g-start-10{grid-column-start:10}.grid .g-start-11{grid-column-start:11}@media (min-width:576px){.grid .g-col-sm-1{grid-column:auto/span 1}.grid .g-col-sm-2{grid-column:auto/span 2}.grid .g-col-sm-3{grid-column:auto/span 3}.grid .g-col-sm-4{grid-column:auto/span 4}.grid .g-col-sm-5{grid-column:auto/span 5}.grid .g-col-sm-6{grid-column:auto/span 6}.grid .g-col-sm-7{grid-column:auto/span 7}.grid .g-col-sm-8{grid-column:auto/span 8}.grid .g-col-sm-9{grid-column:auto/span 9}.grid .g-col-sm-10{grid-column:auto/span 10}.grid .g-col-sm-11{grid-column:auto/span 11}.grid .g-col-sm-12{grid-column:auto/span 12}.grid .g-start-sm-1{grid-column-start:1}.grid .g-start-sm-2{grid-column-start:2}.grid .g-start-sm-3{grid-column-start:3}.grid .g-start-sm-4{grid-column-start:4}.grid .g-start-sm-5{grid-column-start:5}.grid .g-start-sm-6{grid-column-start:6}.grid .g-start-sm-7{grid-column-start:7}.grid .g-start-sm-8{grid-column-start:8}.grid .g-start-sm-9{grid-column-start:9}.grid .g-start-sm-10{grid-column-start:10}.grid .g-start-sm-11{grid-column-start:11}}@media (min-width:768px){.grid .g-col-md-1{grid-column:auto/span 1}.grid .g-col-md-2{grid-column:auto/span 2}.grid .g-col-md-3{grid-column:auto/span 3}.grid .g-col-md-4{grid-column:auto/span 4}.grid .g-col-md-5{grid-column:auto/span 5}.grid .g-col-md-6{grid-column:auto/span 6}.grid .g-col-md-7{grid-column:auto/span 7}.grid .g-col-md-8{grid-column:auto/span 8}.grid .g-col-md-9{grid-column:auto/span 9}.grid .g-col-md-10{grid-column:auto/span 10}.grid .g-col-md-11{grid-column:auto/span 11}.grid .g-col-md-12{grid-column:auto/span 12}.grid .g-start-md-1{grid-column-start:1}.grid .g-start-md-2{grid-column-start:2}.grid .g-start-md-3{grid-column-start:3}.grid .g-start-md-4{grid-column-start:4}.grid .g-start-md-5{grid-column-start:5}.grid .g-start-md-6{grid-column-start:6}.grid .g-start-md-7{grid-column-start:7}.grid .g-start-md-8{grid-column-start:8}.grid .g-start-md-9{grid-column-start:9}.grid .g-start-md-10{grid-column-start:10}.grid .g-start-md-11{grid-column-start:11}}@media (min-width:992px){.grid .g-col-lg-1{grid-column:auto/span 1}.grid .g-col-lg-2{grid-column:auto/span 2}.grid .g-col-lg-3{grid-column:auto/span 3}.grid .g-col-lg-4{grid-column:auto/span 4}.grid .g-col-lg-5{grid-column:auto/span 5}.grid .g-col-lg-6{grid-column:auto/span 6}.grid .g-col-lg-7{grid-column:auto/span 7}.grid .g-col-lg-8{grid-column:auto/span 8}.grid .g-col-lg-9{grid-column:auto/span 9}.grid .g-col-lg-10{grid-column:auto/span 10}.grid .g-col-lg-11{grid-column:auto/span 11}.grid .g-col-lg-12{grid-column:auto/span 12}.grid .g-start-lg-1{grid-column-start:1}.grid .g-start-lg-2{grid-column-start:2}.grid .g-start-lg-3{grid-column-start:3}.grid .g-start-lg-4{grid-column-start:4}.grid .g-start-lg-5{grid-column-start:5}.grid .g-start-lg-6{grid-column-start:6}.grid .g-start-lg-7{grid-column-start:7}.grid .g-start-lg-8{grid-column-start:8}.grid .g-start-lg-9{grid-column-start:9}.grid .g-start-lg-10{grid-column-start:10}.grid .g-start-lg-11{grid-column-start:11}}@media (min-width:1200px){.grid .g-col-xl-1{grid-column:auto/span 1}.grid .g-col-xl-2{grid-column:auto/span 2}.grid .g-col-xl-3{grid-column:auto/span 3}.grid .g-col-xl-4{grid-column:auto/span 4}.grid .g-col-xl-5{grid-column:auto/span 5}.grid .g-col-xl-6{grid-column:auto/span 6}.grid .g-col-xl-7{grid-column:auto/span 7}.grid .g-col-xl-8{grid-column:auto/span 8}.grid .g-col-xl-9{grid-column:auto/span 9}.grid .g-col-xl-10{grid-column:auto/span 10}.grid .g-col-xl-11{grid-column:auto/span 11}.grid .g-col-xl-12{grid-column:auto/span 12}.grid .g-start-xl-1{grid-column-start:1}.grid .g-start-xl-2{grid-column-start:2}.grid .g-start-xl-3{grid-column-start:3}.grid .g-start-xl-4{grid-column-start:4}.grid .g-start-xl-5{grid-column-start:5}.grid .g-start-xl-6{grid-column-start:6}.grid .g-start-xl-7{grid-column-start:7}.grid .g-start-xl-8{grid-column-start:8}.grid .g-start-xl-9{grid-column-start:9}.grid .g-start-xl-10{grid-column-start:10}.grid .g-start-xl-11{grid-column-start:11}}@media (min-width:1400px){.grid .g-col-xxl-1{grid-column:auto/span 1}.grid .g-col-xxl-2{grid-column:auto/span 2}.grid .g-col-xxl-3{grid-column:auto/span 3}.grid .g-col-xxl-4{grid-column:auto/span 4}.grid .g-col-xxl-5{grid-column:auto/span 5}.grid .g-col-xxl-6{grid-column:auto/span 6}.grid .g-col-xxl-7{grid-column:auto/span 7}.grid .g-col-xxl-8{grid-column:auto/span 8}.grid .g-col-xxl-9{grid-column:auto/span 9}.grid .g-col-xxl-10{grid-column:auto/span 10}.grid .g-col-xxl-11{grid-column:auto/span 11}.grid .g-col-xxl-12{grid-column:auto/span 12}.grid .g-start-xxl-1{grid-column-start:1}.grid .g-start-xxl-2{grid-column-start:2}.grid .g-start-xxl-3{grid-column-start:3}.grid .g-start-xxl-4{grid-column-start:4}.grid .g-start-xxl-5{grid-column-start:5}.grid .g-start-xxl-6{grid-column-start:6}.grid .g-start-xxl-7{grid-column-start:7}.grid .g-start-xxl-8{grid-column-start:8}.grid .g-start-xxl-9{grid-column-start:9}.grid .g-start-xxl-10{grid-column-start:10}.grid .g-start-xxl-11{grid-column-start:11}}.col{flex:1 0 0}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--tblr-gutter-x:0}.g-0,.gy-0{--tblr-gutter-y:0}.g-1,.gx-1{--tblr-gutter-x:0.25rem}.g-1,.gy-1{--tblr-gutter-y:0.25rem}.g-2,.gx-2{--tblr-gutter-x:0.5rem}.g-2,.gy-2{--tblr-gutter-y:0.5rem}.g-3,.gx-3{--tblr-gutter-x:1rem}.g-3,.gy-3{--tblr-gutter-y:1rem}.g-4,.gx-4{--tblr-gutter-x:1.5rem}.g-4,.gy-4{--tblr-gutter-y:1.5rem}.g-5,.gx-5{--tblr-gutter-x:2rem}.g-5,.gy-5{--tblr-gutter-y:2rem}.g-6,.gx-6{--tblr-gutter-x:2.5rem}.g-6,.gy-6{--tblr-gutter-y:2.5rem}@media (min-width:576px){.col-sm{flex:1 0 0}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--tblr-gutter-x:0}.g-sm-0,.gy-sm-0{--tblr-gutter-y:0}.g-sm-1,.gx-sm-1{--tblr-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--tblr-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--tblr-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--tblr-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--tblr-gutter-x:1rem}.g-sm-3,.gy-sm-3{--tblr-gutter-y:1rem}.g-sm-4,.gx-sm-4{--tblr-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--tblr-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--tblr-gutter-x:2rem}.g-sm-5,.gy-sm-5{--tblr-gutter-y:2rem}.g-sm-6,.gx-sm-6{--tblr-gutter-x:2.5rem}.g-sm-6,.gy-sm-6{--tblr-gutter-y:2.5rem}}@media (min-width:768px){.col-md{flex:1 0 0}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--tblr-gutter-x:0}.g-md-0,.gy-md-0{--tblr-gutter-y:0}.g-md-1,.gx-md-1{--tblr-gutter-x:0.25rem}.g-md-1,.gy-md-1{--tblr-gutter-y:0.25rem}.g-md-2,.gx-md-2{--tblr-gutter-x:0.5rem}.g-md-2,.gy-md-2{--tblr-gutter-y:0.5rem}.g-md-3,.gx-md-3{--tblr-gutter-x:1rem}.g-md-3,.gy-md-3{--tblr-gutter-y:1rem}.g-md-4,.gx-md-4{--tblr-gutter-x:1.5rem}.g-md-4,.gy-md-4{--tblr-gutter-y:1.5rem}.g-md-5,.gx-md-5{--tblr-gutter-x:2rem}.g-md-5,.gy-md-5{--tblr-gutter-y:2rem}.g-md-6,.gx-md-6{--tblr-gutter-x:2.5rem}.g-md-6,.gy-md-6{--tblr-gutter-y:2.5rem}}@media (min-width:992px){.col-lg{flex:1 0 0}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--tblr-gutter-x:0}.g-lg-0,.gy-lg-0{--tblr-gutter-y:0}.g-lg-1,.gx-lg-1{--tblr-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--tblr-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--tblr-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--tblr-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--tblr-gutter-x:1rem}.g-lg-3,.gy-lg-3{--tblr-gutter-y:1rem}.g-lg-4,.gx-lg-4{--tblr-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--tblr-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--tblr-gutter-x:2rem}.g-lg-5,.gy-lg-5{--tblr-gutter-y:2rem}.g-lg-6,.gx-lg-6{--tblr-gutter-x:2.5rem}.g-lg-6,.gy-lg-6{--tblr-gutter-y:2.5rem}}@media (min-width:1200px){.col-xl{flex:1 0 0}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--tblr-gutter-x:0}.g-xl-0,.gy-xl-0{--tblr-gutter-y:0}.g-xl-1,.gx-xl-1{--tblr-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--tblr-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--tblr-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--tblr-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--tblr-gutter-x:1rem}.g-xl-3,.gy-xl-3{--tblr-gutter-y:1rem}.g-xl-4,.gx-xl-4{--tblr-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--tblr-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--tblr-gutter-x:2rem}.g-xl-5,.gy-xl-5{--tblr-gutter-y:2rem}.g-xl-6,.gx-xl-6{--tblr-gutter-x:2.5rem}.g-xl-6,.gy-xl-6{--tblr-gutter-y:2.5rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--tblr-gutter-x:0}.g-xxl-0,.gy-xxl-0{--tblr-gutter-y:0}.g-xxl-1,.gx-xxl-1{--tblr-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--tblr-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--tblr-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--tblr-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--tblr-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--tblr-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--tblr-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--tblr-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--tblr-gutter-x:2rem}.g-xxl-5,.gy-xxl-5{--tblr-gutter-y:2rem}.g-xxl-6,.gx-xxl-6{--tblr-gutter-x:2.5rem}.g-xxl-6,.gy-xxl-6{--tblr-gutter-y:2.5rem}}.markdown>table,.table{--tblr-table-color-type:initial;--tblr-table-bg-type:initial;--tblr-table-color-state:initial;--tblr-table-bg-state:initial;--tblr-table-color:inherit;--tblr-table-bg:transparent;--tblr-table-border-color:var(--tblr-border-color-translucent);--tblr-table-accent-bg:transparent;--tblr-table-striped-color:inherit;--tblr-table-striped-bg:var(--tblr-bg-surface-tertiary);--tblr-table-active-color:inherit;--tblr-table-active-bg:var(--tblr-active-bg);--tblr-table-hover-color:inherit;--tblr-table-hover-bg:rgba(var(--tblr-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--tblr-table-border-color)}.markdown>table>:not(caption)>*>*,.table>:not(caption)>*>*{padding:.75rem .75rem;color:var(--tblr-table-color-state,var(--tblr-table-color-type,var(--tblr-table-color)));background-color:var(--tblr-table-bg);border-bottom-width:var(--tblr-border-width);box-shadow:inset 0 0 0 9999px var(--tblr-table-bg-state,var(--tblr-table-bg-type,var(--tblr-table-accent-bg)))}.markdown>table>tbody,.table>tbody{vertical-align:inherit}.markdown>table>thead,.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--tblr-border-width) * 2) solid var(--tblr-border-color-translucent)}.caption-top{caption-side:top}.markdown>table>:not(caption)>*>*,.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.markdown>table>:not(caption)>*,.table-bordered>:not(caption)>*{border-width:var(--tblr-border-width) 0}.markdown>table>:not(caption)>*>*,.table-bordered>:not(caption)>*>*{border-width:0 var(--tblr-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(2n)>*{--tblr-table-color-type:var(--tblr-table-striped-color);--tblr-table-bg-type:var(--tblr-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--tblr-table-color-type:var(--tblr-table-striped-color);--tblr-table-bg-type:var(--tblr-table-striped-bg)}.table-active{--tblr-table-color-state:var(--tblr-table-active-color);--tblr-table-bg-state:var(--tblr-table-active-bg)}.table-hover>tbody>tr:hover>*{--tblr-table-color-state:var(--tblr-table-hover-color);--tblr-table-bg-state:var(--tblr-table-hover-bg)}.table-primary{--tblr-table-color:#1f2937;--tblr-table-bg:rgb(205.2, 226.2, 245.8);--tblr-table-border-color:rgb(170.36, 189.16, 207.64);--tblr-table-striped-bg:rgb(196.49, 216.94, 236.26);--tblr-table-striped-color:#1f2937;--tblr-table-active-bg:rgb(187.78, 207.68, 226.72);--tblr-table-active-color:#f9fafb;--tblr-table-hover-bg:rgb(192.135, 212.31, 231.49);--tblr-table-hover-color:#1f2937;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-secondary{--tblr-table-color:#1f2937;--tblr-table-bg:rgb(225.4, 226.8, 229.6);--tblr-table-border-color:rgb(186.52, 189.64, 194.68);--tblr-table-striped-bg:rgb(215.68, 217.51, 220.87);--tblr-table-striped-color:#1f2937;--tblr-table-active-bg:rgb(205.96, 208.22, 212.14);--tblr-table-active-color:#1f2937;--tblr-table-hover-bg:rgb(210.82, 212.865, 216.505);--tblr-table-hover-color:#1f2937;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-success{--tblr-table-color:#1f2937;--tblr-table-bg:rgb(213.4, 239.8, 217.6);--tblr-table-border-color:rgb(176.92, 200.04, 185.08);--tblr-table-striped-bg:rgb(204.28, 229.86, 209.47);--tblr-table-striped-color:#1f2937;--tblr-table-active-bg:rgb(195.16, 219.92, 201.34);--tblr-table-active-color:#1f2937;--tblr-table-hover-bg:rgb(199.72, 224.89, 205.405);--tblr-table-hover-color:#1f2937;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-info{--tblr-table-color:#1f2937;--tblr-table-bg:rgb(217.2, 234.6, 249);--tblr-table-border-color:rgb(179.96, 195.88, 210.2);--tblr-table-striped-bg:rgb(207.89, 224.92, 239.3);--tblr-table-striped-color:#1f2937;--tblr-table-active-bg:rgb(198.58, 215.24, 229.6);--tblr-table-active-color:#1f2937;--tblr-table-hover-bg:rgb(203.235, 220.08, 234.45);--tblr-table-hover-color:#1f2937;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-warning{--tblr-table-color:#1f2937;--tblr-table-bg:rgb(253, 235.8, 204);--tblr-table-border-color:rgb(208.6, 196.84, 174.2);--tblr-table-striped-bg:rgb(241.9, 226.06, 196.55);--tblr-table-striped-color:#1f2937;--tblr-table-active-bg:rgb(230.8, 216.32, 189.1);--tblr-table-active-color:#1f2937;--tblr-table-hover-bg:rgb(236.35, 221.19, 192.825);--tblr-table-hover-color:#1f2937;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-danger{--tblr-table-color:#1f2937;--tblr-table-bg:rgb(246.8, 215.4, 215.4);--tblr-table-border-color:rgb(203.64, 180.52, 183.32);--tblr-table-striped-bg:rgb(236.01, 206.68, 207.38);--tblr-table-striped-color:#1f2937;--tblr-table-active-bg:rgb(225.22, 197.96, 199.36);--tblr-table-active-color:#f9fafb;--tblr-table-hover-bg:rgb(230.615, 202.32, 203.37);--tblr-table-hover-color:#1f2937;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-light{--tblr-table-color:#1f2937;--tblr-table-bg:#f9fafb;--tblr-table-border-color:rgb(205.4, 208.2, 211.8);--tblr-table-striped-bg:rgb(238.1, 239.55, 241.2);--tblr-table-striped-color:#1f2937;--tblr-table-active-bg:rgb(227.2, 229.1, 231.4);--tblr-table-active-color:#1f2937;--tblr-table-hover-bg:rgb(232.65, 234.325, 236.3);--tblr-table-hover-color:#1f2937;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-dark{--tblr-table-color:#f9fafb;--tblr-table-bg:#1f2937;--tblr-table-border-color:rgb(74.6, 82.8, 94.2);--tblr-table-striped-bg:rgb(41.9, 51.45, 64.8);--tblr-table-striped-color:#f9fafb;--tblr-table-active-bg:rgb(52.8, 61.9, 74.6);--tblr-table-active-color:#f9fafb;--tblr-table-hover-bg:rgb(47.35, 56.675, 69.7);--tblr-table-hover-color:#f9fafb;color:var(--tblr-table-color);border-color:var(--tblr-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem;font-size:.875rem;font-weight:var(--tblr-font-weight-medium)}.col-form-label{padding-top:calc(.5625rem + var(--tblr-border-width));padding-bottom:calc(.5625rem + var(--tblr-border-width));margin-bottom:0;font-size:inherit;font-weight:var(--tblr-font-weight-medium);line-height:1.25rem}.col-form-label-lg{padding-top:calc(.6875rem + var(--tblr-border-width));padding-bottom:calc(.6875rem + var(--tblr-border-width));font-size:1rem}.col-form-label-sm{padding-top:calc(.3125rem + var(--tblr-border-width));padding-bottom:calc(.3125rem + var(--tblr-border-width));font-size:.75rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--tblr-secondary-color)}.form-control{display:block;width:100%;padding:.5625rem 1rem;font-family:var(--tblr-body-font-family);font-size:.875rem;font-weight:400;line-height:1.25rem;color:var(--tblr-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--tblr-bg-forms);background-clip:padding-box;border:var(--tblr-border-width) solid var(--tblr-border-color);border-radius:var(--tblr-border-radius);box-shadow:var(--tblr-shadow-input);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--tblr-body-color);background-color:var(--tblr-bg-forms);border-color:rgb(130.5,183,232);outline:0;box-shadow:var(--tblr-shadow-input),0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.25rem;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--tblr-tertiary);opacity:1}.form-control::placeholder{color:var(--tblr-tertiary);opacity:1}.form-control:disabled{background-color:var(--tblr-bg-surface-secondary);opacity:1}.form-control::file-selector-button{padding:.5625rem 1rem;margin:-.5625rem -1rem;margin-inline-end:1rem;color:var(--tblr-body-color);background-color:var(--tblr-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--tblr-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--tblr-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.5625rem 0;margin-bottom:0;line-height:1.25rem;color:var(--tblr-body-color);background-color:transparent;border:solid transparent;border-width:var(--tblr-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.25rem + .625rem + calc(var(--tblr-border-width) * 2));padding:.3125rem .5rem;font-size:.75rem;border-radius:var(--tblr-border-radius-sm)}.form-control-sm::file-selector-button{padding:.3125rem .5rem;margin:-.3125rem -.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.25rem + 1.375rem + calc(var(--tblr-border-width) * 2));padding:.6875rem 1.5rem;font-size:1rem;border-radius:var(--tblr-border-radius-lg)}.form-control-lg::file-selector-button{padding:.6875rem 1.5rem;margin:-.6875rem -1.5rem;margin-inline-end:1.5rem}textarea.form-control{min-height:calc(1.25rem + 1.125rem + calc(var(--tblr-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.25rem + .625rem + calc(var(--tblr-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.25rem + 1.375rem + calc(var(--tblr-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.25rem + 1.125rem + calc(var(--tblr-border-width) * 2));padding:.5625rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--tblr-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--tblr-border-radius)}.form-control-color.form-control-sm{height:calc(1.25rem + .625rem + calc(var(--tblr-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.25rem + 1.375rem + calc(var(--tblr-border-width) * 2))}.form-select{--tblr-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.5625rem 3rem .5625rem 1rem;font-family:var(--tblr-body-font-family);font-size:.875rem;font-weight:400;line-height:1.25rem;color:var(--tblr-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--tblr-bg-forms);background-image:var(--tblr-form-select-bg-img),var(--tblr-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right 1rem center;background-size:16px 12px;border:var(--tblr-border-width) solid var(--tblr-border-color);border-radius:var(--tblr-border-radius);box-shadow:var(--tblr-shadow-input);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:rgb(130.5,183,232);outline:0;box-shadow:var(--tblr-shadow-input),0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:1rem;background-image:none}.form-select:disabled{background-color:var(--tblr-bg-surface-secondary)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--tblr-body-color)}.form-select-sm{padding-top:.3125rem;padding-bottom:.3125rem;padding-left:.5rem;font-size:.75rem;border-radius:var(--tblr-border-radius-sm)}.form-select-lg{padding-top:.6875rem;padding-bottom:.6875rem;padding-left:1.5rem;font-size:1rem;border-radius:var(--tblr-border-radius-lg)}[data-bs-theme=dark] .form-select,body[data-bs-theme=dark] [data-bs-theme=light] .form-select{--tblr-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23e5e7eb' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.25rem;padding-left:2rem;margin-bottom:.75rem}.form-check .form-check-input{float:left;margin-left:-2rem}.form-check-reverse{padding-right:2rem;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-2rem;margin-left:0}.form-check-input{--tblr-form-check-bg:var(--tblr-bg-forms);flex-shrink:0;width:1.25rem;height:1.25rem;margin-top:.0892857143rem;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--tblr-form-check-bg);background-image:var(--tblr-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent);-webkit-print-color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:var(--tblr-border-radius)}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:rgb(130.5,183,232);outline:0;box-shadow:0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-check-input:checked{background-color:var(--tblr-primary);border-color:var(--tblr-border-color-translucent)}.form-check-input:checked[type=checkbox]{--tblr-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3e%3cpath fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8.5l2.5 2.5l5.5 -5.5'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--tblr-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3ccircle r='3' fill='%23ffffff' cx='8' cy='8' /%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:var(--tblr-primary);border-color:var(--tblr-primary);--tblr-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.7}.form-switch{padding-left:2.5rem}.form-switch .form-check-input{--tblr-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23e5e7eb'/%3e%3c/svg%3e");width:2rem;margin-left:-2.5rem;background-image:var(--tblr-form-switch-bg);background-position:left center;border-radius:2rem;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--tblr-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgb%28130.5, 183, 232%29'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--tblr-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5rem;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5rem;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.4}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--tblr-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.25rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #f9fafb,0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #f9fafb,0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.375rem;-webkit-appearance:none;appearance:none;background-color:var(--tblr-primary);border:2px var(--tblr-border-style) #fff;border-radius:1rem;box-shadow:0 .1rem .25rem rgba(0,0,0,.1);-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:rgb(180.3,211.8,241.2)}.form-range::-webkit-slider-runnable-track{width:100%;height:.25rem;color:transparent;cursor:pointer;background-color:var(--tblr-border-color);border-color:transparent;border-radius:1rem;box-shadow:var(--tblr-box-shadow-inset)}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:var(--tblr-primary);border:2px var(--tblr-border-style) #fff;border-radius:1rem;box-shadow:0 .1rem .25rem rgba(0,0,0,.1);-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:rgb(180.3,211.8,241.2)}.form-range::-moz-range-track{width:100%;height:.25rem;color:transparent;cursor:pointer;background-color:var(--tblr-border-color);border-color:transparent;border-radius:1rem;box-shadow:var(--tblr-box-shadow-inset)}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--tblr-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--tblr-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--tblr-border-width) * 2));min-height:calc(3.5rem + calc(var(--tblr-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;max-width:100%;height:100%;padding:1rem 1rem;overflow:hidden;color:rgba(var(--tblr-body-color-rgb),.65);text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--tblr-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem 1rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder),.form-floating>.form-control:not(:-moz-placeholder){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem;padding-left:1rem}.form-floating>.form-control:not(:-moz-placeholder)~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>textarea:not(:-moz-placeholder)~label::after{position:absolute;inset:1rem 0.5rem;z-index:-1;height:1.5em;content:"";background-color:var(--tblr-bg-forms);border-radius:var(--tblr-border-radius)}.form-floating>textarea:focus~label::after,.form-floating>textarea:not(:placeholder-shown)~label::after{position:absolute;inset:1rem 0.5rem;z-index:-1;height:1.5em;content:"";background-color:var(--tblr-bg-forms);border-radius:var(--tblr-border-radius)}.form-floating>textarea:disabled~label::after{background-color:var(--tblr-bg-surface-secondary)}.form-floating>.form-control-plaintext~label{border-width:var(--tblr-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#4b5563}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.5625rem 1rem;font-size:.875rem;font-weight:400;line-height:1.25rem;color:var(--tblr-gray-500);text-align:center;white-space:nowrap;background-color:var(--tblr-bg-surface-secondary);border:var(--tblr-border-width) solid var(--tblr-border-color);border-radius:var(--tblr-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.6875rem 1.5rem;font-size:1rem;border-radius:var(--tblr-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.3125rem .5rem;font-size:.75rem;border-radius:var(--tblr-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:4rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(-1 * var(--tblr-border-width));border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--tblr-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:var(--tblr-spacer-1) var(--tblr-spacer-3);margin-top:.1rem;font-size:.765625rem;color:#fff;background-color:var(--tblr-success);border-radius:var(--tblr-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--tblr-form-valid-border-color);padding-right:2.375rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%232fb344' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='20 6 9 17 4 12'%3e%3c/polyline%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right 1.53125rem center;background-size:1.8125rem 1.8125rem}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--tblr-form-valid-border-color);box-shadow:var(--tblr-shadow-input),0 0 0 .25rem rgba(var(--tblr-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:2.375rem;background-position:top 1.53125rem right 1.53125rem}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--tblr-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--tblr-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%232fb344' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='20 6 9 17 4 12'%3e%3c/polyline%3e%3c/svg%3e");padding-right:5.5rem;background-position:right 1rem center,center right 3rem;background-size:16px 12px,1.8125rem 1.8125rem}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--tblr-form-valid-border-color);box-shadow:var(--tblr-shadow-input),0 0 0 .25rem rgba(var(--tblr-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:5.375rem}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--tblr-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--tblr-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--tblr-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--tblr-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--tblr-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:var(--tblr-spacer-1) var(--tblr-spacer-3);margin-top:.1rem;font-size:.765625rem;color:#fff;background-color:var(--tblr-danger);border-radius:var(--tblr-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--tblr-form-invalid-border-color);padding-right:2.375rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23d63939' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cline x1='18' y1='6' x2='6' y2='18'%3e%3c/line%3e%3cline x1='6' y1='6' x2='18' y2='18'%3e%3c/line%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right 1.53125rem center;background-size:1.8125rem 1.8125rem}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--tblr-form-invalid-border-color);box-shadow:var(--tblr-shadow-input),0 0 0 .25rem rgba(var(--tblr-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:2.375rem;background-position:top 1.53125rem right 1.53125rem}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--tblr-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--tblr-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23d63939' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cline x1='18' y1='6' x2='6' y2='18'%3e%3c/line%3e%3cline x1='6' y1='6' x2='18' y2='18'%3e%3c/line%3e%3c/svg%3e");padding-right:5.5rem;background-position:right 1rem center,center right 3rem;background-size:16px 12px,1.8125rem 1.8125rem}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--tblr-form-invalid-border-color);box-shadow:var(--tblr-shadow-input),0 0 0 .25rem rgba(var(--tblr-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:5.375rem}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--tblr-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--tblr-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--tblr-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--tblr-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--tblr-btn-padding-x:1rem;--tblr-btn-padding-y:0.5625rem;--tblr-btn-font-family:var(--tblr-body-font-family);--tblr-btn-font-size:0.875rem;--tblr-btn-font-weight:var(--tblr-font-weight-medium);--tblr-btn-line-height:1.25rem;--tblr-btn-color:var(--tblr-body-color);--tblr-btn-bg:transparent;--tblr-btn-border-width:var(--tblr-border-width);--tblr-btn-border-color:transparent;--tblr-btn-border-radius:var(--tblr-border-radius);--tblr-btn-hover-border-color:transparent;--tblr-btn-box-shadow:var(--tblr-shadow-input);--tblr-btn-disabled-opacity:0.4;--tblr-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--tblr-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--tblr-btn-padding-y) var(--tblr-btn-padding-x);font-family:var(--tblr-btn-font-family);font-size:var(--tblr-btn-font-size);font-weight:var(--tblr-btn-font-weight);line-height:var(--tblr-btn-line-height);color:var(--tblr-btn-color);text-align:center;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--tblr-btn-border-width) solid var(--tblr-btn-border-color);border-radius:var(--tblr-btn-border-radius);background-color:var(--tblr-btn-bg);box-shadow:var(--tblr-btn-box-shadow);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--tblr-btn-hover-color);text-decoration:none;background-color:var(--tblr-btn-hover-bg);border-color:var(--tblr-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--tblr-btn-color);background-color:var(--tblr-btn-bg);border-color:var(--tblr-btn-border-color)}.btn:focus-visible{color:var(--tblr-btn-hover-color);background-color:var(--tblr-btn-hover-bg);border-color:var(--tblr-btn-hover-border-color);outline:0;box-shadow:var(--tblr-btn-box-shadow),var(--tblr-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--tblr-btn-hover-border-color);outline:0;box-shadow:var(--tblr-btn-box-shadow),var(--tblr-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--tblr-btn-active-color);background-color:var(--tblr-btn-active-bg);border-color:var(--tblr-btn-active-border-color);box-shadow:var(--tblr-btn-active-shadow)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--tblr-btn-active-shadow),var(--tblr-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--tblr-btn-active-shadow),var(--tblr-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--tblr-btn-disabled-color);pointer-events:none;background-color:var(--tblr-btn-disabled-bg);border-color:var(--tblr-btn-disabled-border-color);opacity:var(--tblr-btn-disabled-opacity);box-shadow:none}.btn-link{--tblr-btn-font-weight:400;--tblr-btn-color:var(--tblr-link-color);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-link-hover-color);--tblr-btn-hover-border-color:transparent;--tblr-btn-active-color:var(--tblr-link-hover-color);--tblr-btn-active-border-color:transparent;--tblr-btn-disabled-color:#4b5563;--tblr-btn-disabled-border-color:transparent;--tblr-btn-box-shadow:0 0 0 #000;--tblr-btn-focus-shadow-rgb:42,132,215;text-decoration:none}.btn-link:focus-visible,.btn-link:hover{text-decoration:underline}.btn-link:focus-visible{color:var(--tblr-btn-color)}.btn-link:hover{color:var(--tblr-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--tblr-btn-padding-y:0.6875rem;--tblr-btn-padding-x:1.5rem;--tblr-btn-font-size:1rem;--tblr-btn-border-radius:var(--tblr-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--tblr-btn-padding-y:0.3125rem;--tblr-btn-padding-x:0.5rem;--tblr-btn-font-size:0.75rem;--tblr-btn-border-radius:var(--tblr-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle:after{content:"";display:inline-block;vertical-align:.306em;width:.36em;height:.36em;border-bottom:1px var(--tblr-border-style);border-left:1px var(--tblr-border-style);margin-right:.1em;margin-left:.4em;transform:rotate(-45deg)}.dropdown-menu{--tblr-dropdown-zindex:1000;--tblr-dropdown-min-width:11rem;--tblr-dropdown-padding-x:0;--tblr-dropdown-padding-y:0.25rem;--tblr-dropdown-spacer:1px;--tblr-dropdown-font-size:0.875rem;--tblr-dropdown-color:var(--tblr-body-color);--tblr-dropdown-bg:var(--tblr-bg-surface);--tblr-dropdown-border-color:var(--tblr-border-color-translucent);--tblr-dropdown-border-radius:var(--tblr-border-radius);--tblr-dropdown-border-width:var(--tblr-border-width);--tblr-dropdown-inner-border-radius:calc(var(--tblr-border-radius) - var(--tblr-border-width));--tblr-dropdown-divider-bg:var(--tblr-border-color-translucent);--tblr-dropdown-divider-margin-y:var(--tblr-spacer-2);--tblr-dropdown-box-shadow:var(--tblr-shadow-dropdown);--tblr-dropdown-link-color:inherit;--tblr-dropdown-link-hover-color:inherit;--tblr-dropdown-link-hover-bg:rgba(var(--tblr-secondary-rgb), 0.08);--tblr-dropdown-link-active-color:var(--tblr-primary);--tblr-dropdown-link-active-bg:var(--tblr-active-bg);--tblr-dropdown-link-disabled-color:var(--tblr-tertiary-color);--tblr-dropdown-item-padding-x:0.75rem;--tblr-dropdown-item-padding-y:0.5rem;--tblr-dropdown-header-color:#4b5563;--tblr-dropdown-header-padding-x:0.75rem;--tblr-dropdown-header-padding-y:0.25rem;position:absolute;z-index:var(--tblr-dropdown-zindex);display:none;min-width:var(--tblr-dropdown-min-width);padding:var(--tblr-dropdown-padding-y) var(--tblr-dropdown-padding-x);margin:0;font-size:var(--tblr-dropdown-font-size);color:var(--tblr-dropdown-color);text-align:left;list-style:none;background-color:var(--tblr-dropdown-bg);background-clip:padding-box;border:var(--tblr-dropdown-border-width) solid var(--tblr-dropdown-border-color);border-radius:var(--tblr-dropdown-border-radius);box-shadow:var(--tblr-dropdown-box-shadow)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--tblr-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--tblr-dropdown-spacer)}.dropup .dropdown-toggle:after{content:"";display:inline-block;vertical-align:.306em;width:.36em;height:.36em;border-bottom:1px var(--tblr-border-style);border-left:1px var(--tblr-border-style);margin-right:.1em;margin-left:.4em;transform:rotate(135deg)}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--tblr-dropdown-spacer)}.dropend .dropdown-toggle:after{content:"";display:inline-block;vertical-align:.306em;width:.36em;height:.36em;border-bottom:1px var(--tblr-border-style);border-left:1px var(--tblr-border-style);margin-right:.1em;margin-left:.4em;transform:rotate(-135deg)}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--tblr-dropdown-spacer)}.dropstart .dropdown-toggle:after{content:"";display:inline-block;vertical-align:.306em;width:.36em;height:.36em;border-bottom:1px var(--tblr-border-style);border-left:1px var(--tblr-border-style);margin-right:.1em;margin-left:.4em;transform:rotate(45deg)}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--tblr-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--tblr-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--tblr-dropdown-item-padding-y) var(--tblr-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--tblr-dropdown-link-color);text-align:inherit;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--tblr-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--tblr-dropdown-link-hover-color);text-decoration:none;background-color:var(--tblr-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--tblr-dropdown-link-active-color);text-decoration:none;background-color:var(--tblr-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--tblr-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--tblr-dropdown-header-padding-y) var(--tblr-dropdown-header-padding-x);margin-bottom:0;font-size:.765625rem;color:var(--tblr-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--tblr-dropdown-item-padding-y) var(--tblr-dropdown-item-padding-x);color:var(--tblr-dropdown-link-color)}.dropdown-menu-dark{--tblr-dropdown-color:#d1d5db;--tblr-dropdown-bg:#1f2937;--tblr-dropdown-border-color:var(--tblr-border-color-translucent);--tblr-dropdown-box-shadow: ;--tblr-dropdown-link-color:#d1d5db;--tblr-dropdown-link-hover-color:#ffffff;--tblr-dropdown-divider-bg:var(--tblr-border-color-translucent);--tblr-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--tblr-dropdown-link-active-color:var(--tblr-primary);--tblr-dropdown-link-active-bg:var(--tblr-active-bg);--tblr-dropdown-link-disabled-color:#6b7280;--tblr-dropdown-header-color:#6b7280}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--tblr-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(-1 * var(--tblr-border-width))}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:1.125rem;padding-left:1.125rem}.btn-group.show .dropdown-toggle{box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.show .dropdown-toggle.btn-link{box-shadow:none}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(-1 * var(--tblr-border-width))}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:nth-child(n+3),.btn-group-vertical>:not(.btn-check)+.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--tblr-nav-link-padding-x:0.75rem;--tblr-nav-link-padding-y:0.5rem;--tblr-nav-link-font-weight: ;--tblr-nav-link-color:var(--tblr-gray-500);--tblr-nav-link-hover-color:var(--tblr-link-hover-color);--tblr-nav-link-disabled-color:var(--tblr-disabled-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--tblr-nav-link-padding-y) var(--tblr-nav-link-padding-x);font-size:var(--tblr-nav-link-font-size);font-weight:var(--tblr-nav-link-font-weight);color:var(--tblr-nav-link-color);background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--tblr-nav-link-hover-color);text-decoration:none}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.nav-link.disabled,.nav-link:disabled{color:var(--tblr-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--tblr-nav-tabs-border-width:var(--tblr-border-width);--tblr-nav-tabs-border-color:var(--tblr-border-color);--tblr-nav-tabs-border-radius:var(--tblr-border-radius);--tblr-nav-tabs-link-hover-border-color:var(--tblr-border-color) var(--tblr-border-color) var(--tblr-border-color);--tblr-nav-tabs-link-active-color:var(--tblr-body-color);--tblr-nav-tabs-link-active-bg:var(--tblr-body-bg);--tblr-nav-tabs-link-active-border-color:var(--tblr-border-color) var(--tblr-border-color) var(--tblr-border-color);border-bottom:var(--tblr-nav-tabs-border-width) solid var(--tblr-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--tblr-nav-tabs-border-width));border:var(--tblr-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--tblr-nav-tabs-border-radius);border-top-right-radius:var(--tblr-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--tblr-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--tblr-nav-tabs-link-active-color);background-color:var(--tblr-nav-tabs-link-active-bg);border-color:var(--tblr-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--tblr-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--tblr-nav-pills-border-radius:var(--tblr-border-radius);--tblr-nav-pills-link-active-color:var(--tblr-primary);--tblr-nav-pills-link-active-bg:var(--tblr-active-bg)}.nav-pills .nav-link{border-radius:var(--tblr-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--tblr-nav-pills-link-active-color);background-color:var(--tblr-nav-pills-link-active-bg)}.nav-underline{--tblr-nav-underline-gap:1rem;--tblr-nav-underline-border-width:0.125rem;--tblr-nav-underline-link-active-color:var(--tblr-emphasis-color);gap:var(--tblr-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--tblr-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:600;color:var(--tblr-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-grow:1;flex-basis:0;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--tblr-navbar-padding-x:0;--tblr-navbar-padding-y:0.25rem;--tblr-navbar-color:var(--tblr-secondary);--tblr-navbar-hover-color:var(--tblr-body-color);--tblr-navbar-disabled-color:var(--tblr-disabled-color);--tblr-navbar-active-color:var(--tblr-body-color);--tblr-navbar-brand-padding-y:0.5rem;--tblr-navbar-brand-margin-end:1rem;--tblr-navbar-brand-font-size:1.25rem;--tblr-navbar-brand-color:var(--tblr-body-color);--tblr-navbar-brand-hover-color:var(--tblr-body-color);--tblr-navbar-nav-link-padding-x:0.75rem;--tblr-navbar-toggler-padding-y:0;--tblr-navbar-toggler-padding-x:0;--tblr-navbar-toggler-font-size:1rem;--tblr-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2831, 41, 55, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--tblr-navbar-toggler-border-color:rgba(var(--tblr-emphasis-color-rgb), 0.15);--tblr-navbar-toggler-border-radius:var(--tblr-border-radius);--tblr-navbar-toggler-focus-width:0;--tblr-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--tblr-navbar-padding-y) var(--tblr-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--tblr-navbar-brand-padding-y);padding-bottom:var(--tblr-navbar-brand-padding-y);margin-right:var(--tblr-navbar-brand-margin-end);font-size:var(--tblr-navbar-brand-font-size);color:var(--tblr-navbar-brand-color);white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--tblr-navbar-brand-hover-color);text-decoration:none}.navbar-nav{--tblr-nav-link-padding-x:0;--tblr-nav-link-padding-y:0.5rem;--tblr-nav-link-font-weight: ;--tblr-nav-link-color:var(--tblr-navbar-color);--tblr-nav-link-hover-color:var(--tblr-navbar-hover-color);--tblr-nav-link-disabled-color:var(--tblr-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--tblr-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--tblr-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--tblr-navbar-active-color)}.navbar-collapse{flex-grow:1;flex-basis:100%;align-items:center}.navbar-toggler{padding:var(--tblr-navbar-toggler-padding-y) var(--tblr-navbar-toggler-padding-x);font-size:var(--tblr-navbar-toggler-font-size);line-height:1;color:var(--tblr-navbar-color);background-color:transparent;border:var(--tblr-border-width) solid var(--tblr-navbar-toggler-border-color);border-radius:var(--tblr-navbar-toggler-border-radius);transition:var(--tblr-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--tblr-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--tblr-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--tblr-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--tblr-navbar-nav-link-padding-x);padding-left:var(--tblr-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--tblr-navbar-nav-link-padding-x);padding-left:var(--tblr-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--tblr-navbar-nav-link-padding-x);padding-left:var(--tblr-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--tblr-navbar-nav-link-padding-x);padding-left:var(--tblr-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--tblr-navbar-nav-link-padding-x);padding-left:var(--tblr-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--tblr-navbar-nav-link-padding-x);padding-left:var(--tblr-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;box-shadow:none;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark],body[data-bs-theme=dark] .navbar[data-bs-theme=light]{--tblr-navbar-color:rgba(255, 255, 255, 0.7);--tblr-navbar-hover-color:rgba(255, 255, 255, 0.75);--tblr-navbar-disabled-color:var(--tblr-disabled-color);--tblr-navbar-active-color:#ffffff;--tblr-navbar-brand-color:#ffffff;--tblr-navbar-brand-hover-color:#ffffff;--tblr-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--tblr-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.7%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon,body[data-bs-theme=dark] [data-bs-theme=light] .navbar-toggler-icon{--tblr-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.7%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--tblr-card-spacer-y:1rem;--tblr-card-spacer-x:1.25rem;--tblr-card-title-spacer-y:1.25rem;--tblr-card-title-color: ;--tblr-card-subtitle-color: ;--tblr-card-border-width:var(--tblr-border-width);--tblr-card-border-color:var(--tblr-border-color-translucent);--tblr-card-border-radius:var(--tblr-border-radius-lg);--tblr-card-box-shadow:var(--tblr-shadow-card);--tblr-card-inner-border-radius:calc(var(--tblr-border-radius-lg) - (var(--tblr-border-width)));--tblr-card-cap-padding-y:1rem;--tblr-card-cap-padding-x:1.25rem;--tblr-card-cap-bg:var(--tblr-bg-surface-tertiary);--tblr-card-cap-color:inherit;--tblr-card-height: ;--tblr-card-color:inherit;--tblr-card-bg:var(--tblr-bg-surface);--tblr-card-img-overlay-padding:1rem;--tblr-card-group-margin:1.5rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--tblr-card-height);color:var(--tblr-body-color);word-wrap:break-word;background-color:var(--tblr-card-bg);background-clip:border-box;border:var(--tblr-card-border-width) solid var(--tblr-card-border-color);border-radius:var(--tblr-card-border-radius);box-shadow:var(--tblr-card-box-shadow)}.card>.hr,.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--tblr-card-inner-border-radius);border-top-right-radius:var(--tblr-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--tblr-card-inner-border-radius);border-bottom-left-radius:var(--tblr-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--tblr-card-spacer-y) var(--tblr-card-spacer-x);color:var(--tblr-card-color)}.card-title{margin-bottom:var(--tblr-card-title-spacer-y);color:var(--tblr-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--tblr-card-title-spacer-y));margin-bottom:0;color:var(--tblr-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:var(--tblr-card-spacer-x)}.card-header{padding:var(--tblr-card-cap-padding-y) var(--tblr-card-cap-padding-x);margin-bottom:0;color:var(--tblr-card-cap-color);background-color:var(--tblr-card-cap-bg);border-bottom:var(--tblr-card-border-width) solid var(--tblr-card-border-color)}.card-header:first-child{border-radius:var(--tblr-card-inner-border-radius) var(--tblr-card-inner-border-radius) 0 0}.card-footer{padding:var(--tblr-card-cap-padding-y) var(--tblr-card-cap-padding-x);color:var(--tblr-card-cap-color);background-color:var(--tblr-card-cap-bg);border-top:var(--tblr-card-border-width) solid var(--tblr-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--tblr-card-inner-border-radius) var(--tblr-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--tblr-card-cap-padding-x));margin-bottom:calc(-1 * var(--tblr-card-cap-padding-y));margin-left:calc(-.5 * var(--tblr-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--tblr-card-bg);border-bottom-color:var(--tblr-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--tblr-card-cap-padding-x));margin-left:calc(-.5 * var(--tblr-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--tblr-card-img-overlay-padding);border-radius:var(--tblr-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--tblr-card-inner-border-radius);border-top-right-radius:var(--tblr-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--tblr-card-inner-border-radius);border-bottom-left-radius:var(--tblr-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--tblr-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child)>.card-header,.card-group>.card:not(:last-child)>.card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child)>.card-footer,.card-group>.card:not(:last-child)>.card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child)>.card-header,.card-group>.card:not(:first-child)>.card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child)>.card-footer,.card-group>.card:not(:first-child)>.card-img-bottom{border-bottom-left-radius:0}}.pagination{--tblr-pagination-padding-x:0.25rem;--tblr-pagination-padding-y:calc(0.25rem + 1px);--tblr-pagination-font-size:0.875rem;--tblr-pagination-color:var(--tblr-body-color);--tblr-pagination-bg:transparent;--tblr-pagination-border-width:1px;--tblr-pagination-border-color:transparent;--tblr-pagination-border-radius:var(--tblr-border-radius);--tblr-pagination-hover-color:var(--tblr-link-hover-color);--tblr-pagination-hover-bg:var(--tblr-active-bg);--tblr-pagination-hover-border-color:var(--tblr-pagination-border-color);--tblr-pagination-focus-color:var(--tblr-link-hover-color);--tblr-pagination-focus-bg:var(--tblr-secondary-bg);--tblr-pagination-focus-box-shadow:0 0 0 0.25rem rgba(var(--tblr-primary-rgb), 0.25);--tblr-pagination-active-color:#ffffff;--tblr-pagination-active-bg:var(--tblr-primary);--tblr-pagination-active-border-color:var(--tblr-primary);--tblr-pagination-disabled-color:var(--tblr-disabled-color);--tblr-pagination-disabled-bg:transparent;--tblr-pagination-disabled-border-color:var(--tblr-pagination-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--tblr-pagination-padding-y) var(--tblr-pagination-padding-x);font-size:var(--tblr-pagination-font-size);color:var(--tblr-pagination-color);background-color:var(--tblr-pagination-bg);border:var(--tblr-pagination-border-width) solid var(--tblr-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--tblr-pagination-hover-color);text-decoration:none;background-color:var(--tblr-pagination-hover-bg);border-color:var(--tblr-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--tblr-pagination-focus-color);background-color:var(--tblr-pagination-focus-bg);outline:0;box-shadow:var(--tblr-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--tblr-pagination-active-color);background-color:var(--tblr-pagination-active-bg);border-color:var(--tblr-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--tblr-pagination-disabled-color);pointer-events:none;background-color:var(--tblr-pagination-disabled-bg);border-color:var(--tblr-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(-1 * 1px)}.page-item:first-child .page-link{border-top-left-radius:var(--tblr-pagination-border-radius);border-bottom-left-radius:var(--tblr-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--tblr-pagination-border-radius);border-bottom-right-radius:var(--tblr-pagination-border-radius)}.pagination-lg{--tblr-pagination-padding-x:1.5rem;--tblr-pagination-padding-y:0.75rem;--tblr-pagination-font-size:1.09375rem;--tblr-pagination-border-radius:var(--tblr-border-radius-lg)}.pagination-sm{--tblr-pagination-padding-x:0.5rem;--tblr-pagination-padding-y:0.25rem;--tblr-pagination-font-size:0.765625rem;--tblr-pagination-border-radius:var(--tblr-border-radius-sm)}@keyframes progress-bar-stripes{0%{background-position-x:var(--tblr-progress-height)}}.progress,.progress-stacked{--tblr-progress-height:0.5rem;--tblr-progress-font-size:0.65625rem;--tblr-progress-bg:var(--tblr-border-color);--tblr-progress-border-radius:var(--tblr-border-radius);--tblr-progress-box-shadow:var(--tblr-box-shadow-inset);--tblr-progress-bar-color:#ffffff;--tblr-progress-bar-bg:var(--tblr-primary);--tblr-progress-bar-transition:width 0.6s ease;display:flex;height:var(--tblr-progress-height);overflow:hidden;font-size:var(--tblr-progress-font-size);background-color:var(--tblr-progress-bg);border-radius:var(--tblr-progress-border-radius);box-shadow:var(--tblr-progress-box-shadow)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--tblr-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--tblr-progress-bar-bg);transition:var(--tblr-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--tblr-progress-height) var(--tblr-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--tblr-list-group-color:var(--tblr-body-color);--tblr-list-group-bg:inherit;--tblr-list-group-border-color:var(--tblr-border-color);--tblr-list-group-border-width:var(--tblr-border-width);--tblr-list-group-border-radius:var(--tblr-border-radius);--tblr-list-group-item-padding-x:1.25rem;--tblr-list-group-item-padding-y:1rem;--tblr-list-group-action-color:inherit;--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:rgba(var(--tblr-secondary-rgb), 0.08);--tblr-list-group-action-active-color:var(--tblr-body-color);--tblr-list-group-action-active-bg:var(--tblr-secondary-bg);--tblr-list-group-disabled-color:var(--tblr-secondary-color);--tblr-list-group-disabled-bg:inherit;--tblr-list-group-active-color:inherit;--tblr-list-group-active-bg:var(--tblr-active-bg);--tblr-list-group-active-border-color:var(--tblr-border-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--tblr-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item{position:relative;display:block;padding:var(--tblr-list-group-item-padding-y) var(--tblr-list-group-item-padding-x);color:var(--tblr-list-group-color);background-color:var(--tblr-list-group-bg);border:var(--tblr-list-group-border-width) solid var(--tblr-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--tblr-list-group-disabled-color);pointer-events:none;background-color:var(--tblr-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--tblr-list-group-active-color);background-color:var(--tblr-list-group-active-bg);border-color:var(--tblr-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--tblr-list-group-border-width));border-top-width:var(--tblr-list-group-border-width)}.list-group-item-action{width:100%;color:var(--tblr-list-group-action-color);text-align:inherit}.list-group-item-action:not(.active):focus,.list-group-item-action:not(.active):hover{z-index:1;color:var(--tblr-list-group-action-hover-color);text-decoration:none;background-color:var(--tblr-list-group-action-hover-bg)}.list-group-item-action:not(.active):active{color:var(--tblr-list-group-action-active-color);background-color:var(--tblr-list-group-action-active-bg)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--tblr-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--tblr-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--tblr-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--tblr-list-group-border-width));border-left-width:var(--tblr-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--tblr-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--tblr-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--tblr-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--tblr-list-group-border-width));border-left-width:var(--tblr-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--tblr-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--tblr-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--tblr-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--tblr-list-group-border-width));border-left-width:var(--tblr-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--tblr-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--tblr-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--tblr-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--tblr-list-group-border-width));border-left-width:var(--tblr-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--tblr-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--tblr-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--tblr-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--tblr-list-group-border-width));border-left-width:var(--tblr-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--tblr-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--tblr-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--tblr-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--tblr-list-group-border-width));border-left-width:var(--tblr-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--tblr-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--tblr-list-group-color:var(--tblr-primary-text-emphasis);--tblr-list-group-bg:var(--tblr-primary-bg-subtle);--tblr-list-group-border-color:var(--tblr-primary-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-primary-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-primary-border-subtle);--tblr-list-group-active-color:var(--tblr-primary-bg-subtle);--tblr-list-group-active-bg:var(--tblr-primary-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-primary-text-emphasis)}.list-group-item-secondary{--tblr-list-group-color:var(--tblr-secondary-text-emphasis);--tblr-list-group-bg:var(--tblr-secondary-bg-subtle);--tblr-list-group-border-color:var(--tblr-secondary-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-secondary-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-secondary-border-subtle);--tblr-list-group-active-color:var(--tblr-secondary-bg-subtle);--tblr-list-group-active-bg:var(--tblr-secondary-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-secondary-text-emphasis)}.list-group-item-success{--tblr-list-group-color:var(--tblr-success-text-emphasis);--tblr-list-group-bg:var(--tblr-success-bg-subtle);--tblr-list-group-border-color:var(--tblr-success-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-success-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-success-border-subtle);--tblr-list-group-active-color:var(--tblr-success-bg-subtle);--tblr-list-group-active-bg:var(--tblr-success-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-success-text-emphasis)}.list-group-item-info{--tblr-list-group-color:var(--tblr-info-text-emphasis);--tblr-list-group-bg:var(--tblr-info-bg-subtle);--tblr-list-group-border-color:var(--tblr-info-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-info-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-info-border-subtle);--tblr-list-group-active-color:var(--tblr-info-bg-subtle);--tblr-list-group-active-bg:var(--tblr-info-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-info-text-emphasis)}.list-group-item-warning{--tblr-list-group-color:var(--tblr-warning-text-emphasis);--tblr-list-group-bg:var(--tblr-warning-bg-subtle);--tblr-list-group-border-color:var(--tblr-warning-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-warning-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-warning-border-subtle);--tblr-list-group-active-color:var(--tblr-warning-bg-subtle);--tblr-list-group-active-bg:var(--tblr-warning-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-warning-text-emphasis)}.list-group-item-danger{--tblr-list-group-color:var(--tblr-danger-text-emphasis);--tblr-list-group-bg:var(--tblr-danger-bg-subtle);--tblr-list-group-border-color:var(--tblr-danger-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-danger-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-danger-border-subtle);--tblr-list-group-active-color:var(--tblr-danger-bg-subtle);--tblr-list-group-active-bg:var(--tblr-danger-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-danger-text-emphasis)}.list-group-item-light{--tblr-list-group-color:var(--tblr-light-text-emphasis);--tblr-list-group-bg:var(--tblr-light-bg-subtle);--tblr-list-group-border-color:var(--tblr-light-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-light-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-light-border-subtle);--tblr-list-group-active-color:var(--tblr-light-bg-subtle);--tblr-list-group-active-bg:var(--tblr-light-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-light-text-emphasis)}.list-group-item-dark{--tblr-list-group-color:var(--tblr-dark-text-emphasis);--tblr-list-group-bg:var(--tblr-dark-bg-subtle);--tblr-list-group-border-color:var(--tblr-dark-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-dark-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-dark-border-subtle);--tblr-list-group-active-color:var(--tblr-dark-bg-subtle);--tblr-list-group-active-bg:var(--tblr-dark-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-dark-text-emphasis)}.list-group-item-muted{--tblr-list-group-color:var(--tblr-muted-text-emphasis);--tblr-list-group-bg:var(--tblr-muted-bg-subtle);--tblr-list-group-border-color:var(--tblr-muted-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-muted-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-muted-border-subtle);--tblr-list-group-active-color:var(--tblr-muted-bg-subtle);--tblr-list-group-active-bg:var(--tblr-muted-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-muted-text-emphasis)}.list-group-item-blue{--tblr-list-group-color:var(--tblr-blue-text-emphasis);--tblr-list-group-bg:var(--tblr-blue-bg-subtle);--tblr-list-group-border-color:var(--tblr-blue-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-blue-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-blue-border-subtle);--tblr-list-group-active-color:var(--tblr-blue-bg-subtle);--tblr-list-group-active-bg:var(--tblr-blue-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-blue-text-emphasis)}.list-group-item-azure{--tblr-list-group-color:var(--tblr-azure-text-emphasis);--tblr-list-group-bg:var(--tblr-azure-bg-subtle);--tblr-list-group-border-color:var(--tblr-azure-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-azure-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-azure-border-subtle);--tblr-list-group-active-color:var(--tblr-azure-bg-subtle);--tblr-list-group-active-bg:var(--tblr-azure-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-azure-text-emphasis)}.list-group-item-indigo{--tblr-list-group-color:var(--tblr-indigo-text-emphasis);--tblr-list-group-bg:var(--tblr-indigo-bg-subtle);--tblr-list-group-border-color:var(--tblr-indigo-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-indigo-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-indigo-border-subtle);--tblr-list-group-active-color:var(--tblr-indigo-bg-subtle);--tblr-list-group-active-bg:var(--tblr-indigo-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-indigo-text-emphasis)}.list-group-item-purple{--tblr-list-group-color:var(--tblr-purple-text-emphasis);--tblr-list-group-bg:var(--tblr-purple-bg-subtle);--tblr-list-group-border-color:var(--tblr-purple-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-purple-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-purple-border-subtle);--tblr-list-group-active-color:var(--tblr-purple-bg-subtle);--tblr-list-group-active-bg:var(--tblr-purple-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-purple-text-emphasis)}.list-group-item-pink{--tblr-list-group-color:var(--tblr-pink-text-emphasis);--tblr-list-group-bg:var(--tblr-pink-bg-subtle);--tblr-list-group-border-color:var(--tblr-pink-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-pink-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-pink-border-subtle);--tblr-list-group-active-color:var(--tblr-pink-bg-subtle);--tblr-list-group-active-bg:var(--tblr-pink-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-pink-text-emphasis)}.list-group-item-red{--tblr-list-group-color:var(--tblr-red-text-emphasis);--tblr-list-group-bg:var(--tblr-red-bg-subtle);--tblr-list-group-border-color:var(--tblr-red-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-red-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-red-border-subtle);--tblr-list-group-active-color:var(--tblr-red-bg-subtle);--tblr-list-group-active-bg:var(--tblr-red-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-red-text-emphasis)}.list-group-item-orange{--tblr-list-group-color:var(--tblr-orange-text-emphasis);--tblr-list-group-bg:var(--tblr-orange-bg-subtle);--tblr-list-group-border-color:var(--tblr-orange-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-orange-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-orange-border-subtle);--tblr-list-group-active-color:var(--tblr-orange-bg-subtle);--tblr-list-group-active-bg:var(--tblr-orange-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-orange-text-emphasis)}.list-group-item-yellow{--tblr-list-group-color:var(--tblr-yellow-text-emphasis);--tblr-list-group-bg:var(--tblr-yellow-bg-subtle);--tblr-list-group-border-color:var(--tblr-yellow-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-yellow-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-yellow-border-subtle);--tblr-list-group-active-color:var(--tblr-yellow-bg-subtle);--tblr-list-group-active-bg:var(--tblr-yellow-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-yellow-text-emphasis)}.list-group-item-lime{--tblr-list-group-color:var(--tblr-lime-text-emphasis);--tblr-list-group-bg:var(--tblr-lime-bg-subtle);--tblr-list-group-border-color:var(--tblr-lime-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-lime-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-lime-border-subtle);--tblr-list-group-active-color:var(--tblr-lime-bg-subtle);--tblr-list-group-active-bg:var(--tblr-lime-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-lime-text-emphasis)}.list-group-item-green{--tblr-list-group-color:var(--tblr-green-text-emphasis);--tblr-list-group-bg:var(--tblr-green-bg-subtle);--tblr-list-group-border-color:var(--tblr-green-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-green-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-green-border-subtle);--tblr-list-group-active-color:var(--tblr-green-bg-subtle);--tblr-list-group-active-bg:var(--tblr-green-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-green-text-emphasis)}.list-group-item-teal{--tblr-list-group-color:var(--tblr-teal-text-emphasis);--tblr-list-group-bg:var(--tblr-teal-bg-subtle);--tblr-list-group-border-color:var(--tblr-teal-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-teal-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-teal-border-subtle);--tblr-list-group-active-color:var(--tblr-teal-bg-subtle);--tblr-list-group-active-bg:var(--tblr-teal-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-teal-text-emphasis)}.list-group-item-cyan{--tblr-list-group-color:var(--tblr-cyan-text-emphasis);--tblr-list-group-bg:var(--tblr-cyan-bg-subtle);--tblr-list-group-border-color:var(--tblr-cyan-border-subtle);--tblr-list-group-action-hover-color:var(--tblr-emphasis-color);--tblr-list-group-action-hover-bg:var(--tblr-cyan-border-subtle);--tblr-list-group-action-active-color:var(--tblr-emphasis-color);--tblr-list-group-action-active-bg:var(--tblr-cyan-border-subtle);--tblr-list-group-active-color:var(--tblr-cyan-bg-subtle);--tblr-list-group-active-bg:var(--tblr-cyan-text-emphasis);--tblr-list-group-active-border-color:var(--tblr-cyan-text-emphasis)}.toast{--tblr-toast-zindex:1090;--tblr-toast-padding-x:0.75rem;--tblr-toast-padding-y:0.5rem;--tblr-toast-spacing:calc(var(--tblr-page-padding) * 2);--tblr-toast-max-width:350px;--tblr-toast-font-size:0.875rem;--tblr-toast-color: ;--tblr-toast-bg:var(--tblr-bg-surface);--tblr-toast-border-width:var(--tblr-border-width);--tblr-toast-border-color:var(--tblr-border-color);--tblr-toast-border-radius:var(--tblr-border-radius);--tblr-toast-box-shadow:var(--tblr-box-shadow);--tblr-toast-header-color:var(--tblr-gray-500);--tblr-toast-header-bg:rgba(var(--tblr-body-bg-rgb), 0.85);--tblr-toast-header-border-color:var(--tblr-border-color);width:var(--tblr-toast-max-width);max-width:100%;font-size:var(--tblr-toast-font-size);color:var(--tblr-toast-color);pointer-events:auto;background-color:var(--tblr-toast-bg);background-clip:padding-box;border:var(--tblr-toast-border-width) solid var(--tblr-toast-border-color);box-shadow:var(--tblr-toast-box-shadow);border-radius:var(--tblr-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--tblr-toast-zindex:1090;position:absolute;z-index:var(--tblr-toast-zindex);width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--tblr-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--tblr-toast-padding-y) var(--tblr-toast-padding-x);color:var(--tblr-toast-header-color);background-color:var(--tblr-toast-header-bg);background-clip:padding-box;border-bottom:var(--tblr-toast-border-width) solid var(--tblr-toast-header-border-color);border-top-left-radius:calc(var(--tblr-toast-border-radius) - var(--tblr-toast-border-width));border-top-right-radius:calc(var(--tblr-toast-border-radius) - var(--tblr-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--tblr-toast-padding-x));margin-left:var(--tblr-toast-padding-x)}.toast-body{padding:var(--tblr-toast-padding-x);word-wrap:break-word}.modal{--tblr-modal-zindex:1055;--tblr-modal-width:540px;--tblr-modal-padding:1.5rem;--tblr-modal-margin:0.5rem;--tblr-modal-color:var(--tblr-body-color);--tblr-modal-bg:var(--tblr-bg-surface);--tblr-modal-border-color:transparent;--tblr-modal-border-width:var(--tblr-border-width);--tblr-modal-border-radius:var(--tblr-border-radius-lg);--tblr-modal-box-shadow:var(--tblr-box-shadow-sm);--tblr-modal-inner-border-radius:calc(var(--tblr-modal-border-radius) - 1px);--tblr-modal-header-padding-x:1.5rem;--tblr-modal-header-padding-y:1.5rem;--tblr-modal-header-padding:1.5rem;--tblr-modal-header-border-color:var(--tblr-border-color);--tblr-modal-header-border-width:var(--tblr-border-width);--tblr-modal-title-line-height:1.4285714286;--tblr-modal-footer-gap:0.75rem;--tblr-modal-footer-bg:var(--tblr-bg-surface-tertiary);--tblr-modal-footer-border-color:var(--tblr-border-color);--tblr-modal-footer-border-width:var(--tblr-border-width);position:fixed;top:0;left:0;z-index:var(--tblr-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--tblr-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transform:translate(0,-1rem);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--tblr-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--tblr-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--tblr-modal-color);pointer-events:auto;background-color:var(--tblr-modal-bg);background-clip:padding-box;border:var(--tblr-modal-border-width) solid var(--tblr-modal-border-color);border-radius:var(--tblr-modal-border-radius);box-shadow:var(--tblr-modal-box-shadow);outline:0}.modal-backdrop{--tblr-backdrop-zindex:1050;--tblr-backdrop-bg:var(--tblr-gray-800);--tblr-backdrop-opacity:0.24;position:fixed;top:0;left:0;z-index:var(--tblr-backdrop-zindex);width:100vw;height:100vh;background-color:var(--tblr-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--tblr-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--tblr-modal-header-padding);border-bottom:var(--tblr-modal-header-border-width) solid var(--tblr-modal-header-border-color);border-top-left-radius:var(--tblr-modal-inner-border-radius);border-top-right-radius:var(--tblr-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--tblr-modal-header-padding-y) * .5) calc(var(--tblr-modal-header-padding-x) * .5);margin-top:calc(-.5 * var(--tblr-modal-header-padding-y));margin-right:calc(-.5 * var(--tblr-modal-header-padding-x));margin-bottom:calc(-.5 * var(--tblr-modal-header-padding-y));margin-left:auto}.modal-title{margin-bottom:0;line-height:var(--tblr-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--tblr-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--tblr-modal-padding) - var(--tblr-modal-footer-gap) * .5);background-color:var(--tblr-modal-footer-bg);border-top:var(--tblr-modal-footer-border-width) solid var(--tblr-modal-footer-border-color);border-bottom-right-radius:var(--tblr-modal-inner-border-radius);border-bottom-left-radius:var(--tblr-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--tblr-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--tblr-modal-margin:1.75rem;--tblr-modal-box-shadow:var(--tblr-box-shadow)}.modal-dialog{max-width:var(--tblr-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--tblr-modal-width:380px}}@media (min-width:992px){.modal-lg,.modal-xl{--tblr-modal-width:720px}}@media (min-width:1200px){.modal-xl{--tblr-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--tblr-tooltip-zindex:1080;--tblr-tooltip-max-width:200px;--tblr-tooltip-padding-x:var(--tblr-spacer-3);--tblr-tooltip-padding-y:var(--tblr-spacer-1);--tblr-tooltip-margin: ;--tblr-tooltip-font-size:0.765625rem;--tblr-tooltip-color:var(--tblr-text-inverted);--tblr-tooltip-bg:var(--tblr-bg-surface-inverted);--tblr-tooltip-border-radius:var(--tblr-border-radius);--tblr-tooltip-opacity:0.9;--tblr-tooltip-arrow-width:0.8rem;--tblr-tooltip-arrow-height:0.4rem;z-index:var(--tblr-tooltip-zindex);display:block;margin:var(--tblr-tooltip-margin);font-family:var(--tblr-font-sans-serif);font-style:normal;font-weight:400;line-height:1.4285714286;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--tblr-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--tblr-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--tblr-tooltip-arrow-width);height:var(--tblr-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--tblr-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--tblr-tooltip-arrow-height) calc(var(--tblr-tooltip-arrow-width) * .5) 0;border-top-color:var(--tblr-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--tblr-tooltip-arrow-height));width:var(--tblr-tooltip-arrow-height);height:var(--tblr-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--tblr-tooltip-arrow-width) * .5) var(--tblr-tooltip-arrow-height) calc(var(--tblr-tooltip-arrow-width) * .5) 0;border-right-color:var(--tblr-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--tblr-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--tblr-tooltip-arrow-width) * .5) var(--tblr-tooltip-arrow-height);border-bottom-color:var(--tblr-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--tblr-tooltip-arrow-height));width:var(--tblr-tooltip-arrow-height);height:var(--tblr-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--tblr-tooltip-arrow-width) * .5) 0 calc(var(--tblr-tooltip-arrow-width) * .5) var(--tblr-tooltip-arrow-height);border-left-color:var(--tblr-tooltip-bg)}.tooltip-inner{max-width:var(--tblr-tooltip-max-width);padding:var(--tblr-tooltip-padding-y) var(--tblr-tooltip-padding-x);color:var(--tblr-tooltip-color);text-align:center;background-color:var(--tblr-tooltip-bg);border-radius:var(--tblr-tooltip-border-radius)}.popover{--tblr-popover-zindex:1070;--tblr-popover-max-width:276px;--tblr-popover-font-size:0.765625rem;--tblr-popover-bg:var(--tblr-bg-surface);--tblr-popover-border-width:var(--tblr-border-width);--tblr-popover-border-color:var(--tblr-border-color);--tblr-popover-border-radius:var(--tblr-border-radius-lg);--tblr-popover-inner-border-radius:calc(var(--tblr-border-radius-lg) - var(--tblr-border-width));--tblr-popover-box-shadow:var(--tblr-shadow-lg);--tblr-popover-header-padding-x:1rem;--tblr-popover-header-padding-y:0.5rem;--tblr-popover-header-font-size:0.875rem;--tblr-popover-header-color:inherit;--tblr-popover-header-bg:transparent;--tblr-popover-body-padding-x:0.5rem;--tblr-popover-body-padding-y:0.5rem;--tblr-popover-body-color:inherit;--tblr-popover-arrow-width:1rem;--tblr-popover-arrow-height:0.5rem;--tblr-popover-arrow-border:var(--tblr-popover-border-color);z-index:var(--tblr-popover-zindex);display:block;max-width:var(--tblr-popover-max-width);font-family:var(--tblr-font-sans-serif);font-style:normal;font-weight:400;line-height:1.4285714286;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--tblr-popover-font-size);word-wrap:break-word;background-color:var(--tblr-popover-bg);background-clip:padding-box;border:var(--tblr-popover-border-width) solid var(--tblr-popover-border-color);border-radius:var(--tblr-popover-border-radius);box-shadow:var(--tblr-popover-box-shadow)}.popover .popover-arrow{display:block;width:var(--tblr-popover-arrow-width);height:var(--tblr-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--tblr-popover-arrow-height)) - var(--tblr-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--tblr-popover-arrow-height) calc(var(--tblr-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--tblr-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--tblr-popover-border-width);border-top-color:var(--tblr-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--tblr-popover-arrow-height)) - var(--tblr-popover-border-width));width:var(--tblr-popover-arrow-height);height:var(--tblr-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--tblr-popover-arrow-width) * .5) var(--tblr-popover-arrow-height) calc(var(--tblr-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--tblr-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--tblr-popover-border-width);border-right-color:var(--tblr-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--tblr-popover-arrow-height)) - var(--tblr-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--tblr-popover-arrow-width) * .5) var(--tblr-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--tblr-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--tblr-popover-border-width);border-bottom-color:var(--tblr-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--tblr-popover-arrow-width);margin-left:calc(-.5 * var(--tblr-popover-arrow-width));content:"";border-bottom:var(--tblr-popover-border-width) solid var(--tblr-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--tblr-popover-arrow-height)) - var(--tblr-popover-border-width));width:var(--tblr-popover-arrow-height);height:var(--tblr-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--tblr-popover-arrow-width) * .5) 0 calc(var(--tblr-popover-arrow-width) * .5) var(--tblr-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--tblr-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--tblr-popover-border-width);border-left-color:var(--tblr-popover-bg)}.popover-header{padding:var(--tblr-popover-header-padding-y) var(--tblr-popover-header-padding-x);margin-bottom:0;font-size:var(--tblr-popover-header-font-size);color:var(--tblr-popover-header-color);background-color:var(--tblr-popover-header-bg);border-bottom:var(--tblr-popover-border-width) solid var(--tblr-popover-border-color);border-top-left-radius:var(--tblr-popover-inner-border-radius);border-top-right-radius:var(--tblr-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--tblr-popover-body-padding-y) var(--tblr-popover-body-padding-x);color:var(--tblr-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;filter:var(--tblr-carousel-control-icon-filter);border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:1.5rem;height:1.5rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='15 18 9 12 15 6'%3e%3c/polyline%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='9 18 15 12 9 6'%3e%3c/polyline%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:var(--tblr-carousel-indicator-active-bg);background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:var(--tblr-carousel-caption-color);text-align:center}.carousel-dark{--tblr-carousel-indicator-active-bg:#000000;--tblr-carousel-caption-color:#000000;--tblr-carousel-control-icon-filter:invert(1) grayscale(100)}:root,[data-bs-theme=light]{--tblr-carousel-indicator-active-bg:#ffffff;--tblr-carousel-caption-color:#ffffff;--tblr-carousel-control-icon-filter: }[data-bs-theme=dark],body[data-bs-theme=dark] [data-bs-theme=light]{--tblr-carousel-indicator-active-bg:#000000;--tblr-carousel-caption-color:#000000;--tblr-carousel-control-icon-filter:invert(1) grayscale(100)}.spinner-border,.spinner-grow{display:inline-block;width:var(--tblr-spinner-width);height:var(--tblr-spinner-height);vertical-align:var(--tblr-spinner-vertical-align);border-radius:50%;animation:var(--tblr-spinner-animation-speed) linear infinite var(--tblr-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--tblr-spinner-width:1.5rem;--tblr-spinner-height:1.5rem;--tblr-spinner-vertical-align:-0.125em;--tblr-spinner-border-width:2px;--tblr-spinner-animation-speed:0.75s;--tblr-spinner-animation-name:spinner-border;border:var(--tblr-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--tblr-spinner-width:1rem;--tblr-spinner-height:1rem;--tblr-spinner-border-width:1px}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--tblr-spinner-width:1.5rem;--tblr-spinner-height:1.5rem;--tblr-spinner-vertical-align:-0.125em;--tblr-spinner-animation-speed:0.75s;--tblr-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--tblr-spinner-width:1rem;--tblr-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--tblr-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--tblr-offcanvas-zindex:1045;--tblr-offcanvas-width:400px;--tblr-offcanvas-height:30vh;--tblr-offcanvas-padding-x:1.5rem;--tblr-offcanvas-padding-y:1.5rem;--tblr-offcanvas-color:var(--tblr-body-color);--tblr-offcanvas-bg:var(--tblr-bg-surface);--tblr-offcanvas-border-width:var(--tblr-border-width);--tblr-offcanvas-border-color:var(--tblr-border-color);--tblr-offcanvas-box-shadow:var(--tblr-box-shadow-sm);--tblr-offcanvas-transition:transform 0.3s ease-in-out;--tblr-offcanvas-title-line-height:1.4285714286}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--tblr-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--tblr-offcanvas-color);visibility:hidden;background-color:var(--tblr-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--tblr-offcanvas-box-shadow);transition:var(--tblr-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--tblr-offcanvas-width);border-right:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--tblr-offcanvas-width);border-left:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-bottom:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-top:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--tblr-offcanvas-height:auto;--tblr-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--tblr-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--tblr-offcanvas-color);visibility:hidden;background-color:var(--tblr-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--tblr-offcanvas-box-shadow);transition:var(--tblr-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--tblr-offcanvas-width);border-right:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--tblr-offcanvas-width);border-left:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-bottom:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-top:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--tblr-offcanvas-height:auto;--tblr-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--tblr-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--tblr-offcanvas-color);visibility:hidden;background-color:var(--tblr-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--tblr-offcanvas-box-shadow);transition:var(--tblr-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--tblr-offcanvas-width);border-right:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--tblr-offcanvas-width);border-left:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-bottom:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-top:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--tblr-offcanvas-height:auto;--tblr-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--tblr-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--tblr-offcanvas-color);visibility:hidden;background-color:var(--tblr-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--tblr-offcanvas-box-shadow);transition:var(--tblr-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--tblr-offcanvas-width);border-right:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--tblr-offcanvas-width);border-left:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-bottom:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-top:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--tblr-offcanvas-height:auto;--tblr-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--tblr-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--tblr-offcanvas-color);visibility:hidden;background-color:var(--tblr-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--tblr-offcanvas-box-shadow);transition:var(--tblr-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--tblr-offcanvas-width);border-right:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--tblr-offcanvas-width);border-left:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-bottom:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-top:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--tblr-offcanvas-height:auto;--tblr-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--tblr-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--tblr-offcanvas-color);visibility:hidden;background-color:var(--tblr-offcanvas-bg);background-clip:padding-box;outline:0;box-shadow:var(--tblr-offcanvas-box-shadow);transition:var(--tblr-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--tblr-offcanvas-width);border-right:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--tblr-offcanvas-width);border-left:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-bottom:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--tblr-offcanvas-height);max-height:100%;border-top:var(--tblr-offcanvas-border-width) solid var(--tblr-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:var(--tblr-gray-800)}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.24}.offcanvas-header{display:flex;align-items:center;padding:var(--tblr-offcanvas-padding-y) var(--tblr-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--tblr-offcanvas-padding-y) * .5) calc(var(--tblr-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--tblr-offcanvas-padding-y));margin-right:calc(-.5 * var(--tblr-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--tblr-offcanvas-padding-y));margin-left:auto}.offcanvas-title{margin-bottom:0;line-height:var(--tblr-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--tblr-offcanvas-padding-y) var(--tblr-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.2}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.1}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000000 55%,rgba(0,0,0,0.9) 75%,#000000 95%);mask-image:linear-gradient(130deg,#000000 55%,rgba(0,0,0,0.9) 75%,#000000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--tblr-box-shadow)!important}.shadow-sm{box-shadow:var(--tblr-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--tblr-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--tblr-focus-ring-color:rgba(var(--tblr-primary-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-secondary{--tblr-focus-ring-color:rgba(var(--tblr-secondary-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-success{--tblr-focus-ring-color:rgba(var(--tblr-success-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-info{--tblr-focus-ring-color:rgba(var(--tblr-info-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-warning{--tblr-focus-ring-color:rgba(var(--tblr-warning-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-danger{--tblr-focus-ring-color:rgba(var(--tblr-danger-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-light{--tblr-focus-ring-color:rgba(var(--tblr-light-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-dark{--tblr-focus-ring-color:rgba(var(--tblr-dark-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-muted{--tblr-focus-ring-color:rgba(var(--tblr-muted-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-blue{--tblr-focus-ring-color:rgba(var(--tblr-blue-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-azure{--tblr-focus-ring-color:rgba(var(--tblr-azure-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-indigo{--tblr-focus-ring-color:rgba(var(--tblr-indigo-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-purple{--tblr-focus-ring-color:rgba(var(--tblr-purple-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-pink{--tblr-focus-ring-color:rgba(var(--tblr-pink-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-red{--tblr-focus-ring-color:rgba(var(--tblr-red-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-orange{--tblr-focus-ring-color:rgba(var(--tblr-orange-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-yellow{--tblr-focus-ring-color:rgba(var(--tblr-yellow-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-lime{--tblr-focus-ring-color:rgba(var(--tblr-lime-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-green{--tblr-focus-ring-color:rgba(var(--tblr-green-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-teal{--tblr-focus-ring-color:rgba(var(--tblr-teal-rgb), var(--tblr-focus-ring-opacity))}.focus-ring-cyan{--tblr-focus-ring-color:rgba(var(--tblr-cyan-rgb), var(--tblr-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-wide{border:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-0{border:0!important}.border-top{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-top-wide{border-top:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-end-wide{border-right:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-bottom-wide{border-bottom:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-start-wide{border-left:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-start-0{border-left:0!important}.border-red{--tblr-border-opacity:1;border-color:rgba(var(--tblr-red-rgb),var(--tblr-border-opacity))!important}.border-green{--tblr-border-opacity:1;border-color:rgba(var(--tblr-green-rgb),var(--tblr-border-opacity))!important}.border-primary-subtle{border-color:var(--tblr-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--tblr-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--tblr-success-border-subtle)!important}.border-info-subtle{border-color:var(--tblr-info-border-subtle)!important}.border-warning-subtle{border-color:var(--tblr-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--tblr-danger-border-subtle)!important}.border-light-subtle{border-color:var(--tblr-light-border-subtle)!important}.border-dark-subtle{border-color:var(--tblr-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--tblr-border-opacity:0.1}.border-opacity-25{--tblr-border-opacity:0.25}.border-opacity-50{--tblr-border-opacity:0.5}.border-opacity-75{--tblr-border-opacity:0.75}.border-opacity-100{--tblr-border-opacity:1}.w-0{width:0!important}.w-1{width:.25rem!important}.w-2{width:.5rem!important}.w-3{width:1rem!important}.w-4{width:1.5rem!important}.w-5{width:2rem!important}.w-6{width:2.5rem!important}.w-25{width:25%!important}.w-33{width:33.33333%!important}.w-50{width:50%!important}.w-66{width:66.66666%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-0{height:0!important}.h-1{height:.25rem!important}.h-2{height:.5rem!important}.h-3{height:1rem!important}.h-4{height:1.5rem!important}.h-5{height:2rem!important}.h-6{height:2.5rem!important}.h-25{height:25%!important}.h-33{height:33.33333%!important}.h-50{height:50%!important}.h-66{height:66.66666%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:2rem!important}.m-6{margin:2.5rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:2rem!important;margin-left:2rem!important}.mx-6{margin-right:2.5rem!important;margin-left:2.5rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:2rem!important;margin-bottom:2rem!important}.my-6{margin-top:2.5rem!important;margin-bottom:2.5rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:2rem!important}.mt-6{margin-top:2.5rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:2rem!important}.me-6{margin-right:2.5rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:2rem!important}.mb-6{margin-bottom:2.5rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:2rem!important}.ms-6{margin-left:2.5rem!important}.ms-auto{margin-left:auto!important}.m-n1{margin:-.25rem!important}.m-n2{margin:-.5rem!important}.m-n3{margin:-1rem!important}.m-n4{margin:-1.5rem!important}.m-n5{margin:-2rem!important}.m-n6{margin:-2.5rem!important}.mx-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-n5{margin-right:-2rem!important;margin-left:-2rem!important}.mx-n6{margin-right:-2.5rem!important;margin-left:-2.5rem!important}.my-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-n5{margin-top:-2rem!important;margin-bottom:-2rem!important}.my-n6{margin-top:-2.5rem!important;margin-bottom:-2.5rem!important}.mt-n1{margin-top:-.25rem!important}.mt-n2{margin-top:-.5rem!important}.mt-n3{margin-top:-1rem!important}.mt-n4{margin-top:-1.5rem!important}.mt-n5{margin-top:-2rem!important}.mt-n6{margin-top:-2.5rem!important}.me-n1{margin-right:-.25rem!important}.me-n2{margin-right:-.5rem!important}.me-n3{margin-right:-1rem!important}.me-n4{margin-right:-1.5rem!important}.me-n5{margin-right:-2rem!important}.me-n6{margin-right:-2.5rem!important}.mb-n1{margin-bottom:-.25rem!important}.mb-n2{margin-bottom:-.5rem!important}.mb-n3{margin-bottom:-1rem!important}.mb-n4{margin-bottom:-1.5rem!important}.mb-n5{margin-bottom:-2rem!important}.mb-n6{margin-bottom:-2.5rem!important}.ms-n1{margin-left:-.25rem!important}.ms-n2{margin-left:-.5rem!important}.ms-n3{margin-left:-1rem!important}.ms-n4{margin-left:-1.5rem!important}.ms-n5{margin-left:-2rem!important}.ms-n6{margin-left:-2.5rem!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:2rem!important}.p-6{padding:2.5rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:2rem!important;padding-left:2rem!important}.px-6{padding-right:2.5rem!important;padding-left:2.5rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:2rem!important;padding-bottom:2rem!important}.py-6{padding-top:2.5rem!important;padding-bottom:2.5rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:2rem!important}.pt-6{padding-top:2.5rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:2rem!important}.pe-6{padding-right:2.5rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:2rem!important}.pb-6{padding-bottom:2.5rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:2rem!important}.ps-6{padding-left:2.5rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:2rem!important}.gap-6{gap:2.5rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:2rem!important}.row-gap-6{row-gap:2.5rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:2rem!important;column-gap:2rem!important}.column-gap-6{-moz-column-gap:2.5rem!important;column-gap:2.5rem!important}.font-monospace{font-family:var(--tblr-font-monospace)!important}.fs-1{font-size:1.5rem!important}.fs-2{font-size:1.25rem!important}.fs-3{font-size:1rem!important}.fs-4{font-size:.875rem!important}.fs-5{font-size:.75rem!important}.fs-6{font-size:.625rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:600!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.1428571429!important}.lh-base{line-height:1.4285714286!important}.lh-lg{line-height:1.7142857143!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--tblr-text-opacity:1;color:rgba(var(--tblr-primary-rgb),var(--tblr-text-opacity))!important}.text-secondary{--tblr-text-opacity:1;color:rgba(var(--tblr-secondary-rgb),var(--tblr-text-opacity))!important}.text-success{--tblr-text-opacity:1;color:rgba(var(--tblr-success-rgb),var(--tblr-text-opacity))!important}.text-info{--tblr-text-opacity:1;color:rgba(var(--tblr-info-rgb),var(--tblr-text-opacity))!important}.text-warning{--tblr-text-opacity:1;color:rgba(var(--tblr-warning-rgb),var(--tblr-text-opacity))!important}.text-danger{--tblr-text-opacity:1;color:rgba(var(--tblr-danger-rgb),var(--tblr-text-opacity))!important}.text-light{--tblr-text-opacity:1;color:rgba(var(--tblr-light-rgb),var(--tblr-text-opacity))!important}.text-dark{--tblr-text-opacity:1;color:rgba(var(--tblr-dark-rgb),var(--tblr-text-opacity))!important}.text-muted{--tblr-text-opacity:1;color:var(--tblr-secondary-color)!important}.text-blue{--tblr-text-opacity:1;color:rgba(var(--tblr-blue-rgb),var(--tblr-text-opacity))!important}.text-azure{--tblr-text-opacity:1;color:rgba(var(--tblr-azure-rgb),var(--tblr-text-opacity))!important}.text-indigo{--tblr-text-opacity:1;color:rgba(var(--tblr-indigo-rgb),var(--tblr-text-opacity))!important}.text-purple{--tblr-text-opacity:1;color:rgba(var(--tblr-purple-rgb),var(--tblr-text-opacity))!important}.text-pink{--tblr-text-opacity:1;color:rgba(var(--tblr-pink-rgb),var(--tblr-text-opacity))!important}.text-red{--tblr-text-opacity:1;color:rgba(var(--tblr-red-rgb),var(--tblr-text-opacity))!important}.text-orange{--tblr-text-opacity:1;color:rgba(var(--tblr-orange-rgb),var(--tblr-text-opacity))!important}.text-yellow{--tblr-text-opacity:1;color:rgba(var(--tblr-yellow-rgb),var(--tblr-text-opacity))!important}.text-lime{--tblr-text-opacity:1;color:rgba(var(--tblr-lime-rgb),var(--tblr-text-opacity))!important}.text-green{--tblr-text-opacity:1;color:rgba(var(--tblr-green-rgb),var(--tblr-text-opacity))!important}.text-teal{--tblr-text-opacity:1;color:rgba(var(--tblr-teal-rgb),var(--tblr-text-opacity))!important}.text-cyan{--tblr-text-opacity:1;color:rgba(var(--tblr-cyan-rgb),var(--tblr-text-opacity))!important}.text-black{--tblr-text-opacity:1;color:rgba(var(--tblr-black-rgb),var(--tblr-text-opacity))!important}.text-white{--tblr-text-opacity:1;color:rgba(var(--tblr-white-rgb),var(--tblr-text-opacity))!important}.text-body{--tblr-text-opacity:1;color:rgba(var(--tblr-body-color-rgb),var(--tblr-text-opacity))!important}.text-black-50{--tblr-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--tblr-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--tblr-text-opacity:1;color:var(--tblr-secondary-color)!important}.text-body-tertiary{--tblr-text-opacity:1;color:var(--tblr-tertiary-color)!important}.text-body-emphasis{--tblr-text-opacity:1;color:var(--tblr-emphasis-color)!important}.text-reset{--tblr-text-opacity:1;color:inherit!important}.text-opacity-25{--tblr-text-opacity:0.25}.text-opacity-50{--tblr-text-opacity:0.5}.text-opacity-75{--tblr-text-opacity:0.75}.text-opacity-100{--tblr-text-opacity:1}.text-primary-emphasis{color:var(--tblr-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--tblr-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--tblr-success-text-emphasis)!important}.text-info-emphasis{color:var(--tblr-info-text-emphasis)!important}.text-warning-emphasis{color:var(--tblr-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--tblr-danger-text-emphasis)!important}.text-light-emphasis{color:var(--tblr-light-text-emphasis)!important}.text-dark-emphasis{color:var(--tblr-dark-text-emphasis)!important}.link-opacity-10{--tblr-link-opacity:0.1}.link-opacity-10-hover:hover{--tblr-link-opacity:0.1}.link-opacity-25{--tblr-link-opacity:0.25}.link-opacity-25-hover:hover{--tblr-link-opacity:0.25}.link-opacity-50{--tblr-link-opacity:0.5}.link-opacity-50-hover:hover{--tblr-link-opacity:0.5}.link-opacity-75{--tblr-link-opacity:0.75}.link-opacity-75-hover:hover{--tblr-link-opacity:0.75}.link-opacity-100{--tblr-link-opacity:1}.link-opacity-100-hover:hover{--tblr-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-primary-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-secondary{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-secondary-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-success{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-success-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-info{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-info-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-warning{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-warning-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-danger{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-danger-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-light{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-light-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-dark{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-dark-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-muted{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-muted-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-blue{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-blue-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-azure{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-azure-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-indigo{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-indigo-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-purple{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-purple-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-pink{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-pink-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-red{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-red-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-orange{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-orange-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-yellow{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-yellow-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-lime{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-lime-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-green{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-green-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-teal{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-teal-rgb),var(--tblr-link-underline-opacity))!important}.link-underline-cyan{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-cyan-rgb),var(--tblr-link-underline-opacity))!important}.link-underline{--tblr-link-underline-opacity:1;text-decoration-color:rgba(var(--tblr-link-color-rgb),var(--tblr-link-underline-opacity,1))!important}.link-underline-opacity-0{--tblr-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--tblr-link-underline-opacity:0}.link-underline-opacity-10{--tblr-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--tblr-link-underline-opacity:0.1}.link-underline-opacity-25{--tblr-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--tblr-link-underline-opacity:0.25}.link-underline-opacity-50{--tblr-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--tblr-link-underline-opacity:0.5}.link-underline-opacity-75{--tblr-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--tblr-link-underline-opacity:0.75}.link-underline-opacity-100{--tblr-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--tblr-link-underline-opacity:1}.bg-primary{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-primary-rgb),var(--tblr-bg-opacity))!important}.bg-secondary{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-secondary-rgb),var(--tblr-bg-opacity))!important}.bg-success{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-success-rgb),var(--tblr-bg-opacity))!important}.bg-info{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-info-rgb),var(--tblr-bg-opacity))!important}.bg-warning{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-warning-rgb),var(--tblr-bg-opacity))!important}.bg-danger{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-danger-rgb),var(--tblr-bg-opacity))!important}.bg-light{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-light-rgb),var(--tblr-bg-opacity))!important}.bg-dark{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-dark-rgb),var(--tblr-bg-opacity))!important}.bg-muted{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-muted-rgb),var(--tblr-bg-opacity))!important}.bg-blue{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-blue-rgb),var(--tblr-bg-opacity))!important}.bg-azure{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-azure-rgb),var(--tblr-bg-opacity))!important}.bg-indigo{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-indigo-rgb),var(--tblr-bg-opacity))!important}.bg-purple{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-purple-rgb),var(--tblr-bg-opacity))!important}.bg-pink{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-pink-rgb),var(--tblr-bg-opacity))!important}.bg-red{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-red-rgb),var(--tblr-bg-opacity))!important}.bg-orange{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-orange-rgb),var(--tblr-bg-opacity))!important}.bg-yellow{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-yellow-rgb),var(--tblr-bg-opacity))!important}.bg-lime{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-lime-rgb),var(--tblr-bg-opacity))!important}.bg-green{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-green-rgb),var(--tblr-bg-opacity))!important}.bg-teal{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-teal-rgb),var(--tblr-bg-opacity))!important}.bg-cyan{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-cyan-rgb),var(--tblr-bg-opacity))!important}.bg-black{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-black-rgb),var(--tblr-bg-opacity))!important}.bg-white{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-white-rgb),var(--tblr-bg-opacity))!important}.bg-body{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-body-bg-rgb),var(--tblr-bg-opacity))!important}.bg-transparent{--tblr-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-secondary-bg-rgb),var(--tblr-bg-opacity))!important}.bg-body-tertiary{--tblr-bg-opacity:1;background-color:rgba(var(--tblr-tertiary-bg-rgb),var(--tblr-bg-opacity))!important}.bg-opacity-10{--tblr-bg-opacity:0.1}.bg-opacity-25{--tblr-bg-opacity:0.25}.bg-opacity-50{--tblr-bg-opacity:0.5}.bg-opacity-75{--tblr-bg-opacity:0.75}.bg-opacity-100{--tblr-bg-opacity:1}.bg-primary-subtle{background-color:var(--tblr-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--tblr-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--tblr-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--tblr-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--tblr-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--tblr-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--tblr-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--tblr-dark-bg-subtle)!important}.bg-gradient{background-image:var(--tblr-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--tblr-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--tblr-border-radius-sm)!important}.rounded-2{border-radius:var(--tblr-border-radius)!important}.rounded-3{border-radius:var(--tblr-border-radius-lg)!important}.rounded-4{border-radius:var(--tblr-border-radius-xl)!important}.rounded-5{border-radius:var(--tblr-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--tblr-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--tblr-border-radius)!important;border-top-right-radius:var(--tblr-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--tblr-border-radius-sm)!important;border-top-right-radius:var(--tblr-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--tblr-border-radius)!important;border-top-right-radius:var(--tblr-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--tblr-border-radius-lg)!important;border-top-right-radius:var(--tblr-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--tblr-border-radius-xl)!important;border-top-right-radius:var(--tblr-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--tblr-border-radius-xxl)!important;border-top-right-radius:var(--tblr-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--tblr-border-radius-pill)!important;border-top-right-radius:var(--tblr-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--tblr-border-radius)!important;border-bottom-right-radius:var(--tblr-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--tblr-border-radius-sm)!important;border-bottom-right-radius:var(--tblr-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--tblr-border-radius)!important;border-bottom-right-radius:var(--tblr-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--tblr-border-radius-lg)!important;border-bottom-right-radius:var(--tblr-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--tblr-border-radius-xl)!important;border-bottom-right-radius:var(--tblr-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--tblr-border-radius-xxl)!important;border-bottom-right-radius:var(--tblr-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--tblr-border-radius-pill)!important;border-bottom-right-radius:var(--tblr-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--tblr-border-radius)!important;border-bottom-left-radius:var(--tblr-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--tblr-border-radius-sm)!important;border-bottom-left-radius:var(--tblr-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--tblr-border-radius)!important;border-bottom-left-radius:var(--tblr-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--tblr-border-radius-lg)!important;border-bottom-left-radius:var(--tblr-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--tblr-border-radius-xl)!important;border-bottom-left-radius:var(--tblr-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--tblr-border-radius-xxl)!important;border-bottom-left-radius:var(--tblr-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--tblr-border-radius-pill)!important;border-bottom-left-radius:var(--tblr-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--tblr-border-radius)!important;border-top-left-radius:var(--tblr-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--tblr-border-radius-sm)!important;border-top-left-radius:var(--tblr-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--tblr-border-radius)!important;border-top-left-radius:var(--tblr-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--tblr-border-radius-lg)!important;border-top-left-radius:var(--tblr-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--tblr-border-radius-xl)!important;border-top-left-radius:var(--tblr-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--tblr-border-radius-xxl)!important;border-top-left-radius:var(--tblr-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--tblr-border-radius-pill)!important;border-top-left-radius:var(--tblr-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}.object-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-scale-down{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-none{-o-object-fit:none!important;object-fit:none!important}.cursor-auto{cursor:auto!important}.cursor-pointer{cursor:pointer!important}.cursor-move{cursor:move!important}.cursor-not-allowed{cursor:not-allowed!important}.cursor-zoom-in{cursor:zoom-in!important}.cursor-zoom-out{cursor:zoom-out!important}.cursor-default{cursor:default!important}.cursor-none{cursor:none!important}.cursor-help{cursor:help!important}.cursor-progress{cursor:progress!important}.cursor-wait{cursor:wait!important}.cursor-text{cursor:text!important}.cursor-v-text{cursor:vertical-text!important}.cursor-grab{cursor:grab!important}.cursor-grabbing{cursor:grabbing!important}.cursor-crosshair{cursor:crosshair!important}.border-x{border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important;border-right:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-x-wide{border-left:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important;border-right:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-x-0{border-left:0!important;border-right:0!important}.border-y{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important;border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-y-wide{border-top:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important;border-bottom:2px var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.border-y-0{border-top:0!important;border-bottom:0!important}.columns-2{-moz-columns:2!important;columns:2!important}.columns-3{-moz-columns:3!important;columns:3!important}.columns-4{-moz-columns:4!important;columns:4!important}.bg-pattern-transparent{background:url('data:image/svg+xml;charset=UTF-8,') repeat center/16px 16px!important}.bg-gradient{background:linear-gradient(var(--tblr-gradient-direction,to right),var(--tblr-gradient-stops,var(--tblr-gradient-from,transparent),var(--tblr-gradient-to,transparent))) no-repeat!important}.bg-gradient-to-t{--tblr-gradient-direction:to top!important}.bg-gradient-to-te{--tblr-gradient-direction:to top right!important}.bg-gradient-to-e{--tblr-gradient-direction:to right!important}.bg-gradient-to-be{--tblr-gradient-direction:to bottom right!important}.bg-gradient-to-b{--tblr-gradient-direction:to bottom!important}.bg-gradient-to-bs{--tblr-gradient-direction:to bottom left!important}.bg-gradient-to-s{--tblr-gradient-direction:to left!important}.bg-gradient-to-ts{--tblr-gradient-direction:to top left!important}.table-auto{table-layout:auto!important}.table-fixed{table-layout:fixed!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:2rem!important}.m-sm-6{margin:2.5rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:2rem!important;margin-left:2rem!important}.mx-sm-6{margin-right:2.5rem!important;margin-left:2.5rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:2rem!important;margin-bottom:2rem!important}.my-sm-6{margin-top:2.5rem!important;margin-bottom:2.5rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:2rem!important}.mt-sm-6{margin-top:2.5rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:2rem!important}.me-sm-6{margin-right:2.5rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:2rem!important}.mb-sm-6{margin-bottom:2.5rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:2rem!important}.ms-sm-6{margin-left:2.5rem!important}.ms-sm-auto{margin-left:auto!important}.m-sm-n1{margin:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.m-sm-n3{margin:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.m-sm-n5{margin:-2rem!important}.m-sm-n6{margin:-2.5rem!important}.mx-sm-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-sm-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-sm-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-sm-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-sm-n5{margin-right:-2rem!important;margin-left:-2rem!important}.mx-sm-n6{margin-right:-2.5rem!important;margin-left:-2.5rem!important}.my-sm-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-sm-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-sm-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-sm-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-sm-n5{margin-top:-2rem!important;margin-bottom:-2rem!important}.my-sm-n6{margin-top:-2.5rem!important;margin-bottom:-2.5rem!important}.mt-sm-n1{margin-top:-.25rem!important}.mt-sm-n2{margin-top:-.5rem!important}.mt-sm-n3{margin-top:-1rem!important}.mt-sm-n4{margin-top:-1.5rem!important}.mt-sm-n5{margin-top:-2rem!important}.mt-sm-n6{margin-top:-2.5rem!important}.me-sm-n1{margin-right:-.25rem!important}.me-sm-n2{margin-right:-.5rem!important}.me-sm-n3{margin-right:-1rem!important}.me-sm-n4{margin-right:-1.5rem!important}.me-sm-n5{margin-right:-2rem!important}.me-sm-n6{margin-right:-2.5rem!important}.mb-sm-n1{margin-bottom:-.25rem!important}.mb-sm-n2{margin-bottom:-.5rem!important}.mb-sm-n3{margin-bottom:-1rem!important}.mb-sm-n4{margin-bottom:-1.5rem!important}.mb-sm-n5{margin-bottom:-2rem!important}.mb-sm-n6{margin-bottom:-2.5rem!important}.ms-sm-n1{margin-left:-.25rem!important}.ms-sm-n2{margin-left:-.5rem!important}.ms-sm-n3{margin-left:-1rem!important}.ms-sm-n4{margin-left:-1.5rem!important}.ms-sm-n5{margin-left:-2rem!important}.ms-sm-n6{margin-left:-2.5rem!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:2rem!important}.p-sm-6{padding:2.5rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:2rem!important;padding-left:2rem!important}.px-sm-6{padding-right:2.5rem!important;padding-left:2.5rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:2rem!important;padding-bottom:2rem!important}.py-sm-6{padding-top:2.5rem!important;padding-bottom:2.5rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:2rem!important}.pt-sm-6{padding-top:2.5rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:2rem!important}.pe-sm-6{padding-right:2.5rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:2rem!important}.pb-sm-6{padding-bottom:2.5rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:2rem!important}.ps-sm-6{padding-left:2.5rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:2rem!important}.gap-sm-6{gap:2.5rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:2rem!important}.row-gap-sm-6{row-gap:2.5rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:2rem!important;column-gap:2rem!important}.column-gap-sm-6{-moz-column-gap:2.5rem!important;column-gap:2.5rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}.columns-sm-2{-moz-columns:2!important;columns:2!important}.columns-sm-3{-moz-columns:3!important;columns:3!important}.columns-sm-4{-moz-columns:4!important;columns:4!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:2rem!important}.m-md-6{margin:2.5rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:2rem!important;margin-left:2rem!important}.mx-md-6{margin-right:2.5rem!important;margin-left:2.5rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:2rem!important;margin-bottom:2rem!important}.my-md-6{margin-top:2.5rem!important;margin-bottom:2.5rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:2rem!important}.mt-md-6{margin-top:2.5rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:2rem!important}.me-md-6{margin-right:2.5rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:2rem!important}.mb-md-6{margin-bottom:2.5rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:2rem!important}.ms-md-6{margin-left:2.5rem!important}.ms-md-auto{margin-left:auto!important}.m-md-n1{margin:-.25rem!important}.m-md-n2{margin:-.5rem!important}.m-md-n3{margin:-1rem!important}.m-md-n4{margin:-1.5rem!important}.m-md-n5{margin:-2rem!important}.m-md-n6{margin:-2.5rem!important}.mx-md-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-md-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-md-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-md-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-md-n5{margin-right:-2rem!important;margin-left:-2rem!important}.mx-md-n6{margin-right:-2.5rem!important;margin-left:-2.5rem!important}.my-md-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-md-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-md-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-md-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-md-n5{margin-top:-2rem!important;margin-bottom:-2rem!important}.my-md-n6{margin-top:-2.5rem!important;margin-bottom:-2.5rem!important}.mt-md-n1{margin-top:-.25rem!important}.mt-md-n2{margin-top:-.5rem!important}.mt-md-n3{margin-top:-1rem!important}.mt-md-n4{margin-top:-1.5rem!important}.mt-md-n5{margin-top:-2rem!important}.mt-md-n6{margin-top:-2.5rem!important}.me-md-n1{margin-right:-.25rem!important}.me-md-n2{margin-right:-.5rem!important}.me-md-n3{margin-right:-1rem!important}.me-md-n4{margin-right:-1.5rem!important}.me-md-n5{margin-right:-2rem!important}.me-md-n6{margin-right:-2.5rem!important}.mb-md-n1{margin-bottom:-.25rem!important}.mb-md-n2{margin-bottom:-.5rem!important}.mb-md-n3{margin-bottom:-1rem!important}.mb-md-n4{margin-bottom:-1.5rem!important}.mb-md-n5{margin-bottom:-2rem!important}.mb-md-n6{margin-bottom:-2.5rem!important}.ms-md-n1{margin-left:-.25rem!important}.ms-md-n2{margin-left:-.5rem!important}.ms-md-n3{margin-left:-1rem!important}.ms-md-n4{margin-left:-1.5rem!important}.ms-md-n5{margin-left:-2rem!important}.ms-md-n6{margin-left:-2.5rem!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:2rem!important}.p-md-6{padding:2.5rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:2rem!important;padding-left:2rem!important}.px-md-6{padding-right:2.5rem!important;padding-left:2.5rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:2rem!important;padding-bottom:2rem!important}.py-md-6{padding-top:2.5rem!important;padding-bottom:2.5rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:2rem!important}.pt-md-6{padding-top:2.5rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:2rem!important}.pe-md-6{padding-right:2.5rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:2rem!important}.pb-md-6{padding-bottom:2.5rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:2rem!important}.ps-md-6{padding-left:2.5rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:2rem!important}.gap-md-6{gap:2.5rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:2rem!important}.row-gap-md-6{row-gap:2.5rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:2rem!important;column-gap:2rem!important}.column-gap-md-6{-moz-column-gap:2.5rem!important;column-gap:2.5rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}.columns-md-2{-moz-columns:2!important;columns:2!important}.columns-md-3{-moz-columns:3!important;columns:3!important}.columns-md-4{-moz-columns:4!important;columns:4!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:2rem!important}.m-lg-6{margin:2.5rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:2rem!important;margin-left:2rem!important}.mx-lg-6{margin-right:2.5rem!important;margin-left:2.5rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:2rem!important;margin-bottom:2rem!important}.my-lg-6{margin-top:2.5rem!important;margin-bottom:2.5rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:2rem!important}.mt-lg-6{margin-top:2.5rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:2rem!important}.me-lg-6{margin-right:2.5rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:2rem!important}.mb-lg-6{margin-bottom:2.5rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:2rem!important}.ms-lg-6{margin-left:2.5rem!important}.ms-lg-auto{margin-left:auto!important}.m-lg-n1{margin:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.m-lg-n3{margin:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.m-lg-n5{margin:-2rem!important}.m-lg-n6{margin:-2.5rem!important}.mx-lg-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-lg-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-lg-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-lg-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-lg-n5{margin-right:-2rem!important;margin-left:-2rem!important}.mx-lg-n6{margin-right:-2.5rem!important;margin-left:-2.5rem!important}.my-lg-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-lg-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-lg-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-lg-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-lg-n5{margin-top:-2rem!important;margin-bottom:-2rem!important}.my-lg-n6{margin-top:-2.5rem!important;margin-bottom:-2.5rem!important}.mt-lg-n1{margin-top:-.25rem!important}.mt-lg-n2{margin-top:-.5rem!important}.mt-lg-n3{margin-top:-1rem!important}.mt-lg-n4{margin-top:-1.5rem!important}.mt-lg-n5{margin-top:-2rem!important}.mt-lg-n6{margin-top:-2.5rem!important}.me-lg-n1{margin-right:-.25rem!important}.me-lg-n2{margin-right:-.5rem!important}.me-lg-n3{margin-right:-1rem!important}.me-lg-n4{margin-right:-1.5rem!important}.me-lg-n5{margin-right:-2rem!important}.me-lg-n6{margin-right:-2.5rem!important}.mb-lg-n1{margin-bottom:-.25rem!important}.mb-lg-n2{margin-bottom:-.5rem!important}.mb-lg-n3{margin-bottom:-1rem!important}.mb-lg-n4{margin-bottom:-1.5rem!important}.mb-lg-n5{margin-bottom:-2rem!important}.mb-lg-n6{margin-bottom:-2.5rem!important}.ms-lg-n1{margin-left:-.25rem!important}.ms-lg-n2{margin-left:-.5rem!important}.ms-lg-n3{margin-left:-1rem!important}.ms-lg-n4{margin-left:-1.5rem!important}.ms-lg-n5{margin-left:-2rem!important}.ms-lg-n6{margin-left:-2.5rem!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:2rem!important}.p-lg-6{padding:2.5rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:2rem!important;padding-left:2rem!important}.px-lg-6{padding-right:2.5rem!important;padding-left:2.5rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:2rem!important;padding-bottom:2rem!important}.py-lg-6{padding-top:2.5rem!important;padding-bottom:2.5rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:2rem!important}.pt-lg-6{padding-top:2.5rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:2rem!important}.pe-lg-6{padding-right:2.5rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:2rem!important}.pb-lg-6{padding-bottom:2.5rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:2rem!important}.ps-lg-6{padding-left:2.5rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:2rem!important}.gap-lg-6{gap:2.5rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:2rem!important}.row-gap-lg-6{row-gap:2.5rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:2rem!important;column-gap:2rem!important}.column-gap-lg-6{-moz-column-gap:2.5rem!important;column-gap:2.5rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}.columns-lg-2{-moz-columns:2!important;columns:2!important}.columns-lg-3{-moz-columns:3!important;columns:3!important}.columns-lg-4{-moz-columns:4!important;columns:4!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:2rem!important}.m-xl-6{margin:2.5rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:2rem!important;margin-left:2rem!important}.mx-xl-6{margin-right:2.5rem!important;margin-left:2.5rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:2rem!important;margin-bottom:2rem!important}.my-xl-6{margin-top:2.5rem!important;margin-bottom:2.5rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:2rem!important}.mt-xl-6{margin-top:2.5rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:2rem!important}.me-xl-6{margin-right:2.5rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:2rem!important}.mb-xl-6{margin-bottom:2.5rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:2rem!important}.ms-xl-6{margin-left:2.5rem!important}.ms-xl-auto{margin-left:auto!important}.m-xl-n1{margin:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.m-xl-n3{margin:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.m-xl-n5{margin:-2rem!important}.m-xl-n6{margin:-2.5rem!important}.mx-xl-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-xl-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-xl-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-xl-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-xl-n5{margin-right:-2rem!important;margin-left:-2rem!important}.mx-xl-n6{margin-right:-2.5rem!important;margin-left:-2.5rem!important}.my-xl-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-xl-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-xl-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-xl-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-xl-n5{margin-top:-2rem!important;margin-bottom:-2rem!important}.my-xl-n6{margin-top:-2.5rem!important;margin-bottom:-2.5rem!important}.mt-xl-n1{margin-top:-.25rem!important}.mt-xl-n2{margin-top:-.5rem!important}.mt-xl-n3{margin-top:-1rem!important}.mt-xl-n4{margin-top:-1.5rem!important}.mt-xl-n5{margin-top:-2rem!important}.mt-xl-n6{margin-top:-2.5rem!important}.me-xl-n1{margin-right:-.25rem!important}.me-xl-n2{margin-right:-.5rem!important}.me-xl-n3{margin-right:-1rem!important}.me-xl-n4{margin-right:-1.5rem!important}.me-xl-n5{margin-right:-2rem!important}.me-xl-n6{margin-right:-2.5rem!important}.mb-xl-n1{margin-bottom:-.25rem!important}.mb-xl-n2{margin-bottom:-.5rem!important}.mb-xl-n3{margin-bottom:-1rem!important}.mb-xl-n4{margin-bottom:-1.5rem!important}.mb-xl-n5{margin-bottom:-2rem!important}.mb-xl-n6{margin-bottom:-2.5rem!important}.ms-xl-n1{margin-left:-.25rem!important}.ms-xl-n2{margin-left:-.5rem!important}.ms-xl-n3{margin-left:-1rem!important}.ms-xl-n4{margin-left:-1.5rem!important}.ms-xl-n5{margin-left:-2rem!important}.ms-xl-n6{margin-left:-2.5rem!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:2rem!important}.p-xl-6{padding:2.5rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:2rem!important;padding-left:2rem!important}.px-xl-6{padding-right:2.5rem!important;padding-left:2.5rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:2rem!important;padding-bottom:2rem!important}.py-xl-6{padding-top:2.5rem!important;padding-bottom:2.5rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:2rem!important}.pt-xl-6{padding-top:2.5rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:2rem!important}.pe-xl-6{padding-right:2.5rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:2rem!important}.pb-xl-6{padding-bottom:2.5rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:2rem!important}.ps-xl-6{padding-left:2.5rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:2rem!important}.gap-xl-6{gap:2.5rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:2rem!important}.row-gap-xl-6{row-gap:2.5rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:2rem!important;column-gap:2rem!important}.column-gap-xl-6{-moz-column-gap:2.5rem!important;column-gap:2.5rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}.columns-xl-2{-moz-columns:2!important;columns:2!important}.columns-xl-3{-moz-columns:3!important;columns:3!important}.columns-xl-4{-moz-columns:4!important;columns:4!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:2rem!important}.m-xxl-6{margin:2.5rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:2rem!important;margin-left:2rem!important}.mx-xxl-6{margin-right:2.5rem!important;margin-left:2.5rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:2rem!important;margin-bottom:2rem!important}.my-xxl-6{margin-top:2.5rem!important;margin-bottom:2.5rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:2rem!important}.mt-xxl-6{margin-top:2.5rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:2rem!important}.me-xxl-6{margin-right:2.5rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:2rem!important}.mb-xxl-6{margin-bottom:2.5rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:2rem!important}.ms-xxl-6{margin-left:2.5rem!important}.ms-xxl-auto{margin-left:auto!important}.m-xxl-n1{margin:-.25rem!important}.m-xxl-n2{margin:-.5rem!important}.m-xxl-n3{margin:-1rem!important}.m-xxl-n4{margin:-1.5rem!important}.m-xxl-n5{margin:-2rem!important}.m-xxl-n6{margin:-2.5rem!important}.mx-xxl-n1{margin-right:-.25rem!important;margin-left:-.25rem!important}.mx-xxl-n2{margin-right:-.5rem!important;margin-left:-.5rem!important}.mx-xxl-n3{margin-right:-1rem!important;margin-left:-1rem!important}.mx-xxl-n4{margin-right:-1.5rem!important;margin-left:-1.5rem!important}.mx-xxl-n5{margin-right:-2rem!important;margin-left:-2rem!important}.mx-xxl-n6{margin-right:-2.5rem!important;margin-left:-2.5rem!important}.my-xxl-n1{margin-top:-.25rem!important;margin-bottom:-.25rem!important}.my-xxl-n2{margin-top:-.5rem!important;margin-bottom:-.5rem!important}.my-xxl-n3{margin-top:-1rem!important;margin-bottom:-1rem!important}.my-xxl-n4{margin-top:-1.5rem!important;margin-bottom:-1.5rem!important}.my-xxl-n5{margin-top:-2rem!important;margin-bottom:-2rem!important}.my-xxl-n6{margin-top:-2.5rem!important;margin-bottom:-2.5rem!important}.mt-xxl-n1{margin-top:-.25rem!important}.mt-xxl-n2{margin-top:-.5rem!important}.mt-xxl-n3{margin-top:-1rem!important}.mt-xxl-n4{margin-top:-1.5rem!important}.mt-xxl-n5{margin-top:-2rem!important}.mt-xxl-n6{margin-top:-2.5rem!important}.me-xxl-n1{margin-right:-.25rem!important}.me-xxl-n2{margin-right:-.5rem!important}.me-xxl-n3{margin-right:-1rem!important}.me-xxl-n4{margin-right:-1.5rem!important}.me-xxl-n5{margin-right:-2rem!important}.me-xxl-n6{margin-right:-2.5rem!important}.mb-xxl-n1{margin-bottom:-.25rem!important}.mb-xxl-n2{margin-bottom:-.5rem!important}.mb-xxl-n3{margin-bottom:-1rem!important}.mb-xxl-n4{margin-bottom:-1.5rem!important}.mb-xxl-n5{margin-bottom:-2rem!important}.mb-xxl-n6{margin-bottom:-2.5rem!important}.ms-xxl-n1{margin-left:-.25rem!important}.ms-xxl-n2{margin-left:-.5rem!important}.ms-xxl-n3{margin-left:-1rem!important}.ms-xxl-n4{margin-left:-1.5rem!important}.ms-xxl-n5{margin-left:-2rem!important}.ms-xxl-n6{margin-left:-2.5rem!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:2rem!important}.p-xxl-6{padding:2.5rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:2rem!important;padding-left:2rem!important}.px-xxl-6{padding-right:2.5rem!important;padding-left:2.5rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:2rem!important;padding-bottom:2rem!important}.py-xxl-6{padding-top:2.5rem!important;padding-bottom:2.5rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:2rem!important}.pt-xxl-6{padding-top:2.5rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:2rem!important}.pe-xxl-6{padding-right:2.5rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:2rem!important}.pb-xxl-6{padding-bottom:2.5rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:2rem!important}.ps-xxl-6{padding-left:2.5rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:2rem!important}.gap-xxl-6{gap:2.5rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:2rem!important}.row-gap-xxl-6{row-gap:2.5rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:2rem!important;column-gap:2rem!important}.column-gap-xxl-6{-moz-column-gap:2.5rem!important;column-gap:2.5rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}.columns-xxl-2{-moz-columns:2!important;columns:2!important}.columns-xxl-3{-moz-columns:3!important;columns:3!important}.columns-xxl-4{-moz-columns:4!important;columns:4!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}:host,:root{--tblr-font-monospace:Monaco,Consolas,Liberation Mono,Courier New,monospace;--tblr-font-sans-serif:Inter Var,Inter,-apple-system,BlinkMacSystemFont,San Francisco,Segoe UI,Roboto,Helvetica Neue,sans-serif;--tblr-font-serif:Georgia,Times New Roman,times,serif;--tblr-font-comic:Comic Sans MS,Comic Sans,Chalkboard SE,Comic Neue,sans-serif,cursive;--tblr-gray-50:#f9fafb;--tblr-gray-100:#f3f4f6;--tblr-gray-200:#e5e7eb;--tblr-gray-300:#d1d5db;--tblr-gray-400:#9ca3af;--tblr-gray-500:#6b7280;--tblr-gray-600:#4b5563;--tblr-gray-700:#374151;--tblr-gray-800:#1f2937;--tblr-gray-900:#111827;--tblr-gray-950:#030712;--tblr-white:#ffffff;--tblr-black:#000000;--tblr-dark:#1f2937;--tblr-light:#f9fafb;--tblr-brand:#066fd1;--tblr-primary:#066fd1;--tblr-primary-rgb:6,111,209;--tblr-primary-fg:var(--tblr-light);--tblr-primary-darken:rgb(5.4, 99.9, 188.1);--tblr-primary-darken:color-mix(in oklab, var(--tblr-primary), transparent 20%);--tblr-primary-lt:rgb(230.1, 240.6, 250.4);--tblr-primary-lt:color-mix(in oklab, var(--tblr-primary) 10%, transparent);--tblr-primary-200:color-mix(in oklab, var(--tblr-primary) 20%, transparent);--tblr-primary-lt-rgb:230,241,250;--tblr-secondary:#6b7280;--tblr-secondary-rgb:107,114,128;--tblr-secondary-fg:var(--tblr-light);--tblr-secondary-darken:rgb(96.3, 102.6, 115.2);--tblr-secondary-darken:color-mix(in oklab, var(--tblr-secondary), transparent 20%);--tblr-secondary-lt:rgb(240.2, 240.9, 242.3);--tblr-secondary-lt:color-mix(in oklab, var(--tblr-secondary) 10%, transparent);--tblr-secondary-200:color-mix(in oklab, var(--tblr-secondary) 20%, transparent);--tblr-secondary-lt-rgb:240,241,242;--tblr-success:#2fb344;--tblr-success-rgb:47,179,68;--tblr-success-fg:var(--tblr-light);--tblr-success-darken:rgb(42.3, 161.1, 61.2);--tblr-success-darken:color-mix(in oklab, var(--tblr-success), transparent 20%);--tblr-success-lt:rgb(234.2, 247.4, 236.3);--tblr-success-lt:color-mix(in oklab, var(--tblr-success) 10%, transparent);--tblr-success-200:color-mix(in oklab, var(--tblr-success) 20%, transparent);--tblr-success-lt-rgb:234,247,236;--tblr-info:#4299e1;--tblr-info-rgb:66,153,225;--tblr-info-fg:var(--tblr-light);--tblr-info-darken:rgb(59.4, 137.7, 202.5);--tblr-info-darken:color-mix(in oklab, var(--tblr-info), transparent 20%);--tblr-info-lt:rgb(236.1, 244.8, 252);--tblr-info-lt:color-mix(in oklab, var(--tblr-info) 10%, transparent);--tblr-info-200:color-mix(in oklab, var(--tblr-info) 20%, transparent);--tblr-info-lt-rgb:236,245,252;--tblr-warning:#f59f00;--tblr-warning-rgb:245,159,0;--tblr-warning-fg:var(--tblr-light);--tblr-warning-darken:rgb(220.5, 143.1, 0);--tblr-warning-darken:color-mix(in oklab, var(--tblr-warning), transparent 20%);--tblr-warning-lt:rgb(254, 245.4, 229.5);--tblr-warning-lt:color-mix(in oklab, var(--tblr-warning) 10%, transparent);--tblr-warning-200:color-mix(in oklab, var(--tblr-warning) 20%, transparent);--tblr-warning-lt-rgb:254,245,230;--tblr-danger:#d63939;--tblr-danger-rgb:214,57,57;--tblr-danger-fg:var(--tblr-light);--tblr-danger-darken:rgb(192.6, 51.3, 51.3);--tblr-danger-darken:color-mix(in oklab, var(--tblr-danger), transparent 20%);--tblr-danger-lt:rgb(250.9, 235.2, 235.2);--tblr-danger-lt:color-mix(in oklab, var(--tblr-danger) 10%, transparent);--tblr-danger-200:color-mix(in oklab, var(--tblr-danger) 20%, transparent);--tblr-danger-lt-rgb:251,235,235;--tblr-light:#f9fafb;--tblr-light-rgb:249,250,251;--tblr-light-fg:var(--tblr-dark);--tblr-light-darken:rgb(224.1, 225, 225.9);--tblr-light-darken:color-mix(in oklab, var(--tblr-light), transparent 20%);--tblr-light-lt:rgb(254.4, 254.5, 254.6);--tblr-light-lt:color-mix(in oklab, var(--tblr-light) 10%, transparent);--tblr-light-200:color-mix(in oklab, var(--tblr-light) 20%, transparent);--tblr-light-lt-rgb:254,255,255;--tblr-dark:#1f2937;--tblr-dark-rgb:31,41,55;--tblr-dark-fg:var(--tblr-light);--tblr-dark-darken:rgb(27.9, 36.9, 49.5);--tblr-dark-darken:color-mix(in oklab, var(--tblr-dark), transparent 20%);--tblr-dark-lt:rgb(232.6, 233.6, 235);--tblr-dark-lt:color-mix(in oklab, var(--tblr-dark) 10%, transparent);--tblr-dark-200:color-mix(in oklab, var(--tblr-dark) 20%, transparent);--tblr-dark-lt-rgb:233,234,235;--tblr-muted:#6b7280;--tblr-muted-rgb:107,114,128;--tblr-muted-fg:var(--tblr-light);--tblr-muted-darken:rgb(96.3, 102.6, 115.2);--tblr-muted-darken:color-mix(in oklab, var(--tblr-muted), transparent 20%);--tblr-muted-lt:rgb(240.2, 240.9, 242.3);--tblr-muted-lt:color-mix(in oklab, var(--tblr-muted) 10%, transparent);--tblr-muted-200:color-mix(in oklab, var(--tblr-muted) 20%, transparent);--tblr-muted-lt-rgb:240,241,242;--tblr-blue:#066fd1;--tblr-blue-rgb:6,111,209;--tblr-blue-fg:var(--tblr-light);--tblr-blue-darken:rgb(5.4, 99.9, 188.1);--tblr-blue-darken:color-mix(in oklab, var(--tblr-blue), transparent 20%);--tblr-blue-lt:rgb(230.1, 240.6, 250.4);--tblr-blue-lt:color-mix(in oklab, var(--tblr-blue) 10%, transparent);--tblr-blue-200:color-mix(in oklab, var(--tblr-blue) 20%, transparent);--tblr-blue-lt-rgb:230,241,250;--tblr-azure:#4299e1;--tblr-azure-rgb:66,153,225;--tblr-azure-fg:var(--tblr-light);--tblr-azure-darken:rgb(59.4, 137.7, 202.5);--tblr-azure-darken:color-mix(in oklab, var(--tblr-azure), transparent 20%);--tblr-azure-lt:rgb(236.1, 244.8, 252);--tblr-azure-lt:color-mix(in oklab, var(--tblr-azure) 10%, transparent);--tblr-azure-200:color-mix(in oklab, var(--tblr-azure) 20%, transparent);--tblr-azure-lt-rgb:236,245,252;--tblr-indigo:#4263eb;--tblr-indigo-rgb:66,99,235;--tblr-indigo-fg:var(--tblr-light);--tblr-indigo-darken:rgb(59.4, 89.1, 211.5);--tblr-indigo-darken:color-mix(in oklab, var(--tblr-indigo), transparent 20%);--tblr-indigo-lt:rgb(236.1, 239.4, 253);--tblr-indigo-lt:color-mix(in oklab, var(--tblr-indigo) 10%, transparent);--tblr-indigo-200:color-mix(in oklab, var(--tblr-indigo) 20%, transparent);--tblr-indigo-lt-rgb:236,239,253;--tblr-purple:#ae3ec9;--tblr-purple-rgb:174,62,201;--tblr-purple-fg:var(--tblr-light);--tblr-purple-darken:rgb(156.6, 55.8, 180.9);--tblr-purple-darken:color-mix(in oklab, var(--tblr-purple), transparent 20%);--tblr-purple-lt:rgb(246.9, 235.7, 249.6);--tblr-purple-lt:color-mix(in oklab, var(--tblr-purple) 10%, transparent);--tblr-purple-200:color-mix(in oklab, var(--tblr-purple) 20%, transparent);--tblr-purple-lt-rgb:247,236,250;--tblr-pink:#d6336c;--tblr-pink-rgb:214,51,108;--tblr-pink-fg:var(--tblr-light);--tblr-pink-darken:rgb(192.6, 45.9, 97.2);--tblr-pink-darken:color-mix(in oklab, var(--tblr-pink), transparent 20%);--tblr-pink-lt:rgb(250.9, 234.6, 240.3);--tblr-pink-lt:color-mix(in oklab, var(--tblr-pink) 10%, transparent);--tblr-pink-200:color-mix(in oklab, var(--tblr-pink) 20%, transparent);--tblr-pink-lt-rgb:251,235,240;--tblr-red:#d63939;--tblr-red-rgb:214,57,57;--tblr-red-fg:var(--tblr-light);--tblr-red-darken:rgb(192.6, 51.3, 51.3);--tblr-red-darken:color-mix(in oklab, var(--tblr-red), transparent 20%);--tblr-red-lt:rgb(250.9, 235.2, 235.2);--tblr-red-lt:color-mix(in oklab, var(--tblr-red) 10%, transparent);--tblr-red-200:color-mix(in oklab, var(--tblr-red) 20%, transparent);--tblr-red-lt-rgb:251,235,235;--tblr-orange:#f76707;--tblr-orange-rgb:247,103,7;--tblr-orange-fg:var(--tblr-light);--tblr-orange-darken:rgb(222.3, 92.7, 6.3);--tblr-orange-darken:color-mix(in oklab, var(--tblr-orange), transparent 20%);--tblr-orange-lt:rgb(254.2, 239.8, 230.2);--tblr-orange-lt:color-mix(in oklab, var(--tblr-orange) 10%, transparent);--tblr-orange-200:color-mix(in oklab, var(--tblr-orange) 20%, transparent);--tblr-orange-lt-rgb:254,240,230;--tblr-yellow:#f59f00;--tblr-yellow-rgb:245,159,0;--tblr-yellow-fg:var(--tblr-light);--tblr-yellow-darken:rgb(220.5, 143.1, 0);--tblr-yellow-darken:color-mix(in oklab, var(--tblr-yellow), transparent 20%);--tblr-yellow-lt:rgb(254, 245.4, 229.5);--tblr-yellow-lt:color-mix(in oklab, var(--tblr-yellow) 10%, transparent);--tblr-yellow-200:color-mix(in oklab, var(--tblr-yellow) 20%, transparent);--tblr-yellow-lt-rgb:254,245,230;--tblr-lime:#74b816;--tblr-lime-rgb:116,184,22;--tblr-lime-fg:var(--tblr-light);--tblr-lime-darken:rgb(104.4, 165.6, 19.8);--tblr-lime-darken:color-mix(in oklab, var(--tblr-lime), transparent 20%);--tblr-lime-lt:rgb(241.1, 247.9, 231.7);--tblr-lime-lt:color-mix(in oklab, var(--tblr-lime) 10%, transparent);--tblr-lime-200:color-mix(in oklab, var(--tblr-lime) 20%, transparent);--tblr-lime-lt-rgb:241,248,232;--tblr-green:#2fb344;--tblr-green-rgb:47,179,68;--tblr-green-fg:var(--tblr-light);--tblr-green-darken:rgb(42.3, 161.1, 61.2);--tblr-green-darken:color-mix(in oklab, var(--tblr-green), transparent 20%);--tblr-green-lt:rgb(234.2, 247.4, 236.3);--tblr-green-lt:color-mix(in oklab, var(--tblr-green) 10%, transparent);--tblr-green-200:color-mix(in oklab, var(--tblr-green) 20%, transparent);--tblr-green-lt-rgb:234,247,236;--tblr-teal:#0ca678;--tblr-teal-rgb:12,166,120;--tblr-teal-fg:var(--tblr-light);--tblr-teal-darken:rgb(10.8, 149.4, 108);--tblr-teal-darken:color-mix(in oklab, var(--tblr-teal), transparent 20%);--tblr-teal-lt:rgb(230.7, 246.1, 241.5);--tblr-teal-lt:color-mix(in oklab, var(--tblr-teal) 10%, transparent);--tblr-teal-200:color-mix(in oklab, var(--tblr-teal) 20%, transparent);--tblr-teal-lt-rgb:231,246,242;--tblr-cyan:#17a2b8;--tblr-cyan-rgb:23,162,184;--tblr-cyan-fg:var(--tblr-light);--tblr-cyan-darken:rgb(20.7, 145.8, 165.6);--tblr-cyan-darken:color-mix(in oklab, var(--tblr-cyan), transparent 20%);--tblr-cyan-lt:rgb(231.8, 245.7, 247.9);--tblr-cyan-lt:color-mix(in oklab, var(--tblr-cyan) 10%, transparent);--tblr-cyan-200:color-mix(in oklab, var(--tblr-cyan) 20%, transparent);--tblr-cyan-lt-rgb:232,246,248;--tblr-x:#000000;--tblr-x-rgb:0,0,0;--tblr-x-fg:var(--tblr-light);--tblr-x-darken:black;--tblr-x-darken:color-mix(in oklab, var(--tblr-x), transparent 20%);--tblr-x-lt:rgb(229.5, 229.5, 229.5);--tblr-x-lt:color-mix(in oklab, var(--tblr-x) 10%, transparent);--tblr-x-200:color-mix(in oklab, var(--tblr-x) 20%, transparent);--tblr-x-lt-rgb:230,230,230;--tblr-facebook:#1877f2;--tblr-facebook-rgb:24,119,242;--tblr-facebook-fg:var(--tblr-light);--tblr-facebook-darken:rgb(21.6, 107.1, 217.8);--tblr-facebook-darken:color-mix(in oklab, var(--tblr-facebook), transparent 20%);--tblr-facebook-lt:rgb(231.9, 241.4, 253.7);--tblr-facebook-lt:color-mix(in oklab, var(--tblr-facebook) 10%, transparent);--tblr-facebook-200:color-mix(in oklab, var(--tblr-facebook) 20%, transparent);--tblr-facebook-lt-rgb:232,241,254;--tblr-twitter:#1da1f2;--tblr-twitter-rgb:29,161,242;--tblr-twitter-fg:var(--tblr-light);--tblr-twitter-darken:rgb(26.1, 144.9, 217.8);--tblr-twitter-darken:color-mix(in oklab, var(--tblr-twitter), transparent 20%);--tblr-twitter-lt:rgb(232.4, 245.6, 253.7);--tblr-twitter-lt:color-mix(in oklab, var(--tblr-twitter) 10%, transparent);--tblr-twitter-200:color-mix(in oklab, var(--tblr-twitter) 20%, transparent);--tblr-twitter-lt-rgb:232,246,254;--tblr-linkedin:#0a66c2;--tblr-linkedin-rgb:10,102,194;--tblr-linkedin-fg:var(--tblr-light);--tblr-linkedin-darken:rgb(9, 91.8, 174.6);--tblr-linkedin-darken:color-mix(in oklab, var(--tblr-linkedin), transparent 20%);--tblr-linkedin-lt:rgb(230.5, 239.7, 248.9);--tblr-linkedin-lt:color-mix(in oklab, var(--tblr-linkedin) 10%, transparent);--tblr-linkedin-200:color-mix(in oklab, var(--tblr-linkedin) 20%, transparent);--tblr-linkedin-lt-rgb:231,240,249;--tblr-google:#dc4e41;--tblr-google-rgb:220,78,65;--tblr-google-fg:var(--tblr-light);--tblr-google-darken:rgb(198, 70.2, 58.5);--tblr-google-darken:color-mix(in oklab, var(--tblr-google), transparent 20%);--tblr-google-lt:rgb(251.5, 237.3, 236);--tblr-google-lt:color-mix(in oklab, var(--tblr-google) 10%, transparent);--tblr-google-200:color-mix(in oklab, var(--tblr-google) 20%, transparent);--tblr-google-lt-rgb:252,237,236;--tblr-youtube:#ff0000;--tblr-youtube-rgb:255,0,0;--tblr-youtube-fg:var(--tblr-light);--tblr-youtube-darken:rgb(229.5, 0, 0);--tblr-youtube-darken:color-mix(in oklab, var(--tblr-youtube), transparent 20%);--tblr-youtube-lt:rgb(255, 229.5, 229.5);--tblr-youtube-lt:color-mix(in oklab, var(--tblr-youtube) 10%, transparent);--tblr-youtube-200:color-mix(in oklab, var(--tblr-youtube) 20%, transparent);--tblr-youtube-lt-rgb:255,230,230;--tblr-vimeo:#1ab7ea;--tblr-vimeo-rgb:26,183,234;--tblr-vimeo-fg:var(--tblr-light);--tblr-vimeo-darken:rgb(23.4, 164.7, 210.6);--tblr-vimeo-darken:color-mix(in oklab, var(--tblr-vimeo), transparent 20%);--tblr-vimeo-lt:rgb(232.1, 247.8, 252.9);--tblr-vimeo-lt:color-mix(in oklab, var(--tblr-vimeo) 10%, transparent);--tblr-vimeo-200:color-mix(in oklab, var(--tblr-vimeo) 20%, transparent);--tblr-vimeo-lt-rgb:232,248,253;--tblr-dribbble:#ea4c89;--tblr-dribbble-rgb:234,76,137;--tblr-dribbble-fg:var(--tblr-light);--tblr-dribbble-darken:rgb(210.6, 68.4, 123.3);--tblr-dribbble-darken:color-mix(in oklab, var(--tblr-dribbble), transparent 20%);--tblr-dribbble-lt:rgb(252.9, 237.1, 243.2);--tblr-dribbble-lt:color-mix(in oklab, var(--tblr-dribbble) 10%, transparent);--tblr-dribbble-200:color-mix(in oklab, var(--tblr-dribbble) 20%, transparent);--tblr-dribbble-lt-rgb:253,237,243;--tblr-github:#181717;--tblr-github-rgb:24,23,23;--tblr-github-fg:var(--tblr-light);--tblr-github-darken:rgb(21.6, 20.7, 20.7);--tblr-github-darken:color-mix(in oklab, var(--tblr-github), transparent 20%);--tblr-github-lt:rgb(231.9, 231.8, 231.8);--tblr-github-lt:color-mix(in oklab, var(--tblr-github) 10%, transparent);--tblr-github-200:color-mix(in oklab, var(--tblr-github) 20%, transparent);--tblr-github-lt-rgb:232,232,232;--tblr-instagram:#e4405f;--tblr-instagram-rgb:228,64,95;--tblr-instagram-fg:var(--tblr-light);--tblr-instagram-darken:rgb(205.2, 57.6, 85.5);--tblr-instagram-darken:color-mix(in oklab, var(--tblr-instagram), transparent 20%);--tblr-instagram-lt:rgb(252.3, 235.9, 239);--tblr-instagram-lt:color-mix(in oklab, var(--tblr-instagram) 10%, transparent);--tblr-instagram-200:color-mix(in oklab, var(--tblr-instagram) 20%, transparent);--tblr-instagram-lt-rgb:252,236,239;--tblr-pinterest:#bd081c;--tblr-pinterest-rgb:189,8,28;--tblr-pinterest-fg:var(--tblr-light);--tblr-pinterest-darken:rgb(170.1, 7.2, 25.2);--tblr-pinterest-darken:color-mix(in oklab, var(--tblr-pinterest), transparent 20%);--tblr-pinterest-lt:rgb(248.4, 230.3, 232.3);--tblr-pinterest-lt:color-mix(in oklab, var(--tblr-pinterest) 10%, transparent);--tblr-pinterest-200:color-mix(in oklab, var(--tblr-pinterest) 20%, transparent);--tblr-pinterest-lt-rgb:248,230,232;--tblr-vk:#6383a8;--tblr-vk-rgb:99,131,168;--tblr-vk-fg:var(--tblr-light);--tblr-vk-darken:rgb(89.1, 117.9, 151.2);--tblr-vk-darken:color-mix(in oklab, var(--tblr-vk), transparent 20%);--tblr-vk-lt:rgb(239.4, 242.6, 246.3);--tblr-vk-lt:color-mix(in oklab, var(--tblr-vk) 10%, transparent);--tblr-vk-200:color-mix(in oklab, var(--tblr-vk) 20%, transparent);--tblr-vk-lt-rgb:239,243,246;--tblr-rss:#ffa500;--tblr-rss-rgb:255,165,0;--tblr-rss-fg:var(--tblr-light);--tblr-rss-darken:rgb(229.5, 148.5, 0);--tblr-rss-darken:color-mix(in oklab, var(--tblr-rss), transparent 20%);--tblr-rss-lt:rgb(255, 246, 229.5);--tblr-rss-lt:color-mix(in oklab, var(--tblr-rss) 10%, transparent);--tblr-rss-200:color-mix(in oklab, var(--tblr-rss) 20%, transparent);--tblr-rss-lt-rgb:255,246,230;--tblr-flickr:#0063dc;--tblr-flickr-rgb:0,99,220;--tblr-flickr-fg:var(--tblr-light);--tblr-flickr-darken:rgb(0, 89.1, 198);--tblr-flickr-darken:color-mix(in oklab, var(--tblr-flickr), transparent 20%);--tblr-flickr-lt:rgb(229.5, 239.4, 251.5);--tblr-flickr-lt:color-mix(in oklab, var(--tblr-flickr) 10%, transparent);--tblr-flickr-200:color-mix(in oklab, var(--tblr-flickr) 20%, transparent);--tblr-flickr-lt-rgb:230,239,252;--tblr-bitbucket:#0052cc;--tblr-bitbucket-rgb:0,82,204;--tblr-bitbucket-fg:var(--tblr-light);--tblr-bitbucket-darken:rgb(0, 73.8, 183.6);--tblr-bitbucket-darken:color-mix(in oklab, var(--tblr-bitbucket), transparent 20%);--tblr-bitbucket-lt:rgb(229.5, 237.7, 249.9);--tblr-bitbucket-lt:color-mix(in oklab, var(--tblr-bitbucket) 10%, transparent);--tblr-bitbucket-200:color-mix(in oklab, var(--tblr-bitbucket) 20%, transparent);--tblr-bitbucket-lt-rgb:230,238,250;--tblr-tabler:#066fd1;--tblr-tabler-rgb:6,111,209;--tblr-tabler-fg:var(--tblr-light);--tblr-tabler-darken:rgb(5.4, 99.9, 188.1);--tblr-tabler-darken:color-mix(in oklab, var(--tblr-tabler), transparent 20%);--tblr-tabler-lt:rgb(230.1, 240.6, 250.4);--tblr-tabler-lt:color-mix(in oklab, var(--tblr-tabler) 10%, transparent);--tblr-tabler-200:color-mix(in oklab, var(--tblr-tabler) 20%, transparent);--tblr-tabler-lt-rgb:230,241,250;--tblr-gray-50-fg:var(--tblr-body-color);--tblr-gray-100-fg:var(--tblr-body-color);--tblr-gray-200-fg:var(--tblr-body-color);--tblr-gray-300-fg:var(--tblr-body-color);--tblr-gray-400-fg:var(--tblr-white);--tblr-gray-500-fg:var(--tblr-white);--tblr-gray-600-fg:var(--tblr-white);--tblr-gray-700-fg:var(--tblr-white);--tblr-gray-800-fg:var(--tblr-white);--tblr-gray-900-fg:var(--tblr-white);--tblr-gray-950-fg:var(--tblr-white);--tblr-spacer-0:0;--tblr-spacer-1:0.25rem;--tblr-spacer-2:0.5rem;--tblr-spacer-3:1rem;--tblr-spacer-4:1.5rem;--tblr-spacer-5:2rem;--tblr-spacer-6:2.5rem;--tblr-font-weight-light:300;--tblr-font-weight-normal:400;--tblr-font-weight-medium:500;--tblr-font-weight-bold:600;--tblr-font-weight-black:700;--tblr-font-weight-headings:var(--tblr-font-weight-bold);--tblr-font-size-h1:1.5rem;--tblr-font-size-h2:1.25rem;--tblr-font-size-h3:1rem;--tblr-font-size-h4:0.875rem;--tblr-font-size-h5:0.75rem;--tblr-font-size-h6:0.625rem;--tblr-line-height-h1:2rem;--tblr-line-height-h2:1.75rem;--tblr-line-height-h3:1.5rem;--tblr-line-height-h4:1.25rem;--tblr-line-height-h5:1rem;--tblr-line-height-h6:1rem;--tblr-shadow:rgba(var(--tblr-body-color-rgb), 0.04) 0 2px 4px 0;--tblr-shadow-border:inset 0 0 0 1px var(--tblr-border-color-translucent);--tblr-shadow-transparent:0 0 0 0 transparent;--tblr-shadow-input:0 1px 1px rgba(var(--tblr-body-color-rgb), 0.06);--tblr-shadow-card:0 0 4px rgba(var(--tblr-body-color-rgb), 0.04);--tblr-shadow-card-hover:rgba(var(--tblr-body-color-rgb), 0.16) 0 2px 16px 0;--tblr-shadow-dropdown:0 16px 24px 2px rgba(0, 0, 0, 0.07),0 6px 30px 5px rgba(0, 0, 0, 0.06),0 8px 10px -5px rgba(0, 0, 0, 0.1);--tblr-border-radius-scale:1;--tblr-border-radius-0:calc(0 * var(--tblr-border-radius-scale, 1));--tblr-border-radius-sm:calc(4px * var(--tblr-border-radius-scale, 1));--tblr-border-radius-md:calc(6px * var(--tblr-border-radius-scale, 1));--tblr-border-radius-lg:calc(8px * var(--tblr-border-radius-scale, 1));--tblr-border-radius-pill:calc(100rem * var(--tblr-border-radius-scale, 1));--tblr-border-radius:var(--tblr-border-radius-md);--tblr-backdrop-opacity:24%;--tblr-backdrop-bg:var(--tblr-bg-surface-dark);--tblr-backdrop-bg-dark:color-mix(in srgb, var(--tblr-color-dark), transparent var(--tblr-backdrop-opacity));--tblr-backdrop-bg-light:color-mix(in srgb, var(--tblr-color-light), transparent var(--tblr-backdrop-opacity));--tblr-backdrop-blur:4px;--tblr-backdrop-filter:blur(var(--tblr-backdrop-blur))}:host,:root{font-size:16px;height:100%}@media (min-width:992px){:host,:root{margin-left:calc(100vw - 100%);margin-right:0}}:host,:root,[data-bs-theme=light]{color-scheme:light;--tblr-spacer:var(--tblr-spacer-2);--tblr-bg-surface:var(--tblr-bg-surface-primary);--tblr-bg-surface-primary:var(--tblr-white);--tblr-bg-surface-secondary:var(--tblr-gray-50);--tblr-bg-surface-tertiary:var(--tblr-gray-50);--tblr-bg-surface-dark:var(--tblr-gray-900);--tblr-bg-surface-inverted:var(--tblr-gray-900);--tblr-bg-forms:var(--tblr-bg-surface);--tblr-text-inverted:var(--tblr-gray-100);--tblr-body-color:var(--tblr-gray-700);--tblr-body-bg:var(--tblr-bg-surface-secondary);--tblr-link-color:var(--tblr-primary);--tblr-link-hover-color:color-mix(in srgb, var(--tblr-primary), #000 20%);--tblr-secondary:var(--tblr-gray-500);--tblr-tertiary:var(--tblr-gray-400);--tblr-border-color:#e5e7eb;--tblr-border-color-translucent:rgba(4, 32, 69, 0.1);--tblr-border-dark-color:#9ca3af;--tblr-border-dark-color-translucent:rgba(4, 32, 69, 0.27);--tblr-border-active-color:rgb(169.16, 173.22, 181.34);--tblr-icon-color:var(--tblr-gray-400);--tblr-active-bg:rgba(var(--tblr-primary-rgb), 0.04);--tblr-disabled-bg:var(--tblr-bg-surface-secondary);--tblr-disabled-color:color-mix(in srgb, var(--tblr-body-color) 40%, transparent);--tblr-code-color:light-dark(var(--tblr-gray-600), var(--tblr-gray-400));--tblr-code-bg:light-dark(var(--tblr-gray-100), var(--tblr-gray-900));--tblr-dark-mode-border-color:rgb(45.7069767442, 60.4511627907, 81.0930232558);--tblr-dark-mode-border-color-translucent:rgba(72, 110, 149, 0.14);--tblr-dark-mode-border-active-color:rgb(53.0604651163, 70.176744186, 94.1395348837);--tblr-dark-mode-border-dark-color:rgb(38.3534883721, 50.7255813953, 68.0465116279);--tblr-page-padding:var(--tblr-spacer-3);--tblr-page-padding-y:var(--tblr-spacer-4)}@media (max-width:991.98px){:host,:root,[data-bs-theme=light]{--tblr-page-padding:var(--tblr-spacer-2)}}@keyframes pulse{0%{transform:scale(1)}14%{transform:scale(1.25)}28%{transform:scale(1)}42%{transform:scale(1.25)}70%{transform:scale(1)}}@keyframes tada{0%{transform:scale3d(1,1,1)}10%,5%{transform:scale3d(.9,.9,.9) rotate3d(0,0,1,-5deg)}15%,25%,35%,45%{transform:scale3d(1.1,1.1,1.1) rotate3d(0,0,1,5deg)}20%,30%,40%{transform:scale3d(1.1,1.1,1.1) rotate3d(0,0,1,-5deg)}50%{transform:scale3d(1,1,1)}}@keyframes rotate-360{from{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes blink{from{opacity:0}50%{opacity:1}to{opacity:0}}@keyframes shake{0%{transform:scaleX(1)}20%{transform:scale3d(.9,.9,.9) rotate(-5deg)}50%,70%,90%{transform:scale3d(1.25,1.25,1.25) rotate(5deg)}60%,80%{transform:scale3d(1.25,1.25,1.25) rotate(-5deg)}to{transform:scaleX(1)}}body{letter-spacing:0;touch-action:manipulation;text-rendering:optimizeLegibility;font-feature-settings:"liga" 0,"cv03","cv04","cv11";position:relative;min-height:100%;height:100%;padding:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media print{body{background:0 0}}*{scrollbar-color:color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 20%,transparent) transparent}::-webkit-scrollbar{width:1rem;height:1rem;-webkit-transition:background .3s;transition:background .3s}@media (prefers-reduced-motion:reduce){::-webkit-scrollbar{-webkit-transition:none;transition:none}}::-webkit-scrollbar-thumb{border-radius:1rem;border:5px solid transparent;box-shadow:inset 0 0 0 1rem color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 20%,transparent)}::-webkit-scrollbar-track{background:0 0}:hover::-webkit-scrollbar-thumb{box-shadow:inset 0 0 0 1rem color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 40%,transparent)}::-webkit-scrollbar-corner{background:0 0}.layout-fluid .container,.layout-fluid [class*=" container-"],.layout-fluid [class^=container-]{max-width:100%}.layout-boxed{--tblr-theme-boxed-border-radius:0;--tblr-theme-boxed-width:1320px}@media (min-width:768px){.layout-boxed{background:#1f2937 linear-gradient(to right,rgba(255,255,255,.1),transparent) fixed;padding:1rem;--tblr-theme-boxed-border-radius:6px}}.layout-boxed .page{margin:0 auto;max-width:var(--tblr-theme-boxed-width);border-radius:var(--tblr-theme-boxed-border-radius);color:var(--tblr-body-color)}@media (min-width:768px){.layout-boxed .page{border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);background:var(--tblr-body-bg)}}.layout-boxed .page>.navbar:first-child{border-top-left-radius:var(--tblr-theme-boxed-border-radius);border-top-right-radius:var(--tblr-theme-boxed-border-radius)}.navbar{--tblr-navbar-bg:var(--tblr-bg-surface);--tblr-navbar-border-width:var(--tblr-border-width);--tblr-navbar-active-border-color:var(--tblr-primary);--tblr-navbar-active-bg:rgba(0, 0, 0, 0.2);--tblr-navbar-border-color:var(--tblr-border-color);--tblr-navbar-hover-color:var(--tblr-body-color);align-items:stretch;min-height:3.5rem;box-shadow:inset 0 calc(-1 * var(--tblr-navbar-border-width)) 0 0 var(--tblr-navbar-border-color);background:var(--tblr-navbar-bg);color:var(--tblr-navbar-color)}.navbar-collapse .navbar{flex-grow:1}.navbar.collapsing{min-height:0}.navbar .dropdown-menu{position:absolute;z-index:1030}.navbar .navbar-nav{min-height:3rem}.navbar .navbar-nav .nav-link{position:relative;min-width:2.5rem;min-height:2.5rem;justify-content:center;border-radius:var(--tblr-border-radius)}.navbar .navbar-nav .nav-link .badge{position:absolute;top:.375rem;right:.375rem;transform:translate(50%,-50%)}@media (max-width:575.98px){.navbar-expand-sm .navbar-collapse{flex-direction:column}.navbar-expand-sm .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-expand-sm .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-expand-sm .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-expand-sm .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-expand-sm .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-expand-sm .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-expand-sm .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:576px){.navbar-expand-sm .navbar-collapse{width:auto;flex:1 1 auto}.navbar-expand-sm .nav-item.active{position:relative}.navbar-expand-sm .nav-item.active .nav-link{color:var(--tblr-navbar-active-color)}.navbar-expand-sm .nav-item.active:after{content:"";position:absolute;left:0;right:0;bottom:-.25rem;border:0 var(--tblr-border-style) var(--tblr-navbar-active-border-color);border-bottom-width:2px}.navbar-expand-sm.navbar-vertical{box-shadow:inset calc(-1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-sm.navbar-vertical.navbar-end,.navbar-expand-sm.navbar-vertical.navbar-right{box-shadow:inset calc(1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-sm.navbar-vertical~.navbar,.navbar-expand-sm.navbar-vertical~.page-wrapper{margin-left:15rem}.navbar-expand-sm.navbar-vertical.navbar-end~.navbar,.navbar-expand-sm.navbar-vertical.navbar-end~.page-wrapper,.navbar-expand-sm.navbar-vertical.navbar-right~.navbar,.navbar-expand-sm.navbar-vertical.navbar-right~.page-wrapper{margin-left:0;margin-right:15rem}}@media (max-width:767.98px){.navbar-expand-md .navbar-collapse{flex-direction:column}.navbar-expand-md .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-expand-md .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-expand-md .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-expand-md .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-expand-md .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-expand-md .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-expand-md .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:768px){.navbar-expand-md .navbar-collapse{width:auto;flex:1 1 auto}.navbar-expand-md .nav-item.active{position:relative}.navbar-expand-md .nav-item.active .nav-link{color:var(--tblr-navbar-active-color)}.navbar-expand-md .nav-item.active:after{content:"";position:absolute;left:0;right:0;bottom:-.25rem;border:0 var(--tblr-border-style) var(--tblr-navbar-active-border-color);border-bottom-width:2px}.navbar-expand-md.navbar-vertical{box-shadow:inset calc(-1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-md.navbar-vertical.navbar-end,.navbar-expand-md.navbar-vertical.navbar-right{box-shadow:inset calc(1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-md.navbar-vertical~.navbar,.navbar-expand-md.navbar-vertical~.page-wrapper{margin-left:15rem}.navbar-expand-md.navbar-vertical.navbar-end~.navbar,.navbar-expand-md.navbar-vertical.navbar-end~.page-wrapper,.navbar-expand-md.navbar-vertical.navbar-right~.navbar,.navbar-expand-md.navbar-vertical.navbar-right~.page-wrapper{margin-left:0;margin-right:15rem}}@media (max-width:991.98px){.navbar-expand-lg .navbar-collapse{flex-direction:column}.navbar-expand-lg .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-expand-lg .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-expand-lg .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-expand-lg .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-expand-lg .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-expand-lg .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-expand-lg .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:992px){.navbar-expand-lg .navbar-collapse{width:auto;flex:1 1 auto}.navbar-expand-lg .nav-item.active{position:relative}.navbar-expand-lg .nav-item.active .nav-link{color:var(--tblr-navbar-active-color)}.navbar-expand-lg .nav-item.active:after{content:"";position:absolute;left:0;right:0;bottom:-.25rem;border:0 var(--tblr-border-style) var(--tblr-navbar-active-border-color);border-bottom-width:2px}.navbar-expand-lg.navbar-vertical{box-shadow:inset calc(-1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-lg.navbar-vertical.navbar-end,.navbar-expand-lg.navbar-vertical.navbar-right{box-shadow:inset calc(1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-lg.navbar-vertical~.navbar,.navbar-expand-lg.navbar-vertical~.page-wrapper{margin-left:15rem}.navbar-expand-lg.navbar-vertical.navbar-end~.navbar,.navbar-expand-lg.navbar-vertical.navbar-end~.page-wrapper,.navbar-expand-lg.navbar-vertical.navbar-right~.navbar,.navbar-expand-lg.navbar-vertical.navbar-right~.page-wrapper{margin-left:0;margin-right:15rem}}@media (max-width:1199.98px){.navbar-expand-xl .navbar-collapse{flex-direction:column}.navbar-expand-xl .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-expand-xl .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-expand-xl .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-expand-xl .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-expand-xl .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-expand-xl .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-expand-xl .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:1200px){.navbar-expand-xl .navbar-collapse{width:auto;flex:1 1 auto}.navbar-expand-xl .nav-item.active{position:relative}.navbar-expand-xl .nav-item.active .nav-link{color:var(--tblr-navbar-active-color)}.navbar-expand-xl .nav-item.active:after{content:"";position:absolute;left:0;right:0;bottom:-.25rem;border:0 var(--tblr-border-style) var(--tblr-navbar-active-border-color);border-bottom-width:2px}.navbar-expand-xl.navbar-vertical{box-shadow:inset calc(-1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-xl.navbar-vertical.navbar-end,.navbar-expand-xl.navbar-vertical.navbar-right{box-shadow:inset calc(1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-xl.navbar-vertical~.navbar,.navbar-expand-xl.navbar-vertical~.page-wrapper{margin-left:15rem}.navbar-expand-xl.navbar-vertical.navbar-end~.navbar,.navbar-expand-xl.navbar-vertical.navbar-end~.page-wrapper,.navbar-expand-xl.navbar-vertical.navbar-right~.navbar,.navbar-expand-xl.navbar-vertical.navbar-right~.page-wrapper{margin-left:0;margin-right:15rem}}@media (max-width:1399.98px){.navbar-expand-xxl .navbar-collapse{flex-direction:column}.navbar-expand-xxl .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-expand-xxl .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-expand-xxl .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-expand-xxl .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-expand-xxl .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-expand-xxl .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-expand-xxl .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:1400px){.navbar-expand-xxl .navbar-collapse{width:auto;flex:1 1 auto}.navbar-expand-xxl .nav-item.active{position:relative}.navbar-expand-xxl .nav-item.active .nav-link{color:var(--tblr-navbar-active-color)}.navbar-expand-xxl .nav-item.active:after{content:"";position:absolute;left:0;right:0;bottom:-.25rem;border:0 var(--tblr-border-style) var(--tblr-navbar-active-border-color);border-bottom-width:2px}.navbar-expand-xxl.navbar-vertical{box-shadow:inset calc(-1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-xxl.navbar-vertical.navbar-end,.navbar-expand-xxl.navbar-vertical.navbar-right{box-shadow:inset calc(1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand-xxl.navbar-vertical~.navbar,.navbar-expand-xxl.navbar-vertical~.page-wrapper{margin-left:15rem}.navbar-expand-xxl.navbar-vertical.navbar-end~.navbar,.navbar-expand-xxl.navbar-vertical.navbar-end~.page-wrapper,.navbar-expand-xxl.navbar-vertical.navbar-right~.navbar,.navbar-expand-xxl.navbar-vertical.navbar-right~.page-wrapper{margin-left:0;margin-right:15rem}}.navbar-expand .navbar-collapse{flex-direction:column}.navbar-expand .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-expand .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-expand .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-expand .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-expand .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-expand .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-expand .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-expand .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-expand .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}.navbar-expand .navbar-collapse{width:auto;flex:1 1 auto}.navbar-expand .nav-item.active{position:relative}.navbar-expand .nav-item.active .nav-link{color:var(--tblr-navbar-active-color)}.navbar-expand .nav-item.active:after{content:"";position:absolute;left:0;right:0;bottom:-.25rem;border:0 var(--tblr-border-style) var(--tblr-navbar-active-border-color);border-bottom-width:2px}.navbar-expand.navbar-vertical{box-shadow:inset calc(-1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand.navbar-vertical.navbar-end,.navbar-expand.navbar-vertical.navbar-right{box-shadow:inset calc(1 * var(--tblr-navbar-border-width)) 0 0 0 var(--tblr-navbar-border-color)}.navbar-expand.navbar-vertical~.navbar,.navbar-expand.navbar-vertical~.page-wrapper{margin-left:15rem}.navbar-expand.navbar-vertical.navbar-end~.navbar,.navbar-expand.navbar-vertical.navbar-end~.page-wrapper,.navbar-expand.navbar-vertical.navbar-right~.navbar,.navbar-expand.navbar-vertical.navbar-right~.page-wrapper{margin-left:0;margin-right:15rem}.navbar-brand{display:inline-flex;align-items:center;font-weight:var(--tblr-font-weight-bold);margin:0;line-height:1;gap:.5rem}.navbar-brand-image{height:2rem;width:auto}.navbar-toggler{border:0;width:2rem;height:2rem;position:relative;display:flex;align-items:center;justify-content:center}.navbar-toggler-icon{height:2px;width:1.25em;background:currentColor;border-radius:10px;transition:top .2s .2s,bottom .2s .2s,transform .2s,opacity 0s .2s;position:relative}@media (prefers-reduced-motion:reduce){.navbar-toggler-icon{transition:none}}.navbar-toggler-icon:after,.navbar-toggler-icon:before{content:"";display:block;height:inherit;width:inherit;border-radius:inherit;background:inherit;position:absolute;left:0;transition:inherit}@media (prefers-reduced-motion:reduce){.navbar-toggler-icon:after,.navbar-toggler-icon:before{transition:none}}.navbar-toggler-icon:before{top:-.45em}.navbar-toggler-icon:after{bottom:-.45em}.navbar-toggler[aria-expanded=true] .navbar-toggler-icon{transform:rotate(45deg);transition:top .3s,bottom .3s,transform .3s .3s,opacity 0s .3s}@media (prefers-reduced-motion:reduce){.navbar-toggler[aria-expanded=true] .navbar-toggler-icon{transition:none}}.navbar-toggler[aria-expanded=true] .navbar-toggler-icon:before{top:0;transform:rotate(-90deg)}.navbar-toggler[aria-expanded=true] .navbar-toggler-icon:after{bottom:0;opacity:0}.navbar-transparent{--tblr-navbar-border-color:transparent!important;background:0 0!important}.navbar-nav{--tblr-nav-link-hover-bg:color-mix(in srgb, var(--tblr-nav-link-color) 4%, transparent);margin:0;padding:0;align-items:stretch}.navbar-nav .nav-item{display:flex;flex-direction:column;justify-content:center}.navbar-side{margin:0;display:flex;flex-direction:row;align-items:center;justify-content:space-around}@media (min-width:576px){.navbar-vertical.navbar-expand-sm{width:15rem;position:fixed;top:0;left:0;bottom:0;z-index:1030;align-items:flex-start;transition:transform .3s;overflow-y:scroll;padding:0}}@media (min-width:576px) and (prefers-reduced-motion:reduce){.navbar-vertical.navbar-expand-sm{transition:none}}@media (min-width:576px){.navbar-vertical.navbar-expand-sm.navbar-end,.navbar-vertical.navbar-expand-sm.navbar-right{left:auto;right:0}.navbar-vertical.navbar-expand-sm .navbar-brand{padding:.75rem 0;justify-content:center}.navbar-vertical.navbar-expand-sm .navbar-collapse{align-items:stretch}.navbar-vertical.navbar-expand-sm .navbar-nav{flex-direction:column;flex-grow:1;min-height:auto}.navbar-vertical.navbar-expand-sm .navbar-nav .nav-link{padding-top:.5rem;padding-bottom:.5rem}.navbar-vertical.navbar-expand-sm>[class^=container]{flex-direction:column;align-items:stretch;min-height:100%;justify-content:flex-start;padding:0}.navbar-vertical.navbar-expand-sm~.page{padding-left:15rem}.navbar-vertical.navbar-expand-sm~.page [class^=container]{padding-left:1.5rem;padding-right:1.5rem}.navbar-vertical.navbar-expand-sm.navbar-end~.page,.navbar-vertical.navbar-expand-sm.navbar-right~.page{padding-left:0;padding-right:15rem}.navbar-vertical.navbar-expand-sm .navbar-collapse{flex-direction:column}.navbar-vertical.navbar-expand-sm .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-vertical.navbar-expand-sm .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-vertical.navbar-expand-sm .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-vertical.navbar-expand-sm .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-vertical.navbar-expand-sm .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:768px){.navbar-vertical.navbar-expand-md{width:15rem;position:fixed;top:0;left:0;bottom:0;z-index:1030;align-items:flex-start;transition:transform .3s;overflow-y:scroll;padding:0}}@media (min-width:768px) and (prefers-reduced-motion:reduce){.navbar-vertical.navbar-expand-md{transition:none}}@media (min-width:768px){.navbar-vertical.navbar-expand-md.navbar-end,.navbar-vertical.navbar-expand-md.navbar-right{left:auto;right:0}.navbar-vertical.navbar-expand-md .navbar-brand{padding:.75rem 0;justify-content:center}.navbar-vertical.navbar-expand-md .navbar-collapse{align-items:stretch}.navbar-vertical.navbar-expand-md .navbar-nav{flex-direction:column;flex-grow:1;min-height:auto}.navbar-vertical.navbar-expand-md .navbar-nav .nav-link{padding-top:.5rem;padding-bottom:.5rem}.navbar-vertical.navbar-expand-md>[class^=container]{flex-direction:column;align-items:stretch;min-height:100%;justify-content:flex-start;padding:0}.navbar-vertical.navbar-expand-md~.page{padding-left:15rem}.navbar-vertical.navbar-expand-md~.page [class^=container]{padding-left:1.5rem;padding-right:1.5rem}.navbar-vertical.navbar-expand-md.navbar-end~.page,.navbar-vertical.navbar-expand-md.navbar-right~.page{padding-left:0;padding-right:15rem}.navbar-vertical.navbar-expand-md .navbar-collapse{flex-direction:column}.navbar-vertical.navbar-expand-md .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-vertical.navbar-expand-md .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-vertical.navbar-expand-md .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-vertical.navbar-expand-md .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-vertical.navbar-expand-md .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:992px){.navbar-vertical.navbar-expand-lg{width:15rem;position:fixed;top:0;left:0;bottom:0;z-index:1030;align-items:flex-start;transition:transform .3s;overflow-y:scroll;padding:0}}@media (min-width:992px) and (prefers-reduced-motion:reduce){.navbar-vertical.navbar-expand-lg{transition:none}}@media (min-width:992px){.navbar-vertical.navbar-expand-lg.navbar-end,.navbar-vertical.navbar-expand-lg.navbar-right{left:auto;right:0}.navbar-vertical.navbar-expand-lg .navbar-brand{padding:.75rem 0;justify-content:center}.navbar-vertical.navbar-expand-lg .navbar-collapse{align-items:stretch}.navbar-vertical.navbar-expand-lg .navbar-nav{flex-direction:column;flex-grow:1;min-height:auto}.navbar-vertical.navbar-expand-lg .navbar-nav .nav-link{padding-top:.5rem;padding-bottom:.5rem}.navbar-vertical.navbar-expand-lg>[class^=container]{flex-direction:column;align-items:stretch;min-height:100%;justify-content:flex-start;padding:0}.navbar-vertical.navbar-expand-lg~.page{padding-left:15rem}.navbar-vertical.navbar-expand-lg~.page [class^=container]{padding-left:1.5rem;padding-right:1.5rem}.navbar-vertical.navbar-expand-lg.navbar-end~.page,.navbar-vertical.navbar-expand-lg.navbar-right~.page{padding-left:0;padding-right:15rem}.navbar-vertical.navbar-expand-lg .navbar-collapse{flex-direction:column}.navbar-vertical.navbar-expand-lg .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-vertical.navbar-expand-lg .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-vertical.navbar-expand-lg .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-vertical.navbar-expand-lg .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-vertical.navbar-expand-lg .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:1200px){.navbar-vertical.navbar-expand-xl{width:15rem;position:fixed;top:0;left:0;bottom:0;z-index:1030;align-items:flex-start;transition:transform .3s;overflow-y:scroll;padding:0}}@media (min-width:1200px) and (prefers-reduced-motion:reduce){.navbar-vertical.navbar-expand-xl{transition:none}}@media (min-width:1200px){.navbar-vertical.navbar-expand-xl.navbar-end,.navbar-vertical.navbar-expand-xl.navbar-right{left:auto;right:0}.navbar-vertical.navbar-expand-xl .navbar-brand{padding:.75rem 0;justify-content:center}.navbar-vertical.navbar-expand-xl .navbar-collapse{align-items:stretch}.navbar-vertical.navbar-expand-xl .navbar-nav{flex-direction:column;flex-grow:1;min-height:auto}.navbar-vertical.navbar-expand-xl .navbar-nav .nav-link{padding-top:.5rem;padding-bottom:.5rem}.navbar-vertical.navbar-expand-xl>[class^=container]{flex-direction:column;align-items:stretch;min-height:100%;justify-content:flex-start;padding:0}.navbar-vertical.navbar-expand-xl~.page{padding-left:15rem}.navbar-vertical.navbar-expand-xl~.page [class^=container]{padding-left:1.5rem;padding-right:1.5rem}.navbar-vertical.navbar-expand-xl.navbar-end~.page,.navbar-vertical.navbar-expand-xl.navbar-right~.page{padding-left:0;padding-right:15rem}.navbar-vertical.navbar-expand-xl .navbar-collapse{flex-direction:column}.navbar-vertical.navbar-expand-xl .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-vertical.navbar-expand-xl .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-vertical.navbar-expand-xl .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-vertical.navbar-expand-xl .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-vertical.navbar-expand-xl .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}@media (min-width:1400px){.navbar-vertical.navbar-expand-xxl{width:15rem;position:fixed;top:0;left:0;bottom:0;z-index:1030;align-items:flex-start;transition:transform .3s;overflow-y:scroll;padding:0}}@media (min-width:1400px) and (prefers-reduced-motion:reduce){.navbar-vertical.navbar-expand-xxl{transition:none}}@media (min-width:1400px){.navbar-vertical.navbar-expand-xxl.navbar-end,.navbar-vertical.navbar-expand-xxl.navbar-right{left:auto;right:0}.navbar-vertical.navbar-expand-xxl .navbar-brand{padding:.75rem 0;justify-content:center}.navbar-vertical.navbar-expand-xxl .navbar-collapse{align-items:stretch}.navbar-vertical.navbar-expand-xxl .navbar-nav{flex-direction:column;flex-grow:1;min-height:auto}.navbar-vertical.navbar-expand-xxl .navbar-nav .nav-link{padding-top:.5rem;padding-bottom:.5rem}.navbar-vertical.navbar-expand-xxl>[class^=container]{flex-direction:column;align-items:stretch;min-height:100%;justify-content:flex-start;padding:0}.navbar-vertical.navbar-expand-xxl~.page{padding-left:15rem}.navbar-vertical.navbar-expand-xxl~.page [class^=container]{padding-left:1.5rem;padding-right:1.5rem}.navbar-vertical.navbar-expand-xxl.navbar-end~.page,.navbar-vertical.navbar-expand-xxl.navbar-right~.page{padding-left:0;padding-right:15rem}.navbar-vertical.navbar-expand-xxl .navbar-collapse{flex-direction:column}.navbar-vertical.navbar-expand-xxl .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-vertical.navbar-expand-xxl .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-vertical.navbar-expand-xxl .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-vertical.navbar-expand-xxl .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-vertical.navbar-expand-xxl .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}}.navbar-vertical.navbar-expand{width:15rem;position:fixed;top:0;left:0;bottom:0;z-index:1030;align-items:flex-start;transition:transform .3s;overflow-y:scroll;padding:0}@media (prefers-reduced-motion:reduce){.navbar-vertical.navbar-expand{transition:none}}.navbar-vertical.navbar-expand.navbar-end,.navbar-vertical.navbar-expand.navbar-right{left:auto;right:0}.navbar-vertical.navbar-expand .navbar-brand{padding:.75rem 0;justify-content:center}.navbar-vertical.navbar-expand .navbar-collapse{align-items:stretch}.navbar-vertical.navbar-expand .navbar-nav{flex-direction:column;flex-grow:1;min-height:auto}.navbar-vertical.navbar-expand .navbar-nav .nav-link{padding-top:.5rem;padding-bottom:.5rem}.navbar-vertical.navbar-expand>[class^=container]{flex-direction:column;align-items:stretch;min-height:100%;justify-content:flex-start;padding:0}.navbar-vertical.navbar-expand~.page{padding-left:15rem}.navbar-vertical.navbar-expand~.page [class^=container]{padding-left:1.5rem;padding-right:1.5rem}.navbar-vertical.navbar-expand.navbar-end~.page,.navbar-vertical.navbar-expand.navbar-right~.page{padding-left:0;padding-right:15rem}.navbar-vertical.navbar-expand .navbar-collapse{flex-direction:column}.navbar-vertical.navbar-expand .navbar-collapse [class^=container]{flex-direction:column;align-items:stretch;padding:0}.navbar-vertical.navbar-expand .navbar-collapse .navbar-nav{margin-left:0;margin-right:0}.navbar-vertical.navbar-expand .navbar-collapse .navbar-nav .nav-link{padding:.5rem calc(calc(var(--tblr-page-padding) * 2)/ 2);justify-content:flex-start}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu-columns{flex-direction:column}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu{padding:0;background:0 0;position:static;color:inherit;box-shadow:none;border:none;min-width:0;margin:0}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item{min-width:0;display:flex;width:auto;padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 1.75rem);color:inherit}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item.disabled{color:var(--tblr-disabled-color);pointer-events:none;background-color:transparent}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item.active,.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu .dropdown-item:active{background:var(--tblr-navbar-active-bg)}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 3.25rem)}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-menu .dropdown-menu .dropdown-menu .dropdown-item{padding-left:calc(calc(calc(var(--tblr-page-padding) * 2)/ 2) + 4.75rem)}.navbar-vertical.navbar-expand .navbar-collapse .dropdown-toggle:after{margin-left:auto}.navbar-vertical.navbar-expand .navbar-collapse .nav-item.active:after{border-bottom-width:0;border-left-width:3px;right:auto;top:0;bottom:0}.navbar-overlap:after{content:"";height:9rem;position:absolute;top:100%;left:0;right:0;background:inherit;z-index:-1;box-shadow:inherit}.page{display:flex;flex-direction:column;position:relative;min-height:100%}.page-center{justify-content:center}.page-wrapper{flex:1;display:flex;flex-direction:column}@media print{.page-wrapper{margin:0!important}}.page-wrapper-full .page-body:first-child{margin:0;border-top:0}.page-body{margin-top:var(--tblr-page-padding-y);margin-bottom:var(--tblr-page-padding-y);display:flex;flex-direction:column;flex:1}.page-body-card{background:var(--tblr-bg-surface);border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent);padding:var(--tblr-page-padding) 0;margin-bottom:0;flex:1}.page-body~.page-body-card{margin-top:0}.page-cover{background:no-repeat center/cover;min-height:9rem}@media (min-width:768px){.page-cover{min-height:12rem}}@media (min-width:992px){.page-cover{min-height:15rem}}.page-cover-overlay{position:relative}.page-cover-overlay:after{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background-image:linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,.6) 100%)}.page-header{display:flex;flex-wrap:wrap;min-height:2.25rem;flex-direction:column;justify-content:center;max-width:100%}.page-wrapper .page-header{margin:var(--tblr-page-padding-y) 0 0}.page-header-border{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);padding:var(--tblr-page-padding-y) 0;margin:0!important;background-color:var(--tblr-bg-surface)}.page-pretitle{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary)}.page-title{margin:0;font-size:var(--tblr-font-size-h2);line-height:var(--tblr-line-height-h2);font-weight:var(--tblr-font-weight-headings);color:inherit;display:flex;align-items:center}.page-title svg{width:1.5rem;height:1.5rem;margin-right:.25rem}.page-title-lg{font-size:1.5rem;line-height:2rem}.page-subtitle{margin-top:.25rem;color:var(--tblr-secondary)}.page-cover{--tblr-page-cover-blur:20px;--tblr-page-cover-padding:1rem;min-height:6rem;padding:var(--tblr-page-cover-padding) 0;position:relative;overflow:hidden}.page-cover-img{position:absolute;top:calc(-2 * var(--tblr-page-cover-blur,0));left:calc(-2 * var(--tblr-page-cover-blur,0));right:calc(-2 * var(--tblr-page-cover-blur,0));bottom:calc(-2 * var(--tblr-page-cover-blur,0));pointer-events:none;filter:blur(var(--tblr-page-cover-blur));-o-object-fit:cover;object-fit:cover;background-size:cover;background-position:center;z-index:-1}.page-tabs{margin-top:.5rem;position:relative}.page-header-tabs .nav-bordered{border:0}.page-header-tabs+.page-body-card{margin-top:0}.footer{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);background-color:var(--tblr-bg-surface);padding:2rem 0;color:var(--tblr-gray-500);margin-top:auto}.footer-transparent{background-color:transparent;border-top:0}:root:not(.theme-dark):not([data-bs-theme=dark]) .hide-theme-light{display:none!important}:root:not(.theme-dark):not([data-bs-theme=dark]) .img-dark{display:none!important}:root.theme-dark .hide-theme-dark,:root[data-bs-theme=dark] .hide-theme-dark,body[data-bs-theme=dark] [data-bs-theme=light]:root .hide-theme-dark{display:none!important}:root.theme-dark .img-light,:root[data-bs-theme=dark] .img-light,body[data-bs-theme=dark] [data-bs-theme=light]:root .img-light{display:none!important}[data-bs-theme=dark],body[data-bs-theme=dark] [data-bs-theme=light]{color-scheme:dark;--tblr-body-color:var(--tblr-gray-200);--tblr-secondary:var(--tblr-gray-400);--tblr-body-bg:var(--tblr-gray-900);--tblr-emphasis-color:#ffffff;--tblr-emphasis-color-rgb:255,255,255;--tblr-bg-forms:var(--tblr-gray-900);--tblr-bg-surface:var(--tblr-gray-800);--tblr-bg-surface-inverted:var(--tblr-gray-100);--tblr-bg-surface-secondary:var(--tblr-gray-900);--tblr-bg-surface-tertiary:var(--tblr-gray-800);--tblr-text-inverted:var(--tblr-gray-800);--tblr-link-color:var(--tblr-primary);--tblr-link-hover-color:color-mix(in srgb, var(--tblr-primary), black 20%);--tblr-active-bg:rgb(34.676744186, 45.8627906977, 61.523255814);--tblr-disabled-color:color-mix(in srgb, var(--tblr-body-color) 40%, transparent);--tblr-border-color:var(--tblr-gray-700);--tblr-border-color-translucent:var(--tblr-dark-mode-border-color-translucent);--tblr-border-dark-color:var(--tblr-dark-mode-border-dark-color);--tblr-border-active-color:var(--tblr-dark-mode-border-active-color);--tblr-btn-color:rgb(27.323255814, 36.1372093023, 48.476744186)}[data-bs-theme=dark] .navbar-brand-autodark .navbar-brand-image{filter:brightness(0) invert(1)}.accordion{--tblr-accordion-color:var(--tblr-body-color);--tblr-accordion-border-color:var(--tblr-border-color);--tblr-accordion-border-radius:var(--tblr-border-radius);--tblr-accordion-inner-border-radius:calc(var(--tblr-border-radius) - (var(--tblr-border-width)));--tblr-accordion-padding-x:1.25rem;--tblr-accordion-gap:0;--tblr-accordion-active-color:inherit;--tblr-accordion-btn-color:var(--tblr-accordion-color);--tblr-accordion-btn-bg:transparent;--tblr-accordion-btn-toggle-width:1.25rem;--tblr-accordion-btn-padding-x:var(--tblr-accordion-padding-x);--tblr-accordion-btn-padding-y:1rem;--tblr-accordion-btn-font-weight:var(--tblr-font-weight-medium);--tblr-accordion-body-padding-x:var(--tblr-accordion-padding-x);--tblr-accordion-body-padding-y:1rem;display:flex;flex-direction:column;gap:var(--tblr-accordion-gap)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--tblr-accordion-btn-padding-y) var(--tblr-accordion-padding-x);color:inherit;text-align:inherit;background-color:transparent;border:0;font-size:inherit;font-weight:var(--tblr-accordion-btn-font-weight);gap:.75rem}.accordion-button:not(.collapsed){border-bottom-color:transparent;box-shadow:none;color:var(--tblr-accordion-active-color)}.accordion-header{margin:0;position:relative;display:flex;gap:1rem;align-items:center;width:100%;color:var(--tblr-accordion-btn-color);text-align:left;background-color:transparent;border:0;overflow-anchor:none;transition:transform .3s}.accordion-header:hover{z-index:2}.accordion-header:focus{z-index:3;outline:0;box-shadow:var(--tblr-accordion-btn-focus-box-shadow)}.accordion-header:focus:not(:focus-visible){outline:0;box-shadow:none}.accordion-button-icon{color:var(--tblr-secondary)}.accordion-button-toggle{display:flex;line-height:1;transition:.3s transform;margin-left:auto;margin-right:0;color:var(--tblr-secondary);width:var(--tblr-accordion-btn-toggle-width);height:var(--tblr-accordion-btn-toggle-width)}.accordion-button:not(.collapsed) .accordion-button-toggle{transform:rotate(-180deg);color:var(--tblr-accordion-active-color)}.accordion-button-toggle path{transition:.3s opacity}.accordion-button:not(.collapsed) .accordion-button-toggle-plus path:first-child{opacity:0}.accordion-item{color:var(--tblr-accordion-color);border:var(--tblr-border-width) solid var(--tblr-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--tblr-accordion-border-radius);border-top-right-radius:var(--tblr-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header{border-top-left-radius:var(--tblr-accordion-inner-border-radius);border-top-right-radius:var(--tblr-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--tblr-accordion-border-radius);border-bottom-left-radius:var(--tblr-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header.collapsed{border-bottom-right-radius:var(--tblr-accordion-inner-border-radius);border-bottom-left-radius:var(--tblr-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--tblr-accordion-border-radius);border-bottom-left-radius:var(--tblr-accordion-border-radius)}.accordion-body{color:var(--tblr-secondary);padding:0 var(--tblr-accordion-body-padding-x) var(--tblr-accordion-body-padding-y)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-collapse,.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-tabs{--tblr-accordion-gap:0.75rem}.accordion-tabs>.accordion-item{border:var(--tblr-border-width) solid var(--tblr-accordion-border-color);border-radius:var(--tblr-accordion-border-radius)}.accordion-inverted .accordion-button-toggle{order:-1;margin-left:0}.alert{--tblr-alert-color:var(--tblr-body-color);--tblr-alert-bg:color-mix(in srgb, var(--tblr-alert-color) 10%, transparent);--tblr-alert-padding-x:1rem;--tblr-alert-padding-y:0.75rem;--tblr-alert-margin-bottom:1rem;--tblr-alert-border-color:color-mix(in srgb, var(--tblr-alert-color) 20%, transparent);--tblr-alert-border:var(--tblr-border-width) solid var(--tblr-alert-border-color);--tblr-alert-border-radius:var(--tblr-border-radius);--tblr-alert-link-color:inherit;--tblr-alert-heading-font-weight:var(--tblr-font-weight-medium);position:relative;padding:var(--tblr-alert-padding-y) var(--tblr-alert-padding-x);margin-bottom:var(--tblr-alert-margin-bottom);background-color:color-mix(in srgb,var(--tblr-alert-bg),var(--tblr-bg-surface));border-radius:var(--tblr-alert-border-radius);border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-alert-border-color);display:flex;flex-direction:row;gap:1rem}.alert-heading{color:inherit;margin-bottom:.25rem;font-weight:var(--tblr-alert-heading-font-weight)}.alert-description{color:var(--tblr-secondary)}.alert-icon{color:var(--tblr-alert-color);width:1.25rem!important;height:1.25rem!important}.alert-action{color:var(--tblr-alert-color);text-decoration:underline}.alert-action:hover{text-decoration:none}.alert-list{margin:0}.alert-link{font-weight:var(--tblr-font-weight-bold);color:var(--tblr-alert-link-color)}.alert-link,.alert-link:hover{color:var(--tblr-alert-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:calc(var(--tblr-alert-padding-x)/ 2 - 1px);right:calc(var(--tblr-alert-padding-y)/ 2 - 1px);z-index:1;padding:calc(var(--tblr-alert-padding-y) * 1.25) var(--tblr-alert-padding-x)}.alert-important{border-color:var(--tblr-alert-color);background-color:var(--tblr-alert-color);color:var(--tblr-white)}.alert-important .alert-description{color:inherit}.alert-important .alert-icon{color:inherit}.alert-minor{background:0 0;border-color:var(--tblr-border-color)}.alert-primary{--tblr-alert-color:var(--tblr-primary)}.alert-secondary{--tblr-alert-color:var(--tblr-secondary)}.alert-success{--tblr-alert-color:var(--tblr-success)}.alert-info{--tblr-alert-color:var(--tblr-info)}.alert-warning{--tblr-alert-color:var(--tblr-warning)}.alert-danger{--tblr-alert-color:var(--tblr-danger)}.alert-light{--tblr-alert-color:var(--tblr-light)}.alert-dark{--tblr-alert-color:var(--tblr-dark)}.alert-muted{--tblr-alert-color:var(--tblr-muted)}.alert-blue{--tblr-alert-color:var(--tblr-blue)}.alert-azure{--tblr-alert-color:var(--tblr-azure)}.alert-indigo{--tblr-alert-color:var(--tblr-indigo)}.alert-purple{--tblr-alert-color:var(--tblr-purple)}.alert-pink{--tblr-alert-color:var(--tblr-pink)}.alert-red{--tblr-alert-color:var(--tblr-red)}.alert-orange{--tblr-alert-color:var(--tblr-orange)}.alert-yellow{--tblr-alert-color:var(--tblr-yellow)}.alert-lime{--tblr-alert-color:var(--tblr-lime)}.alert-green{--tblr-alert-color:var(--tblr-green)}.alert-teal{--tblr-alert-color:var(--tblr-teal)}.alert-cyan{--tblr-alert-color:var(--tblr-cyan)}.avatar{--tblr-avatar-size:var(--tblr-avatar-list-size, 2.5rem);--tblr-avatar-status-size:0.75rem;--tblr-avatar-bg:var(--tblr-bg-surface-secondary);--tblr-avatar-box-shadow-color:var(--tblr-border-color-translucent);--tblr-avatar-box-shadow:inset 0 0 0 1px var(--tblr-avatar-box-shadow-color);--tblr-avatar-font-size:1rem;--tblr-avatar-icon-size:1.5rem;--tblr-avatar-brand-size:1.25rem;position:relative;width:var(--tblr-avatar-size);height:var(--tblr-avatar-size);font-size:var(--tblr-avatar-font-size);font-weight:var(--tblr-font-weight-medium);line-height:1;display:inline-flex;align-items:center;justify-content:center;color:var(--tblr-secondary);text-align:center;text-transform:uppercase;vertical-align:bottom;-webkit-user-select:none;-moz-user-select:none;user-select:none;background:var(--tblr-avatar-bg) no-repeat center/cover;border-radius:var(--tblr-border-radius);box-shadow:var(--tblr-avatar-box-shadow);transition:color .3s,background-color .3s,box-shadow .3s}.avatar .icon{width:var(--tblr-avatar-icon-size);height:var(--tblr-avatar-icon-size)}.avatar .badge{position:absolute;right:0;bottom:0;border-radius:100rem;box-shadow:0 0 0 calc(var(--tblr-avatar-status-size)/ 4) var(--tblr-bg-surface)}a.avatar{cursor:pointer}a.avatar:hover{color:var(--tblr-primary);--tblr-avatar-box-shadow-color:var(--tblr-primary)}.avatar-rounded{border-radius:100rem}.avatar-xxs{--tblr-avatar-size:1rem;--tblr-avatar-status-size:0.25rem;--tblr-avatar-font-size:0.5rem;--tblr-avatar-icon-size:0.5rem;--tblr-avatar-brand-size:0.5rem}.avatar-xxs .badge:empty{width:.25rem;height:.25rem}.avatar-xs{--tblr-avatar-size:1.25rem;--tblr-avatar-status-size:0.375rem;--tblr-avatar-font-size:0.625rem;--tblr-avatar-icon-size:0.75rem;--tblr-avatar-brand-size:0.75rem}.avatar-xs .badge:empty{width:.375rem;height:.375rem}.avatar-sm{--tblr-avatar-size:2rem;--tblr-avatar-status-size:0.5rem;--tblr-avatar-font-size:0.75rem;--tblr-avatar-icon-size:1.5rem;--tblr-avatar-brand-size:1rem}.avatar-sm .badge:empty{width:.5rem;height:.5rem}.avatar-md{--tblr-avatar-size:2.5rem;--tblr-avatar-status-size:0.75rem;--tblr-avatar-font-size:0.875rem;--tblr-avatar-icon-size:1.5rem;--tblr-avatar-brand-size:1.25rem}.avatar-md .badge:empty{width:.75rem;height:.75rem}.avatar-lg{--tblr-avatar-size:3rem;--tblr-avatar-status-size:0.75rem;--tblr-avatar-font-size:1.25rem;--tblr-avatar-icon-size:2rem;--tblr-avatar-brand-size:1.25rem}.avatar-lg .badge:empty{width:.75rem;height:.75rem}.avatar-xl{--tblr-avatar-size:5rem;--tblr-avatar-status-size:1rem;--tblr-avatar-font-size:2rem;--tblr-avatar-icon-size:3rem;--tblr-avatar-brand-size:1.25rem}.avatar-xl .badge:empty{width:1rem;height:1rem}.avatar-2xl{--tblr-avatar-size:7rem;--tblr-avatar-status-size:1rem;--tblr-avatar-font-size:3rem;--tblr-avatar-icon-size:5rem;--tblr-avatar-brand-size:2rem}.avatar-2xl .badge:empty{width:1rem;height:1rem}.avatar-list{--tblr-avatar-list-size:2.5rem;--tblr-list-gap:0.5rem;display:flex;flex-wrap:wrap;gap:var(--tblr-list-gap)}.avatar-list a.avatar:hover{z-index:1}.avatar-list-stacked{display:block;--tblr-list-gap:0}.avatar-list-stacked .avatar{margin-right:calc(-.5 * var(--tblr-avatar-size))!important;box-shadow:var(--tblr-avatar-box-shadow),0 0 0 2px var(--tblr-card-bg,var(--tblr-bg-surface))}.avatar-list-xxs{--tblr-avatar-list-size:1rem}.avatar-list-xs{--tblr-avatar-list-size:1.25rem}.avatar-list-sm{--tblr-avatar-list-size:2rem}.avatar-list-md{--tblr-avatar-list-size:2.5rem}.avatar-list-lg{--tblr-avatar-list-size:3rem}.avatar-list-xl{--tblr-avatar-list-size:5rem}.avatar-list-2xl{--tblr-avatar-list-size:7rem}.avatar-upload{border:var(--tblr-border-width) dashed var(--tblr-border-color);background:var(--tblr-bg-forms);box-shadow:none;flex-direction:column;transition:color .3s,background-color .3s}@media (prefers-reduced-motion:reduce){.avatar-upload{transition:none}}.avatar-upload svg{width:1.5rem;height:1.5rem;stroke-width:1}.avatar-upload:hover{border-color:var(--tblr-primary);color:var(--tblr-primary);text-decoration:none}.avatar-upload-text{font-size:.625rem;line-height:1;margin-top:.25rem}.avatar-cover{margin-top:calc(-.5 * var(--tblr-avatar-size));box-shadow:0 0 0 .25rem var(--tblr-card-bg,var(--tblr-body-bg))}.avatar-brand{width:var(--tblr-avatar-brand-size);height:var(--tblr-avatar-brand-size);position:absolute;right:-2px;bottom:-2px;z-index:1000;background:var(--tblr-bg-surface);border-radius:var(--tblr-border-radius);border:1px solid var(--tblr-border-color)}.badge{--tblr-badge-padding-x:0.5em;--tblr-badge-padding-y:0.25em;--tblr-badge-font-size:0.85714285em;--tblr-badge-font-weight:var(--tblr-font-weight-medium);--tblr-badge-color:var(--tblr-secondary);--tblr-badge-border-radius:var(--tblr-border-radius);--tblr-badge-icon-size:1em;--tblr-badge-line-height:1;display:inline-flex;padding:var(--tblr-badge-padding-y) var(--tblr-badge-padding-x);font-weight:var(--tblr-badge-font-weight);font-size:var(--tblr-badge-font-size);color:var(--tblr-badge-color);text-align:center;white-space:nowrap;justify-content:center;align-items:center;gap:.25rem;background:var(--tblr-bg-surface-secondary);overflow:hidden;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--tblr-border-width) var(--tblr-border-style) transparent;border-radius:var(--tblr-badge-border-radius);min-width:calc(1em + var(--tblr-badge-padding-y) * 2 + 2px);letter-spacing:.04em;vertical-align:bottom;line-height:var(--tblr-badge-line-height)}a.badge{background:var(--tblr-bg-surface-secondary)}.badge .icon{width:1em;height:1em;font-size:var(--tblr-badge-icon-size);stroke-width:2}.badge-dot,.badge:empty{display:inline-block;width:10px;height:10px;min-width:0;min-height:auto;padding:0;border-radius:100rem;vertical-align:baseline}.badge-outline{background-color:transparent;border:var(--tblr-border-width) var(--tblr-border-style) currentColor}.badge-pill{border-radius:100rem}.badges-list{--tblr-list-gap:0.5rem;display:flex;flex-wrap:wrap;gap:var(--tblr-list-gap)}.badge-notification{position:absolute!important;top:0!important;right:0!important;transform:translate(50%,-50%);z-index:1}.badge-blink{animation:blink 2s infinite}.badge-sm{--tblr-badge-font-size:0.71428571em;--tblr-badge-icon-size:1em;--tblr-badge-padding-y:2px;--tblr-badge-padding-x:0.25rem}.badge-lg{--tblr-badge-font-size:1em;--tblr-badge-icon-size:1em;--tblr-badge-padding-y:0.25rem;--tblr-badge-padding-x:0.5rem}.badge-icononly{--tblr-badge-padding-x:0}.breadcrumb{--tblr-breadcrumb-padding-x:0;--tblr-breadcrumb-padding-y:0;--tblr-breadcrumb-margin-bottom:1rem;--tblr-breadcrumb-font-size: ;--tblr-breadcrumb-bg: ;--tblr-breadcrumb-border-radius: ;--tblr-breadcrumb-divider-color:var(--tblr-gray-500);--tblr-breadcrumb-item-padding-x:0.5rem;--tblr-breadcrumb-item-active-color:inherit;--tblr-breadcrumb-item-active-font-weight:var(--tblr-font-weight-bold);--tblr-breadcrumb-item-disabled-color:var(--tblr-disabled-color);--tblr-breadcrumb-link-color:var(--tblr-link-color);display:flex;flex-wrap:wrap;font-size:var(--tblr-breadcrumb-font-size);list-style:none;background-color:var(--tblr-breadcrumb-bg);border-radius:var(--tblr-breadcrumb-border-radius);padding:0;margin:0;background:0 0}.breadcrumb a{color:var(--tblr-breadcrumb-link-color)}.breadcrumb a:hover{text-decoration:underline}.breadcrumb-muted{--tblr-breadcrumb-link-color:var(--tblr-secondary)}.breadcrumb-item.active{color:var(--tblr-breadcrumb-item-active-color);font-weight:var(--tblr-breadcrumb-item-active-font-weight)}.breadcrumb-item.active a{color:inherit;pointer-events:none}.breadcrumb-item.disabled{color:var(--tblr-breadcrumb-item-disabled-color)}.breadcrumb-item.disabled:before{color:inherit}.breadcrumb-item.disabled a{color:inherit;pointer-events:none}.breadcrumb-item+.breadcrumb-item{padding-left:var(--tblr-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--tblr-breadcrumb-item-padding-x);color:var(--tblr-breadcrumb-divider-color);content:var(--tblr-breadcrumb-divider, "/")}.breadcrumb-dots{--tblr-breadcrumb-divider:"·"}.breadcrumb-arrows{--tblr-breadcrumb-divider:"›"}.breadcrumb-bullets{--tblr-breadcrumb-divider:"•"}.btn{--tblr-btn-icon-size:1.25rem;--tblr-btn-icon-color:inherit;--tblr-btn-bg:var(--tblr-bg-surface);--tblr-btn-color:var(--tblr-body-color);--tblr-btn-border-color:var(--tblr-border-color);--tblr-btn-hover-bg:var(--tblr-btn-bg);--tblr-btn-hover-border-color:var(--tblr-border-active-color);--tblr-btn-active-color:var(--tblr-primary);--tblr-btn-active-bg:rgba(var(--tblr-primary-rgb), 0.04);--tblr-btn-active-border-color:var(--tblr-primary);display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;box-shadow:var(--tblr-btn-box-shadow);position:relative;min-width:calc(var(--tblr-btn-line-height) * 1 + var(--tblr-btn-padding-y) * 2 + var(--tblr-btn-border-width) * 2);min-height:calc(var(--tblr-btn-line-height) * 1 + var(--tblr-btn-padding-y) * 2 + var(--tblr-btn-border-width) * 2)}.btn .icon{width:var(--tblr-btn-icon-size);height:var(--tblr-btn-icon-size);min-width:var(--tblr-btn-icon-size);font-size:var(--tblr-btn-icon-size);margin:0 calc(var(--tblr-btn-padding-x)/ 2) 0 calc(var(--tblr-btn-padding-x)/ -4);vertical-align:bottom;color:var(--tblr-btn-icon-color)}.btn .avatar{width:var(--tblr-btn-icon-size);height:var(--tblr-btn-icon-size);margin:0 calc(var(--tblr-btn-padding-x)/ 2) 0 calc(var(--tblr-btn-padding-x)/ -4)}.btn .icon-end,.btn .icon-right{margin:0 calc(var(--tblr-btn-padding-x)/ -4) 0 calc(var(--tblr-btn-padding-x)/ 2)}.btn .badge{top:auto}.btn-check+.btn:hover{color:var(--tblr-btn-hover-color);background-color:var(--tblr-btn-hover-bg);border-color:var(--tblr-btn-hover-border-color)}.btn-link{color:rgb(6.711627907,124.1651162791,233.788372093);background-color:transparent;border-color:transparent;box-shadow:none}.btn-link .icon{color:inherit}.btn-link:hover{color:rgb(4.8,88.8,167.2);border-color:transparent}.btn-primary{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-primary-fg, #ffffff);--tblr-btn-bg:var(--tblr-primary);--tblr-btn-hover-color:var(--tblr-primary-fg);--tblr-btn-hover-bg:var(--tblr-primary-darken);--tblr-btn-active-color:var(--tblr-primary-fg);--tblr-btn-active-bg:var(--tblr-primary-darken);--tblr-btn-disabled-bg:var(--tblr-primary);--tblr-btn-disabled-color:var(--tblr-primary-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-primary,.btn-outline.btn-primary{--tblr-btn-color:var(--tblr-primary);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-primary);--tblr-btn-hover-color:var(--tblr-primary-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-primary);--tblr-btn-active-color:var(--tblr-primary-fg);--tblr-btn-active-bg:var(--tblr-primary);--tblr-btn-active-border-color:var(--tblr-primary);--tblr-btn-disabled-color:var(--tblr-primary);--tblr-btn-disabled-border-color:var(--tblr-primary)}.btn-ghost-primary,.btn-ghost.btn-primary{--tblr-btn-color:var(--tblr-primary);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-primary-fg);--tblr-btn-hover-bg:var(--tblr-primary);--tblr-btn-hover-border-color:var(--tblr-primary);--tblr-btn-active-color:var(--tblr-primary-fg);--tblr-btn-active-bg:var(--tblr-primary);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-primary);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-secondary{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-secondary-fg, #ffffff);--tblr-btn-bg:var(--tblr-secondary);--tblr-btn-hover-color:var(--tblr-secondary-fg);--tblr-btn-hover-bg:var(--tblr-secondary-darken);--tblr-btn-active-color:var(--tblr-secondary-fg);--tblr-btn-active-bg:var(--tblr-secondary-darken);--tblr-btn-disabled-bg:var(--tblr-secondary);--tblr-btn-disabled-color:var(--tblr-secondary-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-secondary,.btn-outline.btn-secondary{--tblr-btn-color:var(--tblr-secondary);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-secondary);--tblr-btn-hover-color:var(--tblr-secondary-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-secondary);--tblr-btn-active-color:var(--tblr-secondary-fg);--tblr-btn-active-bg:var(--tblr-secondary);--tblr-btn-active-border-color:var(--tblr-secondary);--tblr-btn-disabled-color:var(--tblr-secondary);--tblr-btn-disabled-border-color:var(--tblr-secondary)}.btn-ghost-secondary,.btn-ghost.btn-secondary{--tblr-btn-color:var(--tblr-secondary);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-secondary-fg);--tblr-btn-hover-bg:var(--tblr-secondary);--tblr-btn-hover-border-color:var(--tblr-secondary);--tblr-btn-active-color:var(--tblr-secondary-fg);--tblr-btn-active-bg:var(--tblr-secondary);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-secondary);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-success{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-success-fg, #ffffff);--tblr-btn-bg:var(--tblr-success);--tblr-btn-hover-color:var(--tblr-success-fg);--tblr-btn-hover-bg:var(--tblr-success-darken);--tblr-btn-active-color:var(--tblr-success-fg);--tblr-btn-active-bg:var(--tblr-success-darken);--tblr-btn-disabled-bg:var(--tblr-success);--tblr-btn-disabled-color:var(--tblr-success-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-success,.btn-outline.btn-success{--tblr-btn-color:var(--tblr-success);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-success);--tblr-btn-hover-color:var(--tblr-success-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-success);--tblr-btn-active-color:var(--tblr-success-fg);--tblr-btn-active-bg:var(--tblr-success);--tblr-btn-active-border-color:var(--tblr-success);--tblr-btn-disabled-color:var(--tblr-success);--tblr-btn-disabled-border-color:var(--tblr-success)}.btn-ghost-success,.btn-ghost.btn-success{--tblr-btn-color:var(--tblr-success);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-success-fg);--tblr-btn-hover-bg:var(--tblr-success);--tblr-btn-hover-border-color:var(--tblr-success);--tblr-btn-active-color:var(--tblr-success-fg);--tblr-btn-active-bg:var(--tblr-success);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-success);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-info{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-info-fg, #ffffff);--tblr-btn-bg:var(--tblr-info);--tblr-btn-hover-color:var(--tblr-info-fg);--tblr-btn-hover-bg:var(--tblr-info-darken);--tblr-btn-active-color:var(--tblr-info-fg);--tblr-btn-active-bg:var(--tblr-info-darken);--tblr-btn-disabled-bg:var(--tblr-info);--tblr-btn-disabled-color:var(--tblr-info-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-info,.btn-outline.btn-info{--tblr-btn-color:var(--tblr-info);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-info);--tblr-btn-hover-color:var(--tblr-info-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-info);--tblr-btn-active-color:var(--tblr-info-fg);--tblr-btn-active-bg:var(--tblr-info);--tblr-btn-active-border-color:var(--tblr-info);--tblr-btn-disabled-color:var(--tblr-info);--tblr-btn-disabled-border-color:var(--tblr-info)}.btn-ghost-info,.btn-ghost.btn-info{--tblr-btn-color:var(--tblr-info);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-info-fg);--tblr-btn-hover-bg:var(--tblr-info);--tblr-btn-hover-border-color:var(--tblr-info);--tblr-btn-active-color:var(--tblr-info-fg);--tblr-btn-active-bg:var(--tblr-info);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-info);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-warning{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-warning-fg, #ffffff);--tblr-btn-bg:var(--tblr-warning);--tblr-btn-hover-color:var(--tblr-warning-fg);--tblr-btn-hover-bg:var(--tblr-warning-darken);--tblr-btn-active-color:var(--tblr-warning-fg);--tblr-btn-active-bg:var(--tblr-warning-darken);--tblr-btn-disabled-bg:var(--tblr-warning);--tblr-btn-disabled-color:var(--tblr-warning-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-warning,.btn-outline.btn-warning{--tblr-btn-color:var(--tblr-warning);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-warning);--tblr-btn-hover-color:var(--tblr-warning-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-warning);--tblr-btn-active-color:var(--tblr-warning-fg);--tblr-btn-active-bg:var(--tblr-warning);--tblr-btn-active-border-color:var(--tblr-warning);--tblr-btn-disabled-color:var(--tblr-warning);--tblr-btn-disabled-border-color:var(--tblr-warning)}.btn-ghost-warning,.btn-ghost.btn-warning{--tblr-btn-color:var(--tblr-warning);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-warning-fg);--tblr-btn-hover-bg:var(--tblr-warning);--tblr-btn-hover-border-color:var(--tblr-warning);--tblr-btn-active-color:var(--tblr-warning-fg);--tblr-btn-active-bg:var(--tblr-warning);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-warning);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-danger{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-danger-fg, #ffffff);--tblr-btn-bg:var(--tblr-danger);--tblr-btn-hover-color:var(--tblr-danger-fg);--tblr-btn-hover-bg:var(--tblr-danger-darken);--tblr-btn-active-color:var(--tblr-danger-fg);--tblr-btn-active-bg:var(--tblr-danger-darken);--tblr-btn-disabled-bg:var(--tblr-danger);--tblr-btn-disabled-color:var(--tblr-danger-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-danger,.btn-outline.btn-danger{--tblr-btn-color:var(--tblr-danger);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-danger);--tblr-btn-hover-color:var(--tblr-danger-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-danger);--tblr-btn-active-color:var(--tblr-danger-fg);--tblr-btn-active-bg:var(--tblr-danger);--tblr-btn-active-border-color:var(--tblr-danger);--tblr-btn-disabled-color:var(--tblr-danger);--tblr-btn-disabled-border-color:var(--tblr-danger)}.btn-ghost-danger,.btn-ghost.btn-danger{--tblr-btn-color:var(--tblr-danger);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-danger-fg);--tblr-btn-hover-bg:var(--tblr-danger);--tblr-btn-hover-border-color:var(--tblr-danger);--tblr-btn-active-color:var(--tblr-danger-fg);--tblr-btn-active-bg:var(--tblr-danger);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-danger);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-light{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-light-fg, #ffffff);--tblr-btn-bg:var(--tblr-light);--tblr-btn-hover-color:var(--tblr-light-fg);--tblr-btn-hover-bg:var(--tblr-light-darken);--tblr-btn-active-color:var(--tblr-light-fg);--tblr-btn-active-bg:var(--tblr-light-darken);--tblr-btn-disabled-bg:var(--tblr-light);--tblr-btn-disabled-color:var(--tblr-light-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-light,.btn-outline.btn-light{--tblr-btn-color:var(--tblr-light);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-light);--tblr-btn-hover-color:var(--tblr-light-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-light);--tblr-btn-active-color:var(--tblr-light-fg);--tblr-btn-active-bg:var(--tblr-light);--tblr-btn-active-border-color:var(--tblr-light);--tblr-btn-disabled-color:var(--tblr-light);--tblr-btn-disabled-border-color:var(--tblr-light)}.btn-ghost-light,.btn-ghost.btn-light{--tblr-btn-color:var(--tblr-light);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-light-fg);--tblr-btn-hover-bg:var(--tblr-light);--tblr-btn-hover-border-color:var(--tblr-light);--tblr-btn-active-color:var(--tblr-light-fg);--tblr-btn-active-bg:var(--tblr-light);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-light);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-dark{--tblr-btn-border-color:var(--tblr-dark-mode-border-color);--tblr-btn-hover-border-color:var(--tblr-dark-mode-border-active-color);--tblr-btn-active-border-color:var(--tblr-dark-mode-border-active-color);--tblr-btn-color:var(--tblr-dark-fg, #ffffff);--tblr-btn-bg:var(--tblr-dark);--tblr-btn-hover-color:var(--tblr-dark-fg);--tblr-btn-hover-bg:var(--tblr-dark-darken);--tblr-btn-active-color:var(--tblr-dark-fg);--tblr-btn-active-bg:var(--tblr-dark-darken);--tblr-btn-disabled-bg:var(--tblr-dark);--tblr-btn-disabled-color:var(--tblr-dark-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-dark,.btn-outline.btn-dark{--tblr-btn-color:var(--tblr-dark);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-dark);--tblr-btn-hover-color:var(--tblr-dark-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-dark);--tblr-btn-active-color:var(--tblr-dark-fg);--tblr-btn-active-bg:var(--tblr-dark);--tblr-btn-active-border-color:var(--tblr-dark);--tblr-btn-disabled-color:var(--tblr-dark);--tblr-btn-disabled-border-color:var(--tblr-dark)}.btn-ghost-dark,.btn-ghost.btn-dark{--tblr-btn-color:var(--tblr-dark);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-dark-fg);--tblr-btn-hover-bg:var(--tblr-dark);--tblr-btn-hover-border-color:var(--tblr-dark);--tblr-btn-active-color:var(--tblr-dark-fg);--tblr-btn-active-bg:var(--tblr-dark);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-dark);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-muted{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-muted-fg, #ffffff);--tblr-btn-bg:var(--tblr-muted);--tblr-btn-hover-color:var(--tblr-muted-fg);--tblr-btn-hover-bg:var(--tblr-muted-darken);--tblr-btn-active-color:var(--tblr-muted-fg);--tblr-btn-active-bg:var(--tblr-muted-darken);--tblr-btn-disabled-bg:var(--tblr-muted);--tblr-btn-disabled-color:var(--tblr-muted-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-muted,.btn-outline.btn-muted{--tblr-btn-color:var(--tblr-muted);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-muted);--tblr-btn-hover-color:var(--tblr-muted-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-muted);--tblr-btn-active-color:var(--tblr-muted-fg);--tblr-btn-active-bg:var(--tblr-muted);--tblr-btn-active-border-color:var(--tblr-muted);--tblr-btn-disabled-color:var(--tblr-muted);--tblr-btn-disabled-border-color:var(--tblr-muted)}.btn-ghost-muted,.btn-ghost.btn-muted{--tblr-btn-color:var(--tblr-muted);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-muted-fg);--tblr-btn-hover-bg:var(--tblr-muted);--tblr-btn-hover-border-color:var(--tblr-muted);--tblr-btn-active-color:var(--tblr-muted-fg);--tblr-btn-active-bg:var(--tblr-muted);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-muted);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-blue{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-blue-fg, #ffffff);--tblr-btn-bg:var(--tblr-blue);--tblr-btn-hover-color:var(--tblr-blue-fg);--tblr-btn-hover-bg:var(--tblr-blue-darken);--tblr-btn-active-color:var(--tblr-blue-fg);--tblr-btn-active-bg:var(--tblr-blue-darken);--tblr-btn-disabled-bg:var(--tblr-blue);--tblr-btn-disabled-color:var(--tblr-blue-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-blue,.btn-outline.btn-blue{--tblr-btn-color:var(--tblr-blue);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-blue);--tblr-btn-hover-color:var(--tblr-blue-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-blue);--tblr-btn-active-color:var(--tblr-blue-fg);--tblr-btn-active-bg:var(--tblr-blue);--tblr-btn-active-border-color:var(--tblr-blue);--tblr-btn-disabled-color:var(--tblr-blue);--tblr-btn-disabled-border-color:var(--tblr-blue)}.btn-ghost-blue,.btn-ghost.btn-blue{--tblr-btn-color:var(--tblr-blue);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-blue-fg);--tblr-btn-hover-bg:var(--tblr-blue);--tblr-btn-hover-border-color:var(--tblr-blue);--tblr-btn-active-color:var(--tblr-blue-fg);--tblr-btn-active-bg:var(--tblr-blue);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-blue);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-azure{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-azure-fg, #ffffff);--tblr-btn-bg:var(--tblr-azure);--tblr-btn-hover-color:var(--tblr-azure-fg);--tblr-btn-hover-bg:var(--tblr-azure-darken);--tblr-btn-active-color:var(--tblr-azure-fg);--tblr-btn-active-bg:var(--tblr-azure-darken);--tblr-btn-disabled-bg:var(--tblr-azure);--tblr-btn-disabled-color:var(--tblr-azure-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-azure,.btn-outline.btn-azure{--tblr-btn-color:var(--tblr-azure);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-azure);--tblr-btn-hover-color:var(--tblr-azure-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-azure);--tblr-btn-active-color:var(--tblr-azure-fg);--tblr-btn-active-bg:var(--tblr-azure);--tblr-btn-active-border-color:var(--tblr-azure);--tblr-btn-disabled-color:var(--tblr-azure);--tblr-btn-disabled-border-color:var(--tblr-azure)}.btn-ghost-azure,.btn-ghost.btn-azure{--tblr-btn-color:var(--tblr-azure);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-azure-fg);--tblr-btn-hover-bg:var(--tblr-azure);--tblr-btn-hover-border-color:var(--tblr-azure);--tblr-btn-active-color:var(--tblr-azure-fg);--tblr-btn-active-bg:var(--tblr-azure);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-azure);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-indigo{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-indigo-fg, #ffffff);--tblr-btn-bg:var(--tblr-indigo);--tblr-btn-hover-color:var(--tblr-indigo-fg);--tblr-btn-hover-bg:var(--tblr-indigo-darken);--tblr-btn-active-color:var(--tblr-indigo-fg);--tblr-btn-active-bg:var(--tblr-indigo-darken);--tblr-btn-disabled-bg:var(--tblr-indigo);--tblr-btn-disabled-color:var(--tblr-indigo-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-indigo,.btn-outline.btn-indigo{--tblr-btn-color:var(--tblr-indigo);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-indigo);--tblr-btn-hover-color:var(--tblr-indigo-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-indigo);--tblr-btn-active-color:var(--tblr-indigo-fg);--tblr-btn-active-bg:var(--tblr-indigo);--tblr-btn-active-border-color:var(--tblr-indigo);--tblr-btn-disabled-color:var(--tblr-indigo);--tblr-btn-disabled-border-color:var(--tblr-indigo)}.btn-ghost-indigo,.btn-ghost.btn-indigo{--tblr-btn-color:var(--tblr-indigo);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-indigo-fg);--tblr-btn-hover-bg:var(--tblr-indigo);--tblr-btn-hover-border-color:var(--tblr-indigo);--tblr-btn-active-color:var(--tblr-indigo-fg);--tblr-btn-active-bg:var(--tblr-indigo);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-indigo);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-purple{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-purple-fg, #ffffff);--tblr-btn-bg:var(--tblr-purple);--tblr-btn-hover-color:var(--tblr-purple-fg);--tblr-btn-hover-bg:var(--tblr-purple-darken);--tblr-btn-active-color:var(--tblr-purple-fg);--tblr-btn-active-bg:var(--tblr-purple-darken);--tblr-btn-disabled-bg:var(--tblr-purple);--tblr-btn-disabled-color:var(--tblr-purple-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-purple,.btn-outline.btn-purple{--tblr-btn-color:var(--tblr-purple);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-purple);--tblr-btn-hover-color:var(--tblr-purple-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-purple);--tblr-btn-active-color:var(--tblr-purple-fg);--tblr-btn-active-bg:var(--tblr-purple);--tblr-btn-active-border-color:var(--tblr-purple);--tblr-btn-disabled-color:var(--tblr-purple);--tblr-btn-disabled-border-color:var(--tblr-purple)}.btn-ghost-purple,.btn-ghost.btn-purple{--tblr-btn-color:var(--tblr-purple);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-purple-fg);--tblr-btn-hover-bg:var(--tblr-purple);--tblr-btn-hover-border-color:var(--tblr-purple);--tblr-btn-active-color:var(--tblr-purple-fg);--tblr-btn-active-bg:var(--tblr-purple);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-purple);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-pink{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-pink-fg, #ffffff);--tblr-btn-bg:var(--tblr-pink);--tblr-btn-hover-color:var(--tblr-pink-fg);--tblr-btn-hover-bg:var(--tblr-pink-darken);--tblr-btn-active-color:var(--tblr-pink-fg);--tblr-btn-active-bg:var(--tblr-pink-darken);--tblr-btn-disabled-bg:var(--tblr-pink);--tblr-btn-disabled-color:var(--tblr-pink-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-pink,.btn-outline.btn-pink{--tblr-btn-color:var(--tblr-pink);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-pink);--tblr-btn-hover-color:var(--tblr-pink-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-pink);--tblr-btn-active-color:var(--tblr-pink-fg);--tblr-btn-active-bg:var(--tblr-pink);--tblr-btn-active-border-color:var(--tblr-pink);--tblr-btn-disabled-color:var(--tblr-pink);--tblr-btn-disabled-border-color:var(--tblr-pink)}.btn-ghost-pink,.btn-ghost.btn-pink{--tblr-btn-color:var(--tblr-pink);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-pink-fg);--tblr-btn-hover-bg:var(--tblr-pink);--tblr-btn-hover-border-color:var(--tblr-pink);--tblr-btn-active-color:var(--tblr-pink-fg);--tblr-btn-active-bg:var(--tblr-pink);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-pink);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-red{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-red-fg, #ffffff);--tblr-btn-bg:var(--tblr-red);--tblr-btn-hover-color:var(--tblr-red-fg);--tblr-btn-hover-bg:var(--tblr-red-darken);--tblr-btn-active-color:var(--tblr-red-fg);--tblr-btn-active-bg:var(--tblr-red-darken);--tblr-btn-disabled-bg:var(--tblr-red);--tblr-btn-disabled-color:var(--tblr-red-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-red,.btn-outline.btn-red{--tblr-btn-color:var(--tblr-red);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-red);--tblr-btn-hover-color:var(--tblr-red-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-red);--tblr-btn-active-color:var(--tblr-red-fg);--tblr-btn-active-bg:var(--tblr-red);--tblr-btn-active-border-color:var(--tblr-red);--tblr-btn-disabled-color:var(--tblr-red);--tblr-btn-disabled-border-color:var(--tblr-red)}.btn-ghost-red,.btn-ghost.btn-red{--tblr-btn-color:var(--tblr-red);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-red-fg);--tblr-btn-hover-bg:var(--tblr-red);--tblr-btn-hover-border-color:var(--tblr-red);--tblr-btn-active-color:var(--tblr-red-fg);--tblr-btn-active-bg:var(--tblr-red);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-red);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-orange{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-orange-fg, #ffffff);--tblr-btn-bg:var(--tblr-orange);--tblr-btn-hover-color:var(--tblr-orange-fg);--tblr-btn-hover-bg:var(--tblr-orange-darken);--tblr-btn-active-color:var(--tblr-orange-fg);--tblr-btn-active-bg:var(--tblr-orange-darken);--tblr-btn-disabled-bg:var(--tblr-orange);--tblr-btn-disabled-color:var(--tblr-orange-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-orange,.btn-outline.btn-orange{--tblr-btn-color:var(--tblr-orange);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-orange);--tblr-btn-hover-color:var(--tblr-orange-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-orange);--tblr-btn-active-color:var(--tblr-orange-fg);--tblr-btn-active-bg:var(--tblr-orange);--tblr-btn-active-border-color:var(--tblr-orange);--tblr-btn-disabled-color:var(--tblr-orange);--tblr-btn-disabled-border-color:var(--tblr-orange)}.btn-ghost-orange,.btn-ghost.btn-orange{--tblr-btn-color:var(--tblr-orange);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-orange-fg);--tblr-btn-hover-bg:var(--tblr-orange);--tblr-btn-hover-border-color:var(--tblr-orange);--tblr-btn-active-color:var(--tblr-orange-fg);--tblr-btn-active-bg:var(--tblr-orange);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-orange);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-yellow{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-yellow-fg, #ffffff);--tblr-btn-bg:var(--tblr-yellow);--tblr-btn-hover-color:var(--tblr-yellow-fg);--tblr-btn-hover-bg:var(--tblr-yellow-darken);--tblr-btn-active-color:var(--tblr-yellow-fg);--tblr-btn-active-bg:var(--tblr-yellow-darken);--tblr-btn-disabled-bg:var(--tblr-yellow);--tblr-btn-disabled-color:var(--tblr-yellow-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-yellow,.btn-outline.btn-yellow{--tblr-btn-color:var(--tblr-yellow);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-yellow);--tblr-btn-hover-color:var(--tblr-yellow-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-yellow);--tblr-btn-active-color:var(--tblr-yellow-fg);--tblr-btn-active-bg:var(--tblr-yellow);--tblr-btn-active-border-color:var(--tblr-yellow);--tblr-btn-disabled-color:var(--tblr-yellow);--tblr-btn-disabled-border-color:var(--tblr-yellow)}.btn-ghost-yellow,.btn-ghost.btn-yellow{--tblr-btn-color:var(--tblr-yellow);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-yellow-fg);--tblr-btn-hover-bg:var(--tblr-yellow);--tblr-btn-hover-border-color:var(--tblr-yellow);--tblr-btn-active-color:var(--tblr-yellow-fg);--tblr-btn-active-bg:var(--tblr-yellow);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-yellow);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-lime{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-lime-fg, #ffffff);--tblr-btn-bg:var(--tblr-lime);--tblr-btn-hover-color:var(--tblr-lime-fg);--tblr-btn-hover-bg:var(--tblr-lime-darken);--tblr-btn-active-color:var(--tblr-lime-fg);--tblr-btn-active-bg:var(--tblr-lime-darken);--tblr-btn-disabled-bg:var(--tblr-lime);--tblr-btn-disabled-color:var(--tblr-lime-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-lime,.btn-outline.btn-lime{--tblr-btn-color:var(--tblr-lime);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-lime);--tblr-btn-hover-color:var(--tblr-lime-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-lime);--tblr-btn-active-color:var(--tblr-lime-fg);--tblr-btn-active-bg:var(--tblr-lime);--tblr-btn-active-border-color:var(--tblr-lime);--tblr-btn-disabled-color:var(--tblr-lime);--tblr-btn-disabled-border-color:var(--tblr-lime)}.btn-ghost-lime,.btn-ghost.btn-lime{--tblr-btn-color:var(--tblr-lime);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-lime-fg);--tblr-btn-hover-bg:var(--tblr-lime);--tblr-btn-hover-border-color:var(--tblr-lime);--tblr-btn-active-color:var(--tblr-lime-fg);--tblr-btn-active-bg:var(--tblr-lime);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-lime);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-green{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-green-fg, #ffffff);--tblr-btn-bg:var(--tblr-green);--tblr-btn-hover-color:var(--tblr-green-fg);--tblr-btn-hover-bg:var(--tblr-green-darken);--tblr-btn-active-color:var(--tblr-green-fg);--tblr-btn-active-bg:var(--tblr-green-darken);--tblr-btn-disabled-bg:var(--tblr-green);--tblr-btn-disabled-color:var(--tblr-green-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-green,.btn-outline.btn-green{--tblr-btn-color:var(--tblr-green);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-green);--tblr-btn-hover-color:var(--tblr-green-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-green);--tblr-btn-active-color:var(--tblr-green-fg);--tblr-btn-active-bg:var(--tblr-green);--tblr-btn-active-border-color:var(--tblr-green);--tblr-btn-disabled-color:var(--tblr-green);--tblr-btn-disabled-border-color:var(--tblr-green)}.btn-ghost-green,.btn-ghost.btn-green{--tblr-btn-color:var(--tblr-green);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-green-fg);--tblr-btn-hover-bg:var(--tblr-green);--tblr-btn-hover-border-color:var(--tblr-green);--tblr-btn-active-color:var(--tblr-green-fg);--tblr-btn-active-bg:var(--tblr-green);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-green);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-teal{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-teal-fg, #ffffff);--tblr-btn-bg:var(--tblr-teal);--tblr-btn-hover-color:var(--tblr-teal-fg);--tblr-btn-hover-bg:var(--tblr-teal-darken);--tblr-btn-active-color:var(--tblr-teal-fg);--tblr-btn-active-bg:var(--tblr-teal-darken);--tblr-btn-disabled-bg:var(--tblr-teal);--tblr-btn-disabled-color:var(--tblr-teal-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-teal,.btn-outline.btn-teal{--tblr-btn-color:var(--tblr-teal);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-teal);--tblr-btn-hover-color:var(--tblr-teal-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-teal);--tblr-btn-active-color:var(--tblr-teal-fg);--tblr-btn-active-bg:var(--tblr-teal);--tblr-btn-active-border-color:var(--tblr-teal);--tblr-btn-disabled-color:var(--tblr-teal);--tblr-btn-disabled-border-color:var(--tblr-teal)}.btn-ghost-teal,.btn-ghost.btn-teal{--tblr-btn-color:var(--tblr-teal);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-teal-fg);--tblr-btn-hover-bg:var(--tblr-teal);--tblr-btn-hover-border-color:var(--tblr-teal);--tblr-btn-active-color:var(--tblr-teal-fg);--tblr-btn-active-bg:var(--tblr-teal);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-teal);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-cyan{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-cyan-fg, #ffffff);--tblr-btn-bg:var(--tblr-cyan);--tblr-btn-hover-color:var(--tblr-cyan-fg);--tblr-btn-hover-bg:var(--tblr-cyan-darken);--tblr-btn-active-color:var(--tblr-cyan-fg);--tblr-btn-active-bg:var(--tblr-cyan-darken);--tblr-btn-disabled-bg:var(--tblr-cyan);--tblr-btn-disabled-color:var(--tblr-cyan-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-cyan,.btn-outline.btn-cyan{--tblr-btn-color:var(--tblr-cyan);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-cyan);--tblr-btn-hover-color:var(--tblr-cyan-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-cyan);--tblr-btn-active-color:var(--tblr-cyan-fg);--tblr-btn-active-bg:var(--tblr-cyan);--tblr-btn-active-border-color:var(--tblr-cyan);--tblr-btn-disabled-color:var(--tblr-cyan);--tblr-btn-disabled-border-color:var(--tblr-cyan)}.btn-ghost-cyan,.btn-ghost.btn-cyan{--tblr-btn-color:var(--tblr-cyan);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-cyan-fg);--tblr-btn-hover-bg:var(--tblr-cyan);--tblr-btn-hover-border-color:var(--tblr-cyan);--tblr-btn-active-color:var(--tblr-cyan-fg);--tblr-btn-active-bg:var(--tblr-cyan);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-cyan);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-x{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-x-fg, #ffffff);--tblr-btn-bg:var(--tblr-x);--tblr-btn-hover-color:var(--tblr-x-fg);--tblr-btn-hover-bg:var(--tblr-x-darken);--tblr-btn-active-color:var(--tblr-x-fg);--tblr-btn-active-bg:var(--tblr-x-darken);--tblr-btn-disabled-bg:var(--tblr-x);--tblr-btn-disabled-color:var(--tblr-x-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-x,.btn-outline.btn-x{--tblr-btn-color:var(--tblr-x);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-x);--tblr-btn-hover-color:var(--tblr-x-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-x);--tblr-btn-active-color:var(--tblr-x-fg);--tblr-btn-active-bg:var(--tblr-x);--tblr-btn-active-border-color:var(--tblr-x);--tblr-btn-disabled-color:var(--tblr-x);--tblr-btn-disabled-border-color:var(--tblr-x)}.btn-ghost-x,.btn-ghost.btn-x{--tblr-btn-color:var(--tblr-x);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-x-fg);--tblr-btn-hover-bg:var(--tblr-x);--tblr-btn-hover-border-color:var(--tblr-x);--tblr-btn-active-color:var(--tblr-x-fg);--tblr-btn-active-bg:var(--tblr-x);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-x);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-facebook{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-facebook-fg, #ffffff);--tblr-btn-bg:var(--tblr-facebook);--tblr-btn-hover-color:var(--tblr-facebook-fg);--tblr-btn-hover-bg:var(--tblr-facebook-darken);--tblr-btn-active-color:var(--tblr-facebook-fg);--tblr-btn-active-bg:var(--tblr-facebook-darken);--tblr-btn-disabled-bg:var(--tblr-facebook);--tblr-btn-disabled-color:var(--tblr-facebook-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-facebook,.btn-outline.btn-facebook{--tblr-btn-color:var(--tblr-facebook);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-facebook);--tblr-btn-hover-color:var(--tblr-facebook-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-facebook);--tblr-btn-active-color:var(--tblr-facebook-fg);--tblr-btn-active-bg:var(--tblr-facebook);--tblr-btn-active-border-color:var(--tblr-facebook);--tblr-btn-disabled-color:var(--tblr-facebook);--tblr-btn-disabled-border-color:var(--tblr-facebook)}.btn-ghost-facebook,.btn-ghost.btn-facebook{--tblr-btn-color:var(--tblr-facebook);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-facebook-fg);--tblr-btn-hover-bg:var(--tblr-facebook);--tblr-btn-hover-border-color:var(--tblr-facebook);--tblr-btn-active-color:var(--tblr-facebook-fg);--tblr-btn-active-bg:var(--tblr-facebook);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-facebook);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-twitter{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-twitter-fg, #ffffff);--tblr-btn-bg:var(--tblr-twitter);--tblr-btn-hover-color:var(--tblr-twitter-fg);--tblr-btn-hover-bg:var(--tblr-twitter-darken);--tblr-btn-active-color:var(--tblr-twitter-fg);--tblr-btn-active-bg:var(--tblr-twitter-darken);--tblr-btn-disabled-bg:var(--tblr-twitter);--tblr-btn-disabled-color:var(--tblr-twitter-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-twitter,.btn-outline.btn-twitter{--tblr-btn-color:var(--tblr-twitter);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-twitter);--tblr-btn-hover-color:var(--tblr-twitter-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-twitter);--tblr-btn-active-color:var(--tblr-twitter-fg);--tblr-btn-active-bg:var(--tblr-twitter);--tblr-btn-active-border-color:var(--tblr-twitter);--tblr-btn-disabled-color:var(--tblr-twitter);--tblr-btn-disabled-border-color:var(--tblr-twitter)}.btn-ghost-twitter,.btn-ghost.btn-twitter{--tblr-btn-color:var(--tblr-twitter);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-twitter-fg);--tblr-btn-hover-bg:var(--tblr-twitter);--tblr-btn-hover-border-color:var(--tblr-twitter);--tblr-btn-active-color:var(--tblr-twitter-fg);--tblr-btn-active-bg:var(--tblr-twitter);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-twitter);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-linkedin{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-linkedin-fg, #ffffff);--tblr-btn-bg:var(--tblr-linkedin);--tblr-btn-hover-color:var(--tblr-linkedin-fg);--tblr-btn-hover-bg:var(--tblr-linkedin-darken);--tblr-btn-active-color:var(--tblr-linkedin-fg);--tblr-btn-active-bg:var(--tblr-linkedin-darken);--tblr-btn-disabled-bg:var(--tblr-linkedin);--tblr-btn-disabled-color:var(--tblr-linkedin-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-linkedin,.btn-outline.btn-linkedin{--tblr-btn-color:var(--tblr-linkedin);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-linkedin);--tblr-btn-hover-color:var(--tblr-linkedin-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-linkedin);--tblr-btn-active-color:var(--tblr-linkedin-fg);--tblr-btn-active-bg:var(--tblr-linkedin);--tblr-btn-active-border-color:var(--tblr-linkedin);--tblr-btn-disabled-color:var(--tblr-linkedin);--tblr-btn-disabled-border-color:var(--tblr-linkedin)}.btn-ghost-linkedin,.btn-ghost.btn-linkedin{--tblr-btn-color:var(--tblr-linkedin);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-linkedin-fg);--tblr-btn-hover-bg:var(--tblr-linkedin);--tblr-btn-hover-border-color:var(--tblr-linkedin);--tblr-btn-active-color:var(--tblr-linkedin-fg);--tblr-btn-active-bg:var(--tblr-linkedin);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-linkedin);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-google{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-google-fg, #ffffff);--tblr-btn-bg:var(--tblr-google);--tblr-btn-hover-color:var(--tblr-google-fg);--tblr-btn-hover-bg:var(--tblr-google-darken);--tblr-btn-active-color:var(--tblr-google-fg);--tblr-btn-active-bg:var(--tblr-google-darken);--tblr-btn-disabled-bg:var(--tblr-google);--tblr-btn-disabled-color:var(--tblr-google-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-google,.btn-outline.btn-google{--tblr-btn-color:var(--tblr-google);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-google);--tblr-btn-hover-color:var(--tblr-google-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-google);--tblr-btn-active-color:var(--tblr-google-fg);--tblr-btn-active-bg:var(--tblr-google);--tblr-btn-active-border-color:var(--tblr-google);--tblr-btn-disabled-color:var(--tblr-google);--tblr-btn-disabled-border-color:var(--tblr-google)}.btn-ghost-google,.btn-ghost.btn-google{--tblr-btn-color:var(--tblr-google);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-google-fg);--tblr-btn-hover-bg:var(--tblr-google);--tblr-btn-hover-border-color:var(--tblr-google);--tblr-btn-active-color:var(--tblr-google-fg);--tblr-btn-active-bg:var(--tblr-google);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-google);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-youtube{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-youtube-fg, #ffffff);--tblr-btn-bg:var(--tblr-youtube);--tblr-btn-hover-color:var(--tblr-youtube-fg);--tblr-btn-hover-bg:var(--tblr-youtube-darken);--tblr-btn-active-color:var(--tblr-youtube-fg);--tblr-btn-active-bg:var(--tblr-youtube-darken);--tblr-btn-disabled-bg:var(--tblr-youtube);--tblr-btn-disabled-color:var(--tblr-youtube-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-youtube,.btn-outline.btn-youtube{--tblr-btn-color:var(--tblr-youtube);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-youtube);--tblr-btn-hover-color:var(--tblr-youtube-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-youtube);--tblr-btn-active-color:var(--tblr-youtube-fg);--tblr-btn-active-bg:var(--tblr-youtube);--tblr-btn-active-border-color:var(--tblr-youtube);--tblr-btn-disabled-color:var(--tblr-youtube);--tblr-btn-disabled-border-color:var(--tblr-youtube)}.btn-ghost-youtube,.btn-ghost.btn-youtube{--tblr-btn-color:var(--tblr-youtube);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-youtube-fg);--tblr-btn-hover-bg:var(--tblr-youtube);--tblr-btn-hover-border-color:var(--tblr-youtube);--tblr-btn-active-color:var(--tblr-youtube-fg);--tblr-btn-active-bg:var(--tblr-youtube);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-youtube);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-vimeo{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-vimeo-fg, #ffffff);--tblr-btn-bg:var(--tblr-vimeo);--tblr-btn-hover-color:var(--tblr-vimeo-fg);--tblr-btn-hover-bg:var(--tblr-vimeo-darken);--tblr-btn-active-color:var(--tblr-vimeo-fg);--tblr-btn-active-bg:var(--tblr-vimeo-darken);--tblr-btn-disabled-bg:var(--tblr-vimeo);--tblr-btn-disabled-color:var(--tblr-vimeo-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-vimeo,.btn-outline.btn-vimeo{--tblr-btn-color:var(--tblr-vimeo);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-vimeo);--tblr-btn-hover-color:var(--tblr-vimeo-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-vimeo);--tblr-btn-active-color:var(--tblr-vimeo-fg);--tblr-btn-active-bg:var(--tblr-vimeo);--tblr-btn-active-border-color:var(--tblr-vimeo);--tblr-btn-disabled-color:var(--tblr-vimeo);--tblr-btn-disabled-border-color:var(--tblr-vimeo)}.btn-ghost-vimeo,.btn-ghost.btn-vimeo{--tblr-btn-color:var(--tblr-vimeo);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-vimeo-fg);--tblr-btn-hover-bg:var(--tblr-vimeo);--tblr-btn-hover-border-color:var(--tblr-vimeo);--tblr-btn-active-color:var(--tblr-vimeo-fg);--tblr-btn-active-bg:var(--tblr-vimeo);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-vimeo);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-dribbble{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-dribbble-fg, #ffffff);--tblr-btn-bg:var(--tblr-dribbble);--tblr-btn-hover-color:var(--tblr-dribbble-fg);--tblr-btn-hover-bg:var(--tblr-dribbble-darken);--tblr-btn-active-color:var(--tblr-dribbble-fg);--tblr-btn-active-bg:var(--tblr-dribbble-darken);--tblr-btn-disabled-bg:var(--tblr-dribbble);--tblr-btn-disabled-color:var(--tblr-dribbble-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-dribbble,.btn-outline.btn-dribbble{--tblr-btn-color:var(--tblr-dribbble);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-dribbble);--tblr-btn-hover-color:var(--tblr-dribbble-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-dribbble);--tblr-btn-active-color:var(--tblr-dribbble-fg);--tblr-btn-active-bg:var(--tblr-dribbble);--tblr-btn-active-border-color:var(--tblr-dribbble);--tblr-btn-disabled-color:var(--tblr-dribbble);--tblr-btn-disabled-border-color:var(--tblr-dribbble)}.btn-ghost-dribbble,.btn-ghost.btn-dribbble{--tblr-btn-color:var(--tblr-dribbble);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-dribbble-fg);--tblr-btn-hover-bg:var(--tblr-dribbble);--tblr-btn-hover-border-color:var(--tblr-dribbble);--tblr-btn-active-color:var(--tblr-dribbble-fg);--tblr-btn-active-bg:var(--tblr-dribbble);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-dribbble);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-github{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-github-fg, #ffffff);--tblr-btn-bg:var(--tblr-github);--tblr-btn-hover-color:var(--tblr-github-fg);--tblr-btn-hover-bg:var(--tblr-github-darken);--tblr-btn-active-color:var(--tblr-github-fg);--tblr-btn-active-bg:var(--tblr-github-darken);--tblr-btn-disabled-bg:var(--tblr-github);--tblr-btn-disabled-color:var(--tblr-github-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-github,.btn-outline.btn-github{--tblr-btn-color:var(--tblr-github);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-github);--tblr-btn-hover-color:var(--tblr-github-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-github);--tblr-btn-active-color:var(--tblr-github-fg);--tblr-btn-active-bg:var(--tblr-github);--tblr-btn-active-border-color:var(--tblr-github);--tblr-btn-disabled-color:var(--tblr-github);--tblr-btn-disabled-border-color:var(--tblr-github)}.btn-ghost-github,.btn-ghost.btn-github{--tblr-btn-color:var(--tblr-github);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-github-fg);--tblr-btn-hover-bg:var(--tblr-github);--tblr-btn-hover-border-color:var(--tblr-github);--tblr-btn-active-color:var(--tblr-github-fg);--tblr-btn-active-bg:var(--tblr-github);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-github);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-instagram{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-instagram-fg, #ffffff);--tblr-btn-bg:var(--tblr-instagram);--tblr-btn-hover-color:var(--tblr-instagram-fg);--tblr-btn-hover-bg:var(--tblr-instagram-darken);--tblr-btn-active-color:var(--tblr-instagram-fg);--tblr-btn-active-bg:var(--tblr-instagram-darken);--tblr-btn-disabled-bg:var(--tblr-instagram);--tblr-btn-disabled-color:var(--tblr-instagram-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-instagram,.btn-outline.btn-instagram{--tblr-btn-color:var(--tblr-instagram);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-instagram);--tblr-btn-hover-color:var(--tblr-instagram-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-instagram);--tblr-btn-active-color:var(--tblr-instagram-fg);--tblr-btn-active-bg:var(--tblr-instagram);--tblr-btn-active-border-color:var(--tblr-instagram);--tblr-btn-disabled-color:var(--tblr-instagram);--tblr-btn-disabled-border-color:var(--tblr-instagram)}.btn-ghost-instagram,.btn-ghost.btn-instagram{--tblr-btn-color:var(--tblr-instagram);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-instagram-fg);--tblr-btn-hover-bg:var(--tblr-instagram);--tblr-btn-hover-border-color:var(--tblr-instagram);--tblr-btn-active-color:var(--tblr-instagram-fg);--tblr-btn-active-bg:var(--tblr-instagram);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-instagram);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-pinterest{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-pinterest-fg, #ffffff);--tblr-btn-bg:var(--tblr-pinterest);--tblr-btn-hover-color:var(--tblr-pinterest-fg);--tblr-btn-hover-bg:var(--tblr-pinterest-darken);--tblr-btn-active-color:var(--tblr-pinterest-fg);--tblr-btn-active-bg:var(--tblr-pinterest-darken);--tblr-btn-disabled-bg:var(--tblr-pinterest);--tblr-btn-disabled-color:var(--tblr-pinterest-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-pinterest,.btn-outline.btn-pinterest{--tblr-btn-color:var(--tblr-pinterest);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-pinterest);--tblr-btn-hover-color:var(--tblr-pinterest-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-pinterest);--tblr-btn-active-color:var(--tblr-pinterest-fg);--tblr-btn-active-bg:var(--tblr-pinterest);--tblr-btn-active-border-color:var(--tblr-pinterest);--tblr-btn-disabled-color:var(--tblr-pinterest);--tblr-btn-disabled-border-color:var(--tblr-pinterest)}.btn-ghost-pinterest,.btn-ghost.btn-pinterest{--tblr-btn-color:var(--tblr-pinterest);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-pinterest-fg);--tblr-btn-hover-bg:var(--tblr-pinterest);--tblr-btn-hover-border-color:var(--tblr-pinterest);--tblr-btn-active-color:var(--tblr-pinterest-fg);--tblr-btn-active-bg:var(--tblr-pinterest);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-pinterest);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-vk{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-vk-fg, #ffffff);--tblr-btn-bg:var(--tblr-vk);--tblr-btn-hover-color:var(--tblr-vk-fg);--tblr-btn-hover-bg:var(--tblr-vk-darken);--tblr-btn-active-color:var(--tblr-vk-fg);--tblr-btn-active-bg:var(--tblr-vk-darken);--tblr-btn-disabled-bg:var(--tblr-vk);--tblr-btn-disabled-color:var(--tblr-vk-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-vk,.btn-outline.btn-vk{--tblr-btn-color:var(--tblr-vk);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-vk);--tblr-btn-hover-color:var(--tblr-vk-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-vk);--tblr-btn-active-color:var(--tblr-vk-fg);--tblr-btn-active-bg:var(--tblr-vk);--tblr-btn-active-border-color:var(--tblr-vk);--tblr-btn-disabled-color:var(--tblr-vk);--tblr-btn-disabled-border-color:var(--tblr-vk)}.btn-ghost-vk,.btn-ghost.btn-vk{--tblr-btn-color:var(--tblr-vk);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-vk-fg);--tblr-btn-hover-bg:var(--tblr-vk);--tblr-btn-hover-border-color:var(--tblr-vk);--tblr-btn-active-color:var(--tblr-vk-fg);--tblr-btn-active-bg:var(--tblr-vk);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-vk);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-rss{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-rss-fg, #ffffff);--tblr-btn-bg:var(--tblr-rss);--tblr-btn-hover-color:var(--tblr-rss-fg);--tblr-btn-hover-bg:var(--tblr-rss-darken);--tblr-btn-active-color:var(--tblr-rss-fg);--tblr-btn-active-bg:var(--tblr-rss-darken);--tblr-btn-disabled-bg:var(--tblr-rss);--tblr-btn-disabled-color:var(--tblr-rss-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-rss,.btn-outline.btn-rss{--tblr-btn-color:var(--tblr-rss);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-rss);--tblr-btn-hover-color:var(--tblr-rss-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-rss);--tblr-btn-active-color:var(--tblr-rss-fg);--tblr-btn-active-bg:var(--tblr-rss);--tblr-btn-active-border-color:var(--tblr-rss);--tblr-btn-disabled-color:var(--tblr-rss);--tblr-btn-disabled-border-color:var(--tblr-rss)}.btn-ghost-rss,.btn-ghost.btn-rss{--tblr-btn-color:var(--tblr-rss);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-rss-fg);--tblr-btn-hover-bg:var(--tblr-rss);--tblr-btn-hover-border-color:var(--tblr-rss);--tblr-btn-active-color:var(--tblr-rss-fg);--tblr-btn-active-bg:var(--tblr-rss);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-rss);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-flickr{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-flickr-fg, #ffffff);--tblr-btn-bg:var(--tblr-flickr);--tblr-btn-hover-color:var(--tblr-flickr-fg);--tblr-btn-hover-bg:var(--tblr-flickr-darken);--tblr-btn-active-color:var(--tblr-flickr-fg);--tblr-btn-active-bg:var(--tblr-flickr-darken);--tblr-btn-disabled-bg:var(--tblr-flickr);--tblr-btn-disabled-color:var(--tblr-flickr-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-flickr,.btn-outline.btn-flickr{--tblr-btn-color:var(--tblr-flickr);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-flickr);--tblr-btn-hover-color:var(--tblr-flickr-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-flickr);--tblr-btn-active-color:var(--tblr-flickr-fg);--tblr-btn-active-bg:var(--tblr-flickr);--tblr-btn-active-border-color:var(--tblr-flickr);--tblr-btn-disabled-color:var(--tblr-flickr);--tblr-btn-disabled-border-color:var(--tblr-flickr)}.btn-ghost-flickr,.btn-ghost.btn-flickr{--tblr-btn-color:var(--tblr-flickr);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-flickr-fg);--tblr-btn-hover-bg:var(--tblr-flickr);--tblr-btn-hover-border-color:var(--tblr-flickr);--tblr-btn-active-color:var(--tblr-flickr-fg);--tblr-btn-active-bg:var(--tblr-flickr);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-flickr);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-bitbucket{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-bitbucket-fg, #ffffff);--tblr-btn-bg:var(--tblr-bitbucket);--tblr-btn-hover-color:var(--tblr-bitbucket-fg);--tblr-btn-hover-bg:var(--tblr-bitbucket-darken);--tblr-btn-active-color:var(--tblr-bitbucket-fg);--tblr-btn-active-bg:var(--tblr-bitbucket-darken);--tblr-btn-disabled-bg:var(--tblr-bitbucket);--tblr-btn-disabled-color:var(--tblr-bitbucket-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-bitbucket,.btn-outline.btn-bitbucket{--tblr-btn-color:var(--tblr-bitbucket);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-bitbucket);--tblr-btn-hover-color:var(--tblr-bitbucket-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-bitbucket);--tblr-btn-active-color:var(--tblr-bitbucket-fg);--tblr-btn-active-bg:var(--tblr-bitbucket);--tblr-btn-active-border-color:var(--tblr-bitbucket);--tblr-btn-disabled-color:var(--tblr-bitbucket);--tblr-btn-disabled-border-color:var(--tblr-bitbucket)}.btn-ghost-bitbucket,.btn-ghost.btn-bitbucket{--tblr-btn-color:var(--tblr-bitbucket);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-bitbucket-fg);--tblr-btn-hover-bg:var(--tblr-bitbucket);--tblr-btn-hover-border-color:var(--tblr-bitbucket);--tblr-btn-active-color:var(--tblr-bitbucket-fg);--tblr-btn-active-bg:var(--tblr-bitbucket);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-bitbucket);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-tabler{--tblr-btn-border-color:transparent;--tblr-btn-hover-border-color:transparent;--tblr-btn-active-border-color:transparent;--tblr-btn-color:var(--tblr-tabler-fg, #ffffff);--tblr-btn-bg:var(--tblr-tabler);--tblr-btn-hover-color:var(--tblr-tabler-fg);--tblr-btn-hover-bg:var(--tblr-tabler-darken);--tblr-btn-active-color:var(--tblr-tabler-fg);--tblr-btn-active-bg:var(--tblr-tabler-darken);--tblr-btn-disabled-bg:var(--tblr-tabler);--tblr-btn-disabled-color:var(--tblr-tabler-fg);--tblr-btn-box-shadow:var(--tblr-shadow-input)}.btn-outline-tabler,.btn-outline.btn-tabler{--tblr-btn-color:var(--tblr-tabler);--tblr-btn-bg:transparent;--tblr-btn-border-color:var(--tblr-tabler);--tblr-btn-hover-color:var(--tblr-tabler-fg);--tblr-btn-hover-border-color:transparent;--tblr-btn-hover-bg:var(--tblr-tabler);--tblr-btn-active-color:var(--tblr-tabler-fg);--tblr-btn-active-bg:var(--tblr-tabler);--tblr-btn-active-border-color:var(--tblr-tabler);--tblr-btn-disabled-color:var(--tblr-tabler);--tblr-btn-disabled-border-color:var(--tblr-tabler)}.btn-ghost-tabler,.btn-ghost.btn-tabler{--tblr-btn-color:var(--tblr-tabler);--tblr-btn-bg:transparent;--tblr-btn-border-color:transparent;--tblr-btn-hover-color:var(--tblr-tabler-fg);--tblr-btn-hover-bg:var(--tblr-tabler);--tblr-btn-hover-border-color:var(--tblr-tabler);--tblr-btn-active-color:var(--tblr-tabler-fg);--tblr-btn-active-bg:var(--tblr-tabler);--tblr-btn-active-border-color:transparent;--tblr-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--tblr-btn-disabled-color:var(--tblr-tabler);--tblr-btn-disabled-bg:transparent;--tblr-btn-disabled-border-color:transparent;--tblr-gradient:none;--tblr-btn-box-shadow:none}.btn-group-sm>.btn,.btn-sm{--tblr-btn-line-height:1.3333333333;--tblr-btn-icon-size:1rem}.btn-group-lg>.btn,.btn-lg{--tblr-btn-line-height:1.5rem;--tblr-btn-icon-size:1.5rem}.btn-group-xl>.btn,.btn-xl{--tblr-btn-line-height:2;--tblr-btn-icon-size:2rem;--tblr-btn-padding-y:0.6875rem;--tblr-btn-padding-x:2rem;--tblr-btn-font-size:1.5rem}.btn-pill{padding-right:1.5em;padding-left:1.5em;border-radius:10rem}.btn-pill[class*=btn-icon]{padding:.375rem 15px}.btn-square{border-radius:0}.btn-action,.btn-icon{padding-left:0;padding-right:0}.btn-action .icon,.btn-icon .icon{margin:calc(-1 * var(--tblr-btn-padding-x))}.btn-list{--tblr-list-gap:0.5rem;display:flex;flex-wrap:wrap;gap:var(--tblr-list-gap)}.btn-floating{position:fixed;z-index:1030;bottom:1rem;left:1rem;box-shadow:var(--tblr-shadow-dropdown)}.btn-loading{position:relative;color:transparent!important;text-shadow:none!important;pointer-events:none}.btn-loading>*{opacity:0}.btn-loading:after{content:"";display:inline-block;vertical-align:text-bottom;border:2px var(--tblr-border-style) currentColor;border-right-color:transparent;border-radius:100rem;color:var(--tblr-btn-color);position:absolute;width:var(--tblr-btn-icon-size);height:var(--tblr-btn-icon-size);left:calc(50% - var(--tblr-btn-icon-size)/ 2);top:calc(50% - var(--tblr-btn-icon-size)/ 2);animation:spinner-border .75s linear infinite}.btn-action{--tblr-border-color:transparent;color:var(--tblr-secondary);border-radius:var(--tblr-border-radius);background:0 0;box-shadow:none}.btn-action:after{content:none}.btn-action:focus{outline:0;box-shadow:none}.btn-action.show,.btn-action:hover{color:var(--tblr-body-color);background:var(--tblr-active-bg);border-color:transparent}.btn-action.show{color:var(--tblr-primary)}.btn-actions{display:flex}.btn-animate-icon .icon{transition:transform .3s ease}.btn-animate-icon:focus-visible .icon,.btn-animate-icon:hover .icon{transform:translateX(4px)}.btn-animate-icon.btn-animate-icon-rotate:focus-visible .icon,.btn-animate-icon.btn-animate-icon-rotate:hover .icon{transform:rotate(90deg)}.btn-animate-icon.btn-animate-icon-move-start:focus-visible .icon,.btn-animate-icon.btn-animate-icon-move-start:hover .icon{transform:translateX(-4px)}.btn-animate-icon.btn-animate-icon-pulse:focus-visible .icon,.btn-animate-icon.btn-animate-icon-pulse:hover .icon{transform:none;animation:pulse .9s}.btn-animate-icon.btn-animate-icon-shake:focus-visible .icon,.btn-animate-icon.btn-animate-icon-shake:hover .icon{transform:none;animation:shake .9s}.btn-animate-icon.btn-animate-icon-tada:focus-visible .icon,.btn-animate-icon.btn-animate-icon-tada:hover .icon{transform:none;animation:tada .9s}.btn-group,.btn-group-vertical{box-shadow:var(--tblr-shadow-input)}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group>.btn-check:checked+.btn,.btn-group>.btn.active,.btn-group>.btn:active{z-index:5}.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:focus+.btn,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.calendar{display:block;font-size:.765625rem;border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);border-radius:var(--tblr-border-radius)}.calendar-nav{display:flex;align-items:center}.calendar-title{flex:1;text-align:center}.calendar-body,.calendar-header{display:flex;flex-wrap:wrap;justify-content:flex-start;padding:.5rem 0}.calendar-header{color:var(--tblr-secondary)}.calendar-date{flex:0 0 14.2857142857%;max-width:14.2857142857%;padding:.2rem;text-align:center;border:0}.calendar-date.next-month,.calendar-date.prev-month{opacity:.25}.calendar-date .date-item{position:relative;display:inline-block;width:1.4rem;height:1.4rem;line-height:1.4rem;color:#66758c;text-align:center;text-decoration:none;white-space:nowrap;vertical-align:middle;cursor:pointer;background:0 0;border:var(--tblr-border-width) var(--tblr-border-style) transparent;border-radius:100rem;outline:0;transition:background .3s,border .3s,box-shadow .32s,color .3s}@media (prefers-reduced-motion:reduce){.calendar-date .date-item{transition:none}}.calendar-date .date-item:hover{color:var(--tblr-primary);text-decoration:none;background:#fefeff;border-color:var(--tblr-border-color)}.calendar-date .date-today{color:var(--tblr-primary);border-color:var(--tblr-border-color)}.calendar-range{position:relative}.calendar-range:before{position:absolute;top:50%;right:0;left:0;height:1.4rem;content:"";background:rgba(var(--tblr-primary-rgb),.1);transform:translateY(-50%)}.calendar-range.range-end .date-item,.calendar-range.range-start .date-item{color:#fff;background:var(--tblr-primary);border-color:var(--tblr-primary)}.calendar-range.range-start:before{left:50%}.calendar-range.range-end:before{right:50%}.carousel-indicators-vertical{left:auto;top:0;margin:0 1rem 0 0;flex-direction:column}.carousel-indicators-vertical [data-bs-target]{margin:3px 0 3px;width:3px;height:30px;border:0;border-left:10px var(--tblr-border-style) transparent;border-right:10px var(--tblr-border-style) transparent}.carousel-indicators-dot [data-bs-target]{width:.5rem;height:.5rem;border-radius:100rem;border:10px var(--tblr-border-style) transparent;margin:0}.carousel-indicators-thumb [data-bs-target]{width:2rem;height:auto;background:no-repeat center/cover;border:0;border-radius:var(--tblr-border-radius);box-shadow:rgba(var(--tblr-body-color-rgb),.04) 0 2px 4px 0;margin:0 3px;opacity:.75}@media (min-width:992px){.carousel-indicators-thumb [data-bs-target]{width:4rem}}.carousel-indicators-thumb [data-bs-target]:before{content:"";padding-top:var(--tblr-aspect-ratio,100%);display:block}.carousel-indicators-thumb.carousel-indicators-vertical [data-bs-target]{margin:3px 0}.carousel-caption-background{background:red;position:absolute;left:0;right:0;bottom:0;height:90%;background:linear-gradient(0deg,rgba(31,41,55,.9),rgba(31,41,55,0))}.card{transition:transform .3s ease-out,opacity .3s ease-out,box-shadow .3s ease-out}@media (prefers-reduced-motion:reduce){.card{transition:none}}@media print{.card{border:none;box-shadow:none}}a.card{color:inherit}a.card:hover{text-decoration:none;box-shadow:rgba(var(--tblr-body-color-rgb),.16) 0 2px 16px 0}.card .card{box-shadow:none}.card-borderless,.card-borderless .card-footer,.card-borderless .card-header{border-color:transparent}.card-stamp{--tblr-stamp-size:7rem;position:absolute;top:0;right:0;width:calc(var(--tblr-stamp-size) * 1);height:calc(var(--tblr-stamp-size) * 1);max-height:100%;border-top-right-radius:6px;opacity:.2;overflow:hidden;pointer-events:none}.card-stamp-lg{--tblr-stamp-size:13rem}.card-stamp-icon{background:var(--tblr-secondary);color:var(--tblr-card-bg,var(--tblr-bg-surface));display:flex;align-items:center;justify-content:center;border-radius:100rem;width:calc(var(--tblr-stamp-size) * 1);height:calc(var(--tblr-stamp-size) * 1);position:relative;top:calc(var(--tblr-stamp-size) * -.25);right:calc(var(--tblr-stamp-size) * -.25);font-size:calc(var(--tblr-stamp-size) * .75);transform:rotate(10deg)}.card-stamp-icon .icon{stroke-width:2;width:calc(var(--tblr-stamp-size) * .75);height:calc(var(--tblr-stamp-size) * .75)}.card-img,.card-img-start{border-top-left-radius:calc(var(--tblr-border-radius-lg) - (var(--tblr-border-width)));border-bottom-left-radius:calc(var(--tblr-border-radius-lg) - (var(--tblr-border-width)))}.card-img,.card-img-end{border-top-right-radius:calc(var(--tblr-border-radius-lg) - (var(--tblr-border-width)));border-bottom-right-radius:calc(var(--tblr-border-radius-lg) - (var(--tblr-border-width)))}.card-img-overlay{display:flex;flex-direction:column;justify-content:flex-end}.card-img-overlay-dark{background-image:linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,.6) 100%)}.card-inactive{pointer-events:none;box-shadow:none}.card-inactive .card-body{opacity:.64}.card-active{--tblr-card-border-color:var(--tblr-primary);--tblr-card-bg:var(--tblr-active-bg)}.card-btn{display:flex;align-items:center;justify-content:center;padding:1rem 1.25rem;text-align:center;transition:background .3s;border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);flex:1;color:inherit;font-weight:var(--tblr-font-weight-medium)}@media (prefers-reduced-motion:reduce){.card-btn{transition:none}}.card-btn:hover{text-decoration:none;background:rgba(var(--tblr-primary-rgb),.04)}.card-btn+.card-btn{border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.card-stacked{--tblr-card-stacked-offset:.25rem;position:relative}.card-stacked:after{position:absolute;top:calc(-1 * var(--tblr-card-stacked-offset));right:var(--tblr-card-stacked-offset);left:var(--tblr-card-stacked-offset);height:var(--tblr-card-stacked-offset);content:"";background:var(--tblr-card-bg,var(--tblr-bg-surface));border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-card-border-color);border-radius:var(--tblr-card-border-radius) var(--tblr-card-border-radius) 0 0}.card-cover{position:relative;padding:1rem 1.25rem;background:#666 no-repeat center/cover}.card-cover:before{position:absolute;top:0;right:0;bottom:0;left:0;content:"";background:rgba(31,41,55,.48)}.card-cover:first-child,.card-cover:first-child:before{border-radius:6px 6px 0 0}.card-cover-blurred:before{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.card-actions{margin:-.5rem -.5rem -.5rem auto;padding-left:.5rem}.card-actions a{text-decoration:none}.card-header{color:inherit;display:flex;align-items:center;background:0 0}.card-header:first-child{border-radius:var(--tblr-card-border-radius) var(--tblr-card-border-radius) 0 0}.card-header-light{border-bottom-color:transparent;background:var(--tblr-bg-surface-tertiary)}.card-header-tabs{background:var(--tblr-bg-surface-tertiary);flex:1;margin:calc(var(--tblr-card-cap-padding-y) * -1) calc(var(--tblr-card-cap-padding-x) * -1) calc(var(--tblr-card-cap-padding-y) * -1);padding:calc(var(--tblr-card-cap-padding-y) * .5) calc(var(--tblr-card-cap-padding-x) * .5) 0;border-radius:var(--tblr-card-border-radius) var(--tblr-card-border-radius) 0 0}.card-header-pills{flex:1;margin-top:-.5rem;margin-bottom:-.5rem}.card-rotate-left,.card-rotate-start{transform:rotate(-1.5deg)}.card-rotate-end,.card-rotate-right{transform:rotate(1.5deg)}.card-link{color:inherit}.card-link:hover{color:inherit;text-decoration:none;box-shadow:0 1px 6px 0 rgba(0,0,0,.08)}.card-link-rotate:hover{transform:rotate(1.5deg);opacity:1}.card-link-pop:hover{transform:translateY(-2px);opacity:1}.card-footer{margin-top:auto}.card-footer:last-child{border-radius:0 0 var(--tblr-card-border-radius) var(--tblr-card-border-radius)}.card-footer-transparent{background:0 0;border-color:transparent;padding-top:0}.card-footer-borderless{border-top:none}.card-progress{height:.25rem}.card-progress:last-child{border-radius:0 0 2px 2px}.card-progress:first-child{border-radius:2px 2px 0 0}.card-meta{color:var(--tblr-secondary)}.card-title{display:block;margin:0 0 1rem;font-size:1rem;font-weight:var(--tblr-font-weight-medium);color:inherit;line-height:1.5rem}a.card-title:hover{color:inherit}.card-header .card-title{margin:0}.card-subtitle{margin-bottom:1.25rem;color:var(--tblr-secondary);font-weight:400}.card-header .card-subtitle{margin:0}.card-title .card-subtitle{margin:0 0 0 .25rem;font-size:.875rem}.card-body{position:relative}.card-body>:last-child{margin-bottom:0}.card-sm>.card-body{padding:1rem}@media (min-width:768px){.card-md>.card-body{padding:2.5rem}}@media (min-width:768px){.card-lg>.card-body{padding:2rem}}@media (min-width:992px){.card-lg>.card-body{padding:4rem}}@media print{.card-body{padding:0}}.card-body+.card-body{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.card-body-scrollable{overflow:auto}.card-options{top:1.5rem;right:.75rem;display:flex;margin-left:auto}.card-options-link{display:inline-block;min-width:1rem;margin-left:.25rem;color:var(--tblr-secondary)}.card-status-top{position:absolute;top:0;right:0;left:0;height:2px;border-radius:var(--tblr-card-border-radius) var(--tblr-card-border-radius) 0 0}.card-status-start{position:absolute;right:auto;bottom:0;width:2px;height:100%;border-radius:var(--tblr-card-border-radius) 0 0 var(--tblr-card-border-radius)}.card-status-bottom{position:absolute;top:initial;bottom:0;width:100%;height:2px;border-radius:0 0 var(--tblr-card-border-radius) var(--tblr-card-border-radius)}.card-table{margin-bottom:0!important}.card-table tr td:first-child,.card-table tr th:first-child{padding-left:1.25rem;border-left:0}.card-table tr td:last-child,.card-table tr th:last-child{padding-right:1.25rem;border-right:0}.card-table tbody tr:first-child,.card-table tfoot tr:first-child,.card-table thead tr:first-child{border-top:0}.card-table tbody tr:first-child td,.card-table tbody tr:first-child th,.card-table tfoot tr:first-child td,.card-table tfoot tr:first-child th,.card-table thead tr:first-child td,.card-table thead tr:first-child th{border-top:0}.card-body+.card-table{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-table-border-color)}.card-code{padding:0}.card-code .highlight{margin:0;border:0}.card-code pre{margin:0!important;border:0!important}.card-chart{position:relative;z-index:1;height:3.5rem}.card-avatar{margin-left:auto;margin-right:auto;box-shadow:0 0 0 .25rem var(--tblr-card-bg,var(--tblr-bg-surface));margin-top:calc(-1 * var(--tblr-avatar-size) * .5)}.card-body+.card-list-group{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.card-list-group .list-group-item{padding-right:1.25rem;padding-left:1.25rem;border-right:0;border-left:0;border-radius:0}.card-list-group .list-group-item:last-child{border-bottom:0}.card-list-group .list-group-item:first-child{border-top:0}.card-tabs .nav-tabs{position:relative;z-index:1000;border-bottom:0}.card-tabs .nav-tabs .nav-link{background:var(--tblr-bg-surface-tertiary);border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)}.card-tabs .nav-tabs .nav-link.active,.card-tabs .nav-tabs .nav-link:active,.card-tabs .nav-tabs .nav-link:hover{border-color:var(--tblr-border-color-translucent);color:var(--tblr-body-color)}.card-tabs .nav-tabs .nav-link.active{color:inherit;background:var(--tblr-card-bg,var(--tblr-bg-surface));border-bottom-color:transparent}.card-tabs .nav-tabs .nav-item:not(:first-child) .nav-link{border-top-left-radius:0}.card-tabs .nav-tabs .nav-item:not(:last-child) .nav-link{border-top-right-radius:0}.card-tabs .nav-tabs .nav-item+.nav-item{margin-left:calc(-1 * var(--tblr-border-width))}.card-tabs .nav-tabs-bottom{margin-bottom:0}.card-tabs .nav-tabs-bottom .nav-link{margin-bottom:0}.card-tabs .nav-tabs-bottom .nav-link.active{border-top-color:transparent}.card-tabs .nav-tabs-bottom .nav-item{margin-top:calc(-1 * var(--tblr-border-width));margin-bottom:0}.card-tabs .nav-tabs-bottom .nav-item .nav-link{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent);border-radius:0 0 var(--tblr-border-radius-lg) var(--tblr-border-radius-lg)}.card-tabs .nav-tabs-bottom .nav-item:not(:first-child) .nav-link{border-bottom-left-radius:0}.card-tabs .nav-tabs-bottom .nav-item:not(:last-child) .nav-link{border-bottom-right-radius:0}.card-tabs .card{border-bottom-left-radius:0}.card-tabs .nav-tabs+.tab-content .card{border-bottom-left-radius:var(--tblr-card-border-radius);border-top-left-radius:0}.card-note{--tblr-card-bg:#fff7dd;--tblr-card-border-color:#fff1c9}.btn-close{--tblr-btn-close-color:currentColor;--tblr-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%231f2937'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");--tblr-btn-close-opacity:0.4;--tblr-btn-close-hover-opacity:0.75;--tblr-btn-close-focus-shadow:0 0 0 0.25rem rgba(var(--tblr-primary-rgb), 0.25);--tblr-btn-close-focus-opacity:1;--tblr-btn-close-disabled-opacity:0.25;--tblr-btn-close-size:1em;width:var(--tblr-btn-close-size);height:var(--tblr-btn-close-size);padding:.25em .25em;color:var(--tblr-btn-close-color);-webkit-mask:var(--tblr-btn-close-bg) no-repeat center/calc(var(--tblr-btn-close-size) * 0.75);mask:var(--tblr-btn-close-bg) no-repeat center/calc(var(--tblr-btn-close-size) * 0.75);background-color:var(--tblr-btn-close-color);border:0;border-radius:var(--tblr-border-radius);opacity:var(--tblr-btn-close-opacity);cursor:pointer;display:block}.btn-close:hover{color:var(--tblr-btn-close-color);text-decoration:none;opacity:var(--tblr-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--tblr-btn-close-focus-shadow);opacity:var(--tblr-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--tblr-btn-close-disabled-opacity)}.dropdown-menu{-webkit-user-select:none;-moz-user-select:none;user-select:none;background-clip:border-box}.dropdown-menu.card{padding:0;min-width:25rem;display:none}.dropdown-menu.card.show{display:flex}.dropdown-item{min-width:11rem;display:flex;align-items:center;margin:0;line-height:1.4285714286;gap:.5rem}.dropdown-item-icon{width:1.25rem!important;height:1.25rem!important;margin-right:.5rem;color:var(--tblr-secondary);opacity:.7;text-align:center}.dropdown-item-indicator{margin-right:.5rem;margin-left:-.25rem;height:1.25rem;display:inline-flex;line-height:1;vertical-align:bottom;align-items:center}.dropdown-header{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);padding-bottom:.25rem;pointer-events:none}.dropdown-menu-scrollable{height:auto;max-height:13rem;overflow-x:hidden}.dropdown-menu-column{min-width:11rem}.dropdown-menu-column .dropdown-item{min-width:0}.dropdown-menu-columns{display:flex;flex:0 0.25rem}.dropdown-menu-arrow:before{content:"";position:absolute;top:-.25rem;left:.75rem;display:block;background:inherit;width:14px;height:14px;transform:rotate(45deg);transform-origin:center;border:1px solid;border-color:inherit;z-index:-1;clip:rect(0,9px,9px,0)}.dropdown-menu-arrow.dropdown-menu-end:before{right:.75rem;left:auto}.dropend>.dropdown-menu{margin-top:calc(-.25rem - 1px);margin-left:-.25rem}.dropend .dropdown-toggle:after{margin-left:auto}.dropdown-menu-card{padding:0;min-width:20rem}.dropdown-menu-card>.card{margin:0;border:0;box-shadow:none}.datagrid{--tblr-datagrid-padding:1.5rem;--tblr-datagrid-item-width:15rem;display:grid;grid-gap:var(--tblr-datagrid-padding);grid-template-columns:repeat(auto-fit,minmax(var(--tblr-datagrid-item-width),1fr))}.datagrid-title{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);margin-bottom:.25rem}.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:1rem;text-align:center}@media (min-width:768px){.empty{padding:3rem}}.empty-icon{margin:0 0 1rem;width:3rem;height:3rem;line-height:1;color:var(--tblr-secondary)}.empty-icon svg{width:100%;height:100%}.empty-img{margin:0 0 2rem;line-height:1}.empty-header{margin:0 0 1rem;font-size:4rem;font-weight:var(--tblr-font-weight-light);line-height:1;color:var(--tblr-secondary)}.empty-title{font-size:1.25rem;line-height:1.75rem;font-weight:var(--tblr-font-weight-bold)}.empty-subtitle,.empty-title{margin:0 0 .5rem}.empty-action{margin-top:1.5rem}.empty-bordered{border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);border-radius:var(--tblr-border-radius)}.row>*{min-width:0}.col-separator{border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.container-slim{--tblr-gutter-x:calc(var(--tblr-page-padding) * 2);--tblr-gutter-y:0;width:100%;padding-right:calc(var(--tblr-gutter-x) * .5);padding-left:calc(var(--tblr-gutter-x) * .5);margin-right:auto;margin-left:auto;max-width:16rem}.container-tight{--tblr-gutter-x:calc(var(--tblr-page-padding) * 2);--tblr-gutter-y:0;width:100%;padding-right:calc(var(--tblr-gutter-x) * .5);padding-left:calc(var(--tblr-gutter-x) * .5);margin-right:auto;margin-left:auto;max-width:30rem}.container-narrow{--tblr-gutter-x:calc(var(--tblr-page-padding) * 2);--tblr-gutter-y:0;width:100%;padding-right:calc(var(--tblr-gutter-x) * .5);padding-left:calc(var(--tblr-gutter-x) * .5);margin-right:auto;margin-left:auto;max-width:61.875rem}.row-0{margin-right:0;margin-left:0}.row-0>.col,.row-0>[class*=col-]{padding-right:0;padding-left:0}.row-0 .card{margin-bottom:0}.row-sm{margin-right:-.375rem;margin-left:-.375rem}.row-sm>.col,.row-sm>[class*=col-]{padding-right:.375rem;padding-left:.375rem}.row-sm .card{margin-bottom:.75rem}.row-md{margin-right:-1.5rem;margin-left:-1.5rem}.row-md>.col,.row-md>[class*=col-]{padding-right:1.5rem;padding-left:1.5rem}.row-md .card{margin-bottom:3rem}.row-lg{margin-right:-3rem;margin-left:-3rem}.row-lg>.col,.row-lg>[class*=col-]{padding-right:3rem;padding-left:3rem}.row-lg .card{margin-bottom:6rem}.row-deck>.col,.row-deck>[class*=col-]{display:flex;align-items:stretch}.row-deck>.col .card,.row-deck>[class*=col-] .card{flex:1 1 auto}.row-cards{--tblr-gutter-x:var(--tblr-page-padding);--tblr-gutter-y:var(--tblr-page-padding);min-width:0}.row-cards .row-cards{flex:1}.space-y{display:flex;flex-direction:column;gap:1rem}.space-x{display:flex;gap:1rem}.space-y-0{display:flex;flex-direction:column;gap:0}.space-x-0{display:flex;gap:0}.space-y-1{display:flex;flex-direction:column;gap:.25rem}.space-x-1{display:flex;gap:.25rem}.space-y-2{display:flex;flex-direction:column;gap:.5rem}.space-x-2{display:flex;gap:.5rem}.space-y-3{display:flex;flex-direction:column;gap:1rem}.space-x-3{display:flex;gap:1rem}.space-y-4{display:flex;flex-direction:column;gap:1.5rem}.space-x-4{display:flex;gap:1.5rem}.space-y-5{display:flex;flex-direction:column;gap:2rem}.space-x-5{display:flex;gap:2rem}.space-y-6{display:flex;flex-direction:column;gap:2.5rem}.space-x-6{display:flex;gap:2.5rem}.divide-y>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y>:not(template):not(:first-child){padding-top:1rem!important}.divide-y>:not(template):not(:last-child){padding-bottom:1rem!important}.divide-x>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x>:not(template):not(:first-child){padding-left:1rem!important}.divide-x>:not(template):not(:last-child){padding-right:1rem!important}.divide-y-0>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y-0>:not(template):not(:first-child){padding-top:0!important}.divide-y-0>:not(template):not(:last-child){padding-bottom:0!important}.divide-x-0>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x-0>:not(template):not(:first-child){padding-left:0!important}.divide-x-0>:not(template):not(:last-child){padding-right:0!important}.divide-y-1>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y-1>:not(template):not(:first-child){padding-top:.25rem!important}.divide-y-1>:not(template):not(:last-child){padding-bottom:.25rem!important}.divide-x-1>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x-1>:not(template):not(:first-child){padding-left:.25rem!important}.divide-x-1>:not(template):not(:last-child){padding-right:.25rem!important}.divide-y-2>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y-2>:not(template):not(:first-child){padding-top:.5rem!important}.divide-y-2>:not(template):not(:last-child){padding-bottom:.5rem!important}.divide-x-2>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x-2>:not(template):not(:first-child){padding-left:.5rem!important}.divide-x-2>:not(template):not(:last-child){padding-right:.5rem!important}.divide-y-3>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y-3>:not(template):not(:first-child){padding-top:1rem!important}.divide-y-3>:not(template):not(:last-child){padding-bottom:1rem!important}.divide-x-3>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x-3>:not(template):not(:first-child){padding-left:1rem!important}.divide-x-3>:not(template):not(:last-child){padding-right:1rem!important}.divide-y-4>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y-4>:not(template):not(:first-child){padding-top:1.5rem!important}.divide-y-4>:not(template):not(:last-child){padding-bottom:1.5rem!important}.divide-x-4>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x-4>:not(template):not(:first-child){padding-left:1.5rem!important}.divide-x-4>:not(template):not(:last-child){padding-right:1.5rem!important}.divide-y-5>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y-5>:not(template):not(:first-child){padding-top:2rem!important}.divide-y-5>:not(template):not(:last-child){padding-bottom:2rem!important}.divide-x-5>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x-5>:not(template):not(:first-child){padding-left:2rem!important}.divide-x-5>:not(template):not(:last-child){padding-right:2rem!important}.divide-y-6>:not(template)~:not(template){border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-y-6>:not(template):not(:first-child){padding-top:2.5rem!important}.divide-y-6>:not(template):not(:last-child){padding-bottom:2.5rem!important}.divide-x-6>:not(template)~:not(template){border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)!important}.divide-x-6>:not(template):not(:first-child){padding-left:2.5rem!important}.divide-x-6>:not(template):not(:last-child){padding-right:2.5rem!important}.divide-y-fill{display:flex;flex-direction:column;height:100%}.divide-y-fill>:not(template){flex:1;display:flex;justify-content:center;flex-direction:column}.icon{--tblr-icon-size:1.25rem;width:var(--tblr-icon-size);height:var(--tblr-icon-size);font-size:var(--tblr-icon-size);vertical-align:bottom;stroke-width:1.5}.icon:hover{text-decoration:none}.icon-inline{--tblr-icon-size:1rem;vertical-align:-.2rem}.icon-filled{fill:currentColor}.icon-sm{--tblr-icon-size:1rem;stroke-width:1}.icon-md{--tblr-icon-size:2.5rem;stroke-width:1}.icon-lg{--tblr-icon-size:3.5rem;stroke-width:1}.icon-pulse{transition:all .15s ease 0s;animation:pulse 2s ease infinite;animation-fill-mode:both}.icon-tada{transition:all .15s ease 0s;animation:tada 3s ease infinite;animation-fill-mode:both}.icon-rotate{transition:all .15s ease 0s;animation:rotate-360 3s linear infinite;animation-fill-mode:both}.img-responsive{--tblr-img-responsive-ratio:75%;background:no-repeat center/cover;padding-top:var(--tblr-img-responsive-ratio)}.img-responsive-grid{padding-top:calc(var(--tblr-img-responsive-ratio) - var(--tblr-gutter-y)/ 2)}.img-responsive-1x1{--tblr-img-responsive-ratio:100%}.img-responsive-2x1{--tblr-img-responsive-ratio:50%}.img-responsive-1x2{--tblr-img-responsive-ratio:200%}.img-responsive-3x1{--tblr-img-responsive-ratio:33.3333333333%}.img-responsive-1x3{--tblr-img-responsive-ratio:300%}.img-responsive-4x1{--tblr-img-responsive-ratio:25%}.img-responsive-1x4{--tblr-img-responsive-ratio:400%}.img-responsive-4x3{--tblr-img-responsive-ratio:75%}.img-responsive-3x4{--tblr-img-responsive-ratio:133.3333333333%}.img-responsive-16x9{--tblr-img-responsive-ratio:56.25%}.img-responsive-9x16{--tblr-img-responsive-ratio:177.7777777778%}.img-responsive-21x9{--tblr-img-responsive-ratio:42.8571428571%}.img-responsive-9x21{--tblr-img-responsive-ratio:233.3333333333%}.img-bg{background:no-repeat center/cover}textarea[cols]{height:auto}.col-form-label,.form-label{display:block;font-weight:var(--tblr-font-weight-medium)}.col-form-label.required:after,.form-label.required:after{content:"*";margin-left:.25rem;color:#d63939}.form-label-description{float:right;font-weight:var(--tblr-font-weight-normal);color:var(--tblr-secondary)}.form-hint{display:block;color:var(--tblr-secondary)}.form-hint:last-child{margin-bottom:0}.form-hint+.form-control{margin-top:.25rem}.form-label+.form-hint{margin-top:-.25rem}.form-control+.form-hint,.form-select+.form-hint,.input-group+.form-hint{margin-top:.5rem;color:var(--tblr-secondary)}.form-select:-moz-focusring{color:var(--tblr-body-color)}.form-control:-webkit-autofill{box-shadow:0 0 0 1000px var(--tblr-bg-surface-secondary) inset;color:var(--tblr-body-color);-webkit-text-fill-color:var(--tblr-body-color)}.form-control.disabled,.form-control:disabled{color:var(--tblr-secondary);-webkit-user-select:none;-moz-user-select:none;user-select:none}.form-control[size]{width:auto}.form-control-light{background-color:var(--tblr-gray-100);border-color:transparent}.form-control-dark{background-color:rgba(0,0,0,.1);color:#fff;border-color:transparent}.form-control-dark:focus{background-color:rgba(0,0,0,.1);box-shadow:none;border-color:rgba(255,255,255,.24)}.form-control-dark::-moz-placeholder{color:rgba(255,255,255,.6)}.form-control-dark::placeholder{color:rgba(255,255,255,.6)}.form-control-rounded{border-radius:10rem}.form-control-flush{padding:0;background:0 0!important;border-color:transparent!important;resize:none;box-shadow:none!important;line-height:inherit}.form-footer{margin-top:2rem}.form-fieldset{padding:1rem;margin-bottom:1rem;background:var(--tblr-bg-surface-secondary);border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);border-radius:var(--tblr-border-radius)}fieldset:empty{display:none}.form-help{display:inline-flex;font-weight:var(--tblr-font-weight-bold);align-items:center;justify-content:center;width:1.125rem;height:1.125rem;font-size:.75rem;color:var(--tblr-secondary);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background:var(--tblr-gray-100);border-radius:100rem;transition:background-color .3s,color .3s}@media (prefers-reduced-motion:reduce){.form-help{transition:none}}.form-help:hover,.form-help[aria-describedby]{color:#fff;background:var(--tblr-primary)}.input-group{box-shadow:var(--tblr-shadow-input);border-radius:var(--tblr-border-radius)}.input-group .btn,.input-group .form-control{box-shadow:none}.input-group-link{font-size:.75rem}.input-group-flat:focus-within{box-shadow:0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25);border-radius:var(--tblr-border-radius)}.input-group-flat:focus-within .form-control,.input-group-flat:focus-within .input-group-text{border-color:rgb(130.5,183,232)!important}.input-group-flat .form-control:focus{border-color:var(--tblr-border-color);box-shadow:none}.input-group-flat .form-control:not(:last-child){border-right:0}.input-group-flat .form-control:not(:first-child){border-left:0}.input-group-flat .input-group-text{background:var(--tblr-bg-forms);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.input-group-flat .input-group-text{transition:none}}.input-group-flat .input-group-text:first-child{padding-right:0}.input-group-flat .input-group-text:last-child{padding-left:0}.form-file-button{margin-left:0;border-left:0}label[for=floating-input]{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media (max-width:575.98px){.form-control,.form-select{font-size:1rem}}.input-icon{position:relative}.input-icon .form-control:not(:last-child),.input-icon .form-select:not(:last-child){padding-right:2.5rem}.input-icon .form-control:not(:first-child),.input-icon .form-select:not(:last-child){padding-left:2.5rem}.input-icon-addon{position:absolute;top:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;min-width:2.5rem;color:var(--tblr-icon-color);pointer-events:none;font-size:1.2em}.input-icon-addon:last-child{right:0;left:auto}.form-colorinput{position:relative;display:inline-block;margin:0;line-height:1;cursor:pointer}.form-colorinput-input{position:absolute;z-index:-1;opacity:0}.form-colorinput-color{display:block;width:1.5rem;height:1.5rem;color:#fff;border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent);border-radius:var(--tblr-border-radius);box-shadow:0 1px 2px 0 rgba(0,0,0,.05)}.form-colorinput-color:before{position:absolute;top:0;left:0;width:100%;height:100%;content:"";background:no-repeat center center/1.25rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3e%3cpath fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8.5l2.5 2.5l5.5 -5.5'/%3e%3c/svg%3e");opacity:0;transition:opacity .3s}@media (prefers-reduced-motion:reduce){.form-colorinput-color:before{transition:none}}.form-colorinput-input:checked~.form-colorinput-color:before{opacity:1}.form-colorinput-input:focus~.form-colorinput-color{border-color:var(--tblr-primary);box-shadow:0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-colorinput-light .form-colorinput-color:before{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3e%3cpath fill='none' stroke='%231f2937' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8.5l2.5 2.5l5.5 -5.5'/%3e%3c/svg%3e")}.form-imagecheck{--tblr-form-imagecheck-radius:var(--tblr-border-radius);position:relative;margin:0;cursor:pointer}.form-imagecheck-input{position:absolute;z-index:-1;opacity:0}.form-imagecheck-figure{position:relative;display:block;margin:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);border-radius:var(--tblr-form-imagecheck-radius)}.form-imagecheck-input:focus~.form-imagecheck-figure{border-color:var(--tblr-primary);box-shadow:0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-imagecheck-input:checked~.form-imagecheck-figure{border-color:var(--tblr-primary)}.form-imagecheck-figure:before{position:absolute;top:.25rem;left:.25rem;z-index:1;display:block;width:1.25rem;height:1.25rem;color:#fff;pointer-events:none;content:"";-webkit-user-select:none;-moz-user-select:none;user-select:none;background:var(--tblr-bg-forms);border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);border-radius:var(--tblr-border-radius);transition:opacity .3s}@media (prefers-reduced-motion:reduce){.form-imagecheck-figure:before{transition:none}}.form-imagecheck-input:checked~.form-imagecheck-figure:before{background-color:var(--tblr-primary);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3e%3cpath fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8.5l2.5 2.5l5.5 -5.5'/%3e%3c/svg%3e");background-repeat:repeat;background-position:center;background-size:1.25rem;border-color:var(--tblr-border-color-translucent)}.form-imagecheck-input[type=radio]~.form-imagecheck-figure:before{border-radius:50%}.form-imagecheck-input[type=radio]:checked~.form-imagecheck-figure:before{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3ccircle r='3' fill='%23ffffff' cx='8' cy='8' /%3e%3c/svg%3e")}.form-imagecheck-image{max-width:100%;display:block;opacity:.64;transition:opacity .3s}@media (prefers-reduced-motion:reduce){.form-imagecheck-image{transition:none}}.form-imagecheck-image:first-child{border-top-left-radius:calc(var(--tblr-form-imagecheck-radius) - 1px);border-top-right-radius:calc(var(--tblr-form-imagecheck-radius) - 1px)}.form-imagecheck-image:last-child{border-bottom-right-radius:calc(var(--tblr-form-imagecheck-radius) - 1px);border-bottom-left-radius:calc(var(--tblr-form-imagecheck-radius) - 1px)}.form-imagecheck-input:checked~.form-imagecheck-figure .form-imagecheck-image,.form-imagecheck-input:focus~.form-imagecheck-figure .form-imagecheck-image,.form-imagecheck:hover .form-imagecheck-image{opacity:1}.form-imagecheck-caption{padding:.25rem;font-size:.765625rem;color:var(--tblr-secondary);text-align:center;transition:color .3s}@media (prefers-reduced-motion:reduce){.form-imagecheck-caption{transition:none}}.form-imagecheck-input:checked~.form-imagecheck-figure .form-imagecheck-caption,.form-imagecheck-input:focus~.form-imagecheck-figure .form-imagecheck-caption,.form-imagecheck:hover .form-imagecheck-caption{color:var(--tblr-body-color)}.form-selectgroup{display:inline-flex;margin:0 -.5rem -.5rem 0;flex-wrap:wrap}.form-selectgroup .form-selectgroup-item{margin:0 .5rem .5rem 0}.form-selectgroup-vertical{flex-direction:column}.form-selectgroup-item{display:block;position:relative}.form-selectgroup-input{position:absolute;top:0;left:0;z-index:-1;opacity:0}.form-selectgroup-label{position:relative;display:block;min-width:calc(1.25rem + 1.125rem + calc(var(--tblr-border-width) * 2));margin:0;padding:.5625rem 1rem;font-size:.875rem;line-height:1.25rem;color:var(--tblr-secondary);background:var(--tblr-bg-forms);text-align:center;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);border-radius:var(--tblr-border-radius);box-shadow:var(--tblr-shadow-input);transition:border-color .3s,background .3s,color .3s}@media (prefers-reduced-motion:reduce){.form-selectgroup-label{transition:none}}.form-selectgroup-label .icon:only-child{margin:0 -.25rem}.form-selectgroup-label:hover{color:var(--tblr-body-color)}.form-selectgroup-check{display:inline-block;width:1.25rem;height:1.25rem;border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent);vertical-align:middle;box-shadow:var(--tblr-shadow-input)}.form-selectgroup-input[type=checkbox]+.form-selectgroup-label .form-selectgroup-check{border-radius:var(--tblr-border-radius)}.form-selectgroup-input[type=radio]+.form-selectgroup-label .form-selectgroup-check{border-radius:50%}.form-selectgroup-input:checked+.form-selectgroup-label .form-selectgroup-check{background-color:var(--tblr-primary);background-repeat:repeat;background-position:center;background-size:1.25rem;border-color:var(--tblr-border-color-translucent)}.form-selectgroup-input[type=checkbox]:checked+.form-selectgroup-label .form-selectgroup-check{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3e%3cpath fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8.5l2.5 2.5l5.5 -5.5'/%3e%3c/svg%3e")}.form-selectgroup-input[type=radio]:checked+.form-selectgroup-label .form-selectgroup-check{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3ccircle r='3' fill='%23ffffff' cx='8' cy='8' /%3e%3c/svg%3e")}.form-selectgroup-check-floated{position:absolute;top:.5625rem;right:.5625rem}.form-selectgroup-input:checked+.form-selectgroup-label{z-index:1;color:var(--tblr-primary);background:rgba(var(--tblr-primary-rgb),.04);border-color:var(--tblr-primary)}.form-selectgroup-input:focus+.form-selectgroup-label{z-index:2;color:var(--tblr-primary);border-color:var(--tblr-primary);box-shadow:0 0 0 .25rem rgba(var(--tblr-primary-rgb),.25)}.form-selectgroup-boxes .form-selectgroup-label{text-align:left;padding:1.25rem 1rem;color:inherit}.form-selectgroup-boxes .form-selectgroup-input:checked+.form-selectgroup-label{color:inherit}.form-selectgroup-boxes .form-selectgroup-input:checked+.form-selectgroup-label .form-selectgroup-title{color:var(--tblr-primary)}.form-selectgroup-boxes .form-selectgroup-input:checked+.form-selectgroup-label .form-selectgroup-label-content{opacity:1}.form-selectgroup-pills{flex-wrap:wrap;align-items:flex-start}.form-selectgroup-pills .form-selectgroup-item{flex-grow:0}.form-selectgroup-pills .form-selectgroup-label{border-radius:50px}.form-control-color::-webkit-color-swatch{border:none}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.form-control::file-selector-button{background-color:var(--tblr-btn-color,var(--tblr-tertiary-bg))}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--tblr-btn-color,var(--tblr-secondary-bg))}.form-check{-webkit-user-select:none;-moz-user-select:none;user-select:none}.form-check.form-check-highlight .form-check-input:not(:checked)~.form-check-label{color:var(--tblr-secondary)}.form-check .form-check-label-off{color:var(--tblr-secondary)}.form-check .form-check-input:checked~.form-check-label-off{display:none}.form-check .form-check-input:not(:checked)~.form-check-label-on{display:none}.form-check-input{background-size:1.25rem;margin-top:0;box-shadow:var(--tblr-shadow-input)}.form-switch .form-check-input{transition:background-color .3s,background-position .3s}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-check-label{display:block}.form-check-label.required:after{content:"*";margin-left:.25rem;color:#d63939}.form-check-description{display:block;color:var(--tblr-secondary);font-size:.75rem;margin-top:.25rem}.form-check-single{margin:0}.form-check-single .form-check-input{margin:0}.form-switch .form-check-input{height:1.25rem;margin-top:0}.form-switch-lg{padding-left:3.5rem;min-height:1.5rem}.form-switch-lg .form-check-input{height:1.5rem;width:2.75rem;background-size:1.5rem;margin-left:-3.5rem}.form-switch-lg .form-check-label{padding-top:.125rem}.form-check-input:checked{border:none}.form-control.is-invalid-lite,.form-control.is-valid-lite,.form-select.is-invalid-lite,.form-select.is-valid-lite{border-color:var(--tblr-border-color)!important}.legend{--tblr-legend-size:0.75em;display:inline-block;background:var(--tblr-border-color);width:var(--tblr-legend-size);height:var(--tblr-legend-size);border-radius:var(--tblr-border-radius-sm);border:1px solid var(--tblr-border-color-translucent)}.list-group{margin-left:0;margin-right:0}.list-group-header{background:var(--tblr-bg-surface-tertiary);padding:.5rem 1.25rem;font-size:.75rem;font-weight:var(--tblr-font-weight-medium);line-height:1;text-transform:uppercase;color:var(--tblr-gray-500);border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.list-group-flush>.list-group-header:last-child{border-bottom-width:0}.list-group-item{background-color:inherit}.list-group-item.active{background-color:rgba(var(--tblr-secondary-rgb),.08);border-left-color:#066fd1;border-left-width:2px}.list-group-item.disabled,.list-group-item:disabled{color:#6b7280;background-color:rgba(var(--tblr-secondary-rgb),.08)}.list-bordered .list-item{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);margin-top:-1px}.list-bordered .list-item:first-child{border-top:none}.list-group-hoverable .list-group-item:active,.list-group-hoverable .list-group-item:focus,.list-group-hoverable .list-group-item:hover{background-color:rgba(var(--tblr-secondary-rgb),.08)}.list-group-hoverable .list-group-item-actions{opacity:0;transition:opacity .3s}@media (prefers-reduced-motion:reduce){.list-group-hoverable .list-group-item-actions{transition:none}}.list-group-hoverable .list-group-item-actions.show,.list-group-hoverable .list-group-item:hover .list-group-item-actions{opacity:1}.list-group-transparent{--tblr-list-group-border-radius:0;margin:0 -1.25rem}.list-group-transparent .list-group-item{background:0 0;border:0}.list-group-transparent .list-group-item .icon{color:var(--tblr-secondary)}.list-group-transparent .list-group-item.active{font-weight:var(--tblr-font-weight-bold);color:inherit;background:var(--tblr-active-bg)}.list-group-transparent .list-group-item.active .icon{color:inherit}.list-separated{display:flex;flex-direction:column;gap:1rem}.list-inline{margin:0}.list-inline-item:not(:last-child){margin-right:auto;margin-inline-end:.5rem}.list-inline-dots .list-inline-item+.list-inline-item:before{content:" · ";margin-inline-end:.5rem}.loader{position:relative;display:block;width:2.5rem;height:2.5rem;color:#066fd1;vertical-align:middle}.loader:after{position:absolute;top:0;left:0;width:100%;height:100%;content:"";border:1px var(--tblr-border-style);border-color:transparent;border-top-color:currentColor;border-left-color:currentColor;border-radius:100rem;animation:rotate-360 .6s linear;animation-iteration-count:infinite}.dimmer{position:relative}.dimmer .loader{position:absolute;top:50%;right:0;left:0;display:none;margin:0 auto;transform:translateY(-50%)}.dimmer.active .loader{display:block}.dimmer.active .dimmer-content{pointer-events:none;opacity:.1}@keyframes animated-dots{0%{transform:translateX(-100%)}}.animated-dots{display:inline-block;overflow:hidden;vertical-align:bottom}.animated-dots:after{display:inline-block;content:"...";animation:animated-dots 1.2s steps(4,jump-none) infinite}.modal-content>.btn-close,.modal-header>.btn-close{position:absolute;top:0;right:0;width:3.5rem;height:3.5rem;margin:0;padding:0;z-index:10}.modal-body{scrollbar-color:color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 20%,transparent) transparent}.modal-body::-webkit-scrollbar{width:1rem;height:1rem;-webkit-transition:background .3s;transition:background .3s}@media (prefers-reduced-motion:reduce){.modal-body::-webkit-scrollbar{-webkit-transition:none;transition:none}}.modal-body::-webkit-scrollbar-thumb{border-radius:1rem;border:5px solid transparent;box-shadow:inset 0 0 0 1rem color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 20%,transparent)}.modal-body::-webkit-scrollbar-track{background:0 0}.modal-body:hover::-webkit-scrollbar-thumb{box-shadow:inset 0 0 0 1rem color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 40%,transparent)}.modal-body::-webkit-scrollbar-corner{background:0 0}.modal-body .modal-title{margin-bottom:1rem}.modal-body+.modal-body{border-top:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.modal-status{position:absolute;top:0;left:0;right:0;height:2px;background:var(--tblr-secondary);border-radius:var(--tblr-border-radius-lg) var(--tblr-border-radius-lg) 0 0}.modal-header{align-items:center;min-height:3.5rem;background:0 0;padding:0 3.5rem 0 1.5rem}.modal-title{font-size:1rem;font-weight:var(--tblr-font-weight-bold);color:inherit;line-height:1.4285714286}.modal-footer{padding-top:.75rem;padding-bottom:.75rem}.modal-blur{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.modal-full-width{max-width:none;margin:0 .5rem}.nav{--tblr-nav-link-hover-bg:color-mix(in srgb, var(--tblr-nav-link-color) 4%, transparent)}.nav-vertical,.nav-vertical .nav{flex-direction:column;flex-wrap:nowrap}.nav-vertical .nav{margin-left:1.25rem;border-left:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);padding-left:.5rem}.nav-vertical .nav-item.show .nav-link,.nav-vertical .nav-link.active{font-weight:var(--tblr-font-weight-bold);color:var(--tblr-nav-link-active-color)}.nav-vertical.nav-pills{margin:0 -.75rem}.nav-bordered{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.nav-bordered .nav-item+.nav-item{margin-left:1.25rem}.nav-bordered .nav-link{padding-left:0;padding-right:0;margin:0 0 calc(-1 * var(--tblr-border-width));border:0;border-bottom:2px var(--tblr-border-style) transparent}.nav-bordered .nav-link:hover{background-color:transparent}.nav-bordered .nav-item.show .nav-link,.nav-bordered .nav-link.active{color:var(--tblr-primary);border-color:var(--tblr-primary)}.nav-underline .nav-link{border-radius:0}.nav-link{display:flex;transition:color .3s,background-color .3s;align-items:center}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{background-color:var(--tblr-nav-link-hover-bg)}.nav-link-toggle{margin-left:auto;padding:0 .25rem;transition:transform .3s}@media (prefers-reduced-motion:reduce){.nav-link-toggle{transition:none}}.nav-link-toggle:after{content:"";display:inline-block;vertical-align:.306em;width:.36em;height:.36em;border-bottom:1px var(--tblr-border-style);border-left:1px var(--tblr-border-style);margin-right:.1em;margin-left:.4em;transform:rotate(-45deg)}.nav-link-toggle:after{margin:0}.nav-link[aria-expanded=true] .nav-link-toggle{transform:rotate(180deg)}.nav-link-icon{width:1.25rem;height:1.25rem;margin-right:.5rem;color:inherit}.nav-link-icon svg{display:block;height:100%}.nav-fill .nav-item .nav-link{justify-content:center}.stars{display:inline-flex;color:#9ca3af;font-size:.75rem}.stars .star:not(:first-child){margin-left:.25rem}.pagination{margin:0;--tblr-pagination-gap:.25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;gap:var(--tblr-pagination-gap);line-height:var(--tblr-body-line-height)}.page-link{min-width:2rem;border-radius:var(--tblr-pagination-border-radius)}.page-item:not(.active) .page-link:hover{background:var(--tblr-pagination-hover-bg)}.page-text{padding-left:.5rem;padding-right:.5rem}.page-item{text-align:center}.page-item.page-next,.page-item.page-prev{flex:0 0 50%;text-align:left}.page-item.page-next{margin-left:auto;text-align:right}.page-item-subtitle{margin-bottom:2px;font-size:12px;color:var(--tblr-secondary);text-transform:uppercase}.page-item.disabled .page-item-subtitle{color:var(--tblr-disabled-color)}.page-item-title{font-size:1rem;font-weight:var(--tblr-font-weight-normal);color:var(--tblr-body-color)}.page-link:hover .page-item-title{color:#066fd1}.page-item.disabled .page-item-title{color:var(--tblr-disabled-color)}.pagination-outline{--tblr-pagination-border-color:var(--tblr-border-color);--tblr-pagination-disabled-border-color:var(--tblr-border-color);--tblr-pagination-border-width:1px}.pagination-circle{--tblr-pagination-border-radius:var(--tblr-border-radius-pill)}@keyframes progress-indeterminate{0%{right:100%;left:-35%}100%,60%{right:-90%;left:100%}}.progress{position:relative;width:100%;line-height:.5rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.progress::-webkit-progress-bar{background:var(--tblr-progress-bg)}.progress::-webkit-progress-value{background-color:var(--tblr-primary)}.progress::-moz-progress-bar{background-color:var(--tblr-primary)}.progress::-ms-fill{background-color:var(--tblr-primary);border:none}.progress-sm{height:.25rem}.progress-bar{height:100%}.progress-bar-indeterminate:after,.progress-bar-indeterminate:before{position:absolute;top:0;bottom:0;left:0;content:"";background-color:inherit;will-change:left,right}.progress-bar-indeterminate:before{animation:progress-indeterminate 1.5s cubic-bezier(.65,.815,.735,.395) infinite}.progress-separated .progress-bar{box-shadow:0 0 0 2px var(--tblr-card-bg,var(--tblr-bg-surface))}.progressbg{position:relative;padding:.25rem .5rem;display:flex}.progressbg-text{position:relative;z-index:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.progressbg-progress{position:absolute;top:0;right:0;bottom:0;left:0;z-index:0;height:100%;background:0 0;pointer-events:none}.progressbg-value{font-weight:var(--tblr-font-weight-medium);margin-left:auto;padding-left:2rem}.ribbon{--tblr-ribbon-margin:0.25rem;--tblr-ribbon-border-radius:var(--tblr-border-radius);position:absolute;top:.75rem;right:calc(-1 * var(--tblr-ribbon-margin));z-index:1;padding:.25rem .75rem;font-size:.625rem;font-weight:var(--tblr-font-weight-bold);line-height:1;color:#fff;text-align:center;text-transform:uppercase;background:var(--tblr-primary);border-color:var(--tblr-primary);border-radius:var(--tblr-ribbon-border-radius) 0 var(--tblr-ribbon-border-radius) var(--tblr-ribbon-border-radius);display:inline-flex;align-items:center;justify-content:center;min-height:2rem;min-width:2rem}.ribbon:before{position:absolute;right:0;bottom:100%;width:0;height:0;content:"";filter:brightness(70%);border:calc(var(--tblr-ribbon-margin) * .5) var(--tblr-border-style);border-color:inherit;border-top-color:transparent;border-right-color:transparent}.ribbon.bg-blue{border-color:var(--tblr-blue)}.ribbon.bg-blue-lt{border-color:rgba(var(--tblr-blue-rgb),.1)!important}.ribbon.bg-azure{border-color:var(--tblr-azure)}.ribbon.bg-azure-lt{border-color:rgba(var(--tblr-azure-rgb),.1)!important}.ribbon.bg-indigo{border-color:var(--tblr-indigo)}.ribbon.bg-indigo-lt{border-color:rgba(var(--tblr-indigo-rgb),.1)!important}.ribbon.bg-purple{border-color:var(--tblr-purple)}.ribbon.bg-purple-lt{border-color:rgba(var(--tblr-purple-rgb),.1)!important}.ribbon.bg-pink{border-color:var(--tblr-pink)}.ribbon.bg-pink-lt{border-color:rgba(var(--tblr-pink-rgb),.1)!important}.ribbon.bg-red{border-color:var(--tblr-red)}.ribbon.bg-red-lt{border-color:rgba(var(--tblr-red-rgb),.1)!important}.ribbon.bg-orange{border-color:var(--tblr-orange)}.ribbon.bg-orange-lt{border-color:rgba(var(--tblr-orange-rgb),.1)!important}.ribbon.bg-yellow{border-color:var(--tblr-yellow)}.ribbon.bg-yellow-lt{border-color:rgba(var(--tblr-yellow-rgb),.1)!important}.ribbon.bg-lime{border-color:var(--tblr-lime)}.ribbon.bg-lime-lt{border-color:rgba(var(--tblr-lime-rgb),.1)!important}.ribbon.bg-green{border-color:var(--tblr-green)}.ribbon.bg-green-lt{border-color:rgba(var(--tblr-green-rgb),.1)!important}.ribbon.bg-teal{border-color:var(--tblr-teal)}.ribbon.bg-teal-lt{border-color:rgba(var(--tblr-teal-rgb),.1)!important}.ribbon.bg-cyan{border-color:var(--tblr-cyan)}.ribbon.bg-cyan-lt{border-color:rgba(var(--tblr-cyan-rgb),.1)!important}.ribbon .icon{width:1.25rem;height:1.25rem;font-size:1.25rem}.ribbon-top{top:calc(-1 * var(--tblr-ribbon-margin));right:.75rem;width:2rem;padding:.5rem 0;border-radius:0 var(--tblr-ribbon-border-radius) var(--tblr-ribbon-border-radius) var(--tblr-ribbon-border-radius)}.ribbon-top:before{top:0;right:100%;bottom:auto;border-color:inherit;border-top-color:transparent;border-left-color:transparent}.ribbon-top.ribbon-start{right:auto;left:.75rem}.ribbon-top.ribbon-start:before{top:0;right:100%;left:auto}.ribbon-start{right:auto;left:calc(-1 * var(--tblr-ribbon-margin));border-radius:0 var(--tblr-ribbon-border-radius) var(--tblr-ribbon-border-radius) var(--tblr-ribbon-border-radius)}.ribbon-start:before{top:auto;bottom:100%;left:0;border-color:inherit;border-top-color:transparent;border-left-color:transparent}.ribbon-bottom{top:auto;bottom:.75rem}.ribbon-bookmark{padding-left:.25rem;border-radius:0 0 var(--tblr-ribbon-border-radius) 0}.ribbon-bookmark:after{position:absolute;top:0;right:100%;display:block;width:0;height:0;content:"";border:1rem var(--tblr-border-style);border-color:inherit;border-right-width:0;border-left-color:transparent;border-left-width:.5rem}.ribbon-bookmark.ribbon-left{padding-right:.5rem}.ribbon-bookmark.ribbon-left:after{right:auto;left:100%;border-right-color:transparent;border-right-width:.5rem;border-left-width:0}.ribbon-bookmark.ribbon-top{padding-right:0;padding-bottom:.25rem;padding-left:0;border-radius:0 var(--tblr-ribbon-border-radius) 0 0}.ribbon-bookmark.ribbon-top:after{top:100%;right:0;left:0;border-color:inherit;border-width:1rem;border-top-width:0;border-bottom-color:transparent;border-bottom-width:.5rem}.markdown{line-height:2}.markdown>:first-child{margin-top:0}.markdown>:last-child,.markdown>:last-child .highlight{margin-bottom:0}@media (min-width:768px){.markdown>.hr,.markdown>hr{margin-top:3em;margin-bottom:3em}}.markdown>.h1,.markdown>.h2,.markdown>.h3,.markdown>.h4,.markdown>.h5,.markdown>.h6,.markdown>h1,.markdown>h2,.markdown>h3,.markdown>h4,.markdown>h5,.markdown>h6{font-weight:var(--tblr-font-weight-bold)}.markdown>.h2,.markdown>.h3,.markdown>.h4,.markdown>.h5,.markdown>.h6,.markdown>h2,.markdown>h3,.markdown>h4,.markdown>h5,.markdown>h6{margin-top:2.5rem}.markdown>table{font-size:var(--tblr-body-font-size)}.markdown>blockquote{font-size:1rem;margin:1.5rem 0;padding:.5rem 1.5rem}.markdown>img,.markdown>p>img{border-radius:var(--tblr-border-radius);border:1px solid var(--tblr-border-color)}.markdown pre{max-height:20rem}.placeholder:not(.btn):not([class*=bg-]){background-color:currentColor!important}.placeholder:not(.avatar):not([class*=card-img-]){border-radius:var(--tblr-border-radius)}.nav-segmented{--tblr-nav-bg:var(--tblr-bg-surface-tertiary);--tblr-nav-padding:2px;--tblr-nav-height:2.5rem;--tblr-nav-gap:.25rem;--tblr-nav-active-bg:var(--tblr-bg-surface);--tblr-nav-font-size:inherit;--tblr-nav-radius:6px;--tblr-nav-link-disabled-color:var(--tblr-disabled-color);--tblr-nav-link-gap:.25rem;--tblr-nav-link-padding-x:.75rem;--tblr-nav-link-icon-size:1.25rem;display:inline-flex;flex-wrap:wrap;gap:var(--tblr-nav-gap);padding:var(--tblr-nav-padding);list-style:none;background:var(--tblr-nav-bg);border-radius:calc(var(--tblr-nav-radius) + var(--tblr-nav-padding));box-shadow:inset 0 0 0 1px rgba(0,0,0,.04)}.nav-segmented .nav-link{display:inline-flex;gap:calc(.25rem + var(--tblr-nav-link-gap));align-items:center;margin:0;font-size:var(--tblr-nav-font-size);min-width:calc(var(--tblr-nav-height) - 2 * var(--tblr-nav-padding));height:calc(var(--tblr-nav-height) - 2 * var(--tblr-nav-padding));padding:0 calc(var(--tblr-nav-link-padding-x) - 2px);border:1px solid transparent;background:0 0;color:var(--tblr-secondary);text-align:center;text-decoration:none;white-space:nowrap;cursor:pointer;transition:background-color .3s,color .3s;border-radius:var(--tblr-nav-radius);flex-grow:1;justify-content:center}.nav-segmented .nav-link.hover,.nav-segmented .nav-link:hover{background:rgba(0,0,0,.04);color:var(--tblr-body-color)}.nav-segmented .nav-link.disabled,.nav-segmented .nav-link:disabled{color:var(--tblr-nav-link-disabled-color);cursor:not-allowed}.nav-segmented .nav-link-input:checked+.nav-link,.nav-segmented .nav-link.active{color:var(--tblr-body-color);background:var(--tblr-nav-active-bg);border-color:var(--tblr-border-color)}.nav-segmented .nav-link-input{display:none}.nav-segmented .nav-link-icon{width:var(--tblr-nav-link-icon-size);height:var(--tblr-nav-link-icon-size);margin:0 -.25rem;color:inherit}.nav-segmented-vertical{flex-direction:column}.nav-segmented-vertical .nav-link{justify-content:flex-start}.nav-sm{--tblr-nav-height:2rem;--tblr-nav-font-size:var(--tblr-font-size-h5);--tblr-nav-radius:4px;--tblr-nav-link-padding-x:.5rem;--tblr-nav-link-gap:.25rem;--tblr-nav-link-icon-size:1rem}.nav-lg{--tblr-nav-height:3rem;--tblr-nav-font-size:var(--tblr-font-size-h3);--tblr-nav-radius:8px;--tblr-nav-link-padding-x:1rem;--tblr-nav-link-gap:.5rem;--tblr-nav-link-icon-size:1.5rem}.steps{--tblr-steps-color:var(--tblr-primary);--tblr-steps-inactive-color:var(--tblr-border-color);--tblr-steps-dot-size:.5rem;--tblr-steps-border-width:2px;display:flex;flex-wrap:nowrap;width:100%;padding:0;margin:0;list-style:none}.steps-blue{--tblr-steps-color:var(--tblr-blue)}.steps-blue-lt{--tblr-steps-color:var(--tblr-blue-lt)}.steps-azure{--tblr-steps-color:var(--tblr-azure)}.steps-azure-lt{--tblr-steps-color:var(--tblr-azure-lt)}.steps-indigo{--tblr-steps-color:var(--tblr-indigo)}.steps-indigo-lt{--tblr-steps-color:var(--tblr-indigo-lt)}.steps-purple{--tblr-steps-color:var(--tblr-purple)}.steps-purple-lt{--tblr-steps-color:var(--tblr-purple-lt)}.steps-pink{--tblr-steps-color:var(--tblr-pink)}.steps-pink-lt{--tblr-steps-color:var(--tblr-pink-lt)}.steps-red{--tblr-steps-color:var(--tblr-red)}.steps-red-lt{--tblr-steps-color:var(--tblr-red-lt)}.steps-orange{--tblr-steps-color:var(--tblr-orange)}.steps-orange-lt{--tblr-steps-color:var(--tblr-orange-lt)}.steps-yellow{--tblr-steps-color:var(--tblr-yellow)}.steps-yellow-lt{--tblr-steps-color:var(--tblr-yellow-lt)}.steps-lime{--tblr-steps-color:var(--tblr-lime)}.steps-lime-lt{--tblr-steps-color:var(--tblr-lime-lt)}.steps-green{--tblr-steps-color:var(--tblr-green)}.steps-green-lt{--tblr-steps-color:var(--tblr-green-lt)}.steps-teal{--tblr-steps-color:var(--tblr-teal)}.steps-teal-lt{--tblr-steps-color:var(--tblr-teal-lt)}.steps-cyan{--tblr-steps-color:var(--tblr-cyan)}.steps-cyan-lt{--tblr-steps-color:var(--tblr-cyan-lt)}.step-item{position:relative;flex:1 1 0;min-height:1rem;margin-top:0;color:inherit;text-align:center;cursor:default;padding-top:calc(var(--tblr-steps-dot-size))}a.step-item{cursor:pointer}a.step-item:hover{color:inherit}.step-item:after,.step-item:before{background:var(--tblr-steps-color)}.step-item:not(:last-child):after{position:absolute;left:50%;width:100%;content:"";transform:translateY(-50%)}.step-item:after{top:calc(var(--tblr-steps-dot-size) * .5);height:var(--tblr-steps-border-width)}.step-item:before{content:"";position:absolute;top:0;left:50%;z-index:1;box-sizing:content-box;display:flex;align-items:center;justify-content:center;border-radius:100rem;transform:translateX(-50%);color:var(--tblr-white);width:var(--tblr-steps-dot-size);height:var(--tblr-steps-dot-size)}.step-item.active{font-weight:var(--tblr-font-weight-bold)}.step-item.active:after{background:var(--tblr-steps-inactive-color)}.step-item.active~.step-item{color:var(--tblr-disabled-color)}.step-item.active~.step-item:after,.step-item.active~.step-item:before{background:var(--tblr-steps-inactive-color)}.steps-counter{--tblr-steps-dot-size:1.5rem;counter-reset:steps}.steps-counter .step-item{counter-increment:steps}.steps-counter .step-item:before{content:counter(steps)}.steps-vertical{--tblr-steps-dot-offset:6px;flex-direction:column}.steps-vertical.steps-counter{--tblr-steps-dot-offset:-2px}.steps-vertical .step-item{text-align:left;padding-top:0;padding-left:calc(var(--tblr-steps-dot-size) + 1rem);min-height:auto}.steps-vertical .step-item:not(:first-child){margin-top:1rem}.steps-vertical .step-item:before{top:var(--tblr-steps-dot-offset);left:0;transform:translate(0,0)}.steps-vertical .step-item:not(:last-child):after{position:absolute;content:"";transform:translateX(-50%);top:var(--tblr-steps-dot-offset);left:calc(var(--tblr-steps-dot-size) * .5);width:var(--tblr-steps-border-width);height:calc(100% + 1rem)}@keyframes status-pulsate-main{40%{transform:scale(1.25,1.25)}60%{transform:scale(1.25,1.25)}}@keyframes status-pulsate-secondary{10%{transform:scale(1,1)}30%{transform:scale(3,3)}80%{transform:scale(3,3)}100%{transform:scale(1,1)}}@keyframes status-pulsate-tertiary{25%{transform:scale(1,1)}80%{transform:scale(3,3);opacity:0}100%{transform:scale(3,3);opacity:0}}.status{--tblr-status-height:1.5rem;--tblr-status-color:#6b7280;--tblr-status-color-rgb:107,114,128;display:inline-flex;align-items:center;height:var(--tblr-status-height);padding:.25rem .75rem;gap:.5rem;color:var(--tblr-status-color);background:rgba(var(--tblr-status-color-rgb),.1);font-size:.875rem;text-transform:none;letter-spacing:normal;border-radius:100rem;font-weight:var(--tblr-font-weight-medium);line-height:1;margin:0}.status .status-dot{background:var(--tblr-status-color)}.status .icon{font-size:1.25rem}.status-lite{border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)!important;background:0 0!important;color:var(--tblr-body-color)!important}.status-primary{--tblr-status-color:#066fd1;--tblr-status-color-rgb:6,111,209}.status-secondary{--tblr-status-color:#6b7280;--tblr-status-color-rgb:107,114,128}.status-success{--tblr-status-color:#2fb344;--tblr-status-color-rgb:47,179,68}.status-info{--tblr-status-color:#4299e1;--tblr-status-color-rgb:66,153,225}.status-warning{--tblr-status-color:#f59f00;--tblr-status-color-rgb:245,159,0}.status-danger{--tblr-status-color:#d63939;--tblr-status-color-rgb:214,57,57}.status-light{--tblr-status-color:#f9fafb;--tblr-status-color-rgb:249,250,251}.status-dark{--tblr-status-color:#1f2937;--tblr-status-color-rgb:31,41,55}.status-muted{--tblr-status-color:#6b7280;--tblr-status-color-rgb:107,114,128}.status-blue{--tblr-status-color:#066fd1;--tblr-status-color-rgb:6,111,209}.status-azure{--tblr-status-color:#4299e1;--tblr-status-color-rgb:66,153,225}.status-indigo{--tblr-status-color:#4263eb;--tblr-status-color-rgb:66,99,235}.status-purple{--tblr-status-color:#ae3ec9;--tblr-status-color-rgb:174,62,201}.status-pink{--tblr-status-color:#d6336c;--tblr-status-color-rgb:214,51,108}.status-red{--tblr-status-color:#d63939;--tblr-status-color-rgb:214,57,57}.status-orange{--tblr-status-color:#f76707;--tblr-status-color-rgb:247,103,7}.status-yellow{--tblr-status-color:#f59f00;--tblr-status-color-rgb:245,159,0}.status-lime{--tblr-status-color:#74b816;--tblr-status-color-rgb:116,184,22}.status-green{--tblr-status-color:#2fb344;--tblr-status-color-rgb:47,179,68}.status-teal{--tblr-status-color:#0ca678;--tblr-status-color-rgb:12,166,120}.status-cyan{--tblr-status-color:#17a2b8;--tblr-status-color-rgb:23,162,184}.status-dot{--tblr-status-dot-color:var(--tblr-status-color, #6b7280);--tblr-status-size:0.5rem;position:relative;display:inline-block;width:var(--tblr-status-size);height:var(--tblr-status-size);background:var(--tblr-status-dot-color);border-radius:100rem}.status-dot-animated:before{content:"";position:absolute;inset:0;z-index:0;background:inherit;border-radius:inherit;opacity:.6;animation:1s linear 2s backwards infinite status-pulsate-tertiary}.status-indicator{--tblr-status-indicator-size:2.5rem;--tblr-status-indicator-color:var(--tblr-status-color, #6b7280);display:block;position:relative;width:var(--tblr-status-indicator-size);height:var(--tblr-status-indicator-size)}.status-indicator-circle{--tblr-status-circle-size:.75rem;position:absolute;left:50%;top:50%;margin:calc(var(--tblr-status-circle-size)/ -2) 0 0 calc(var(--tblr-status-circle-size)/ -2);width:var(--tblr-status-circle-size);height:var(--tblr-status-circle-size);border-radius:100rem;background:var(--tblr-status-color)}.status-indicator-circle:first-child{z-index:3}.status-indicator-circle:nth-child(2){z-index:2;opacity:.1}.status-indicator-circle:nth-child(3){z-index:1;opacity:.3}.status-indicator-animated .status-indicator-circle:first-child{animation:2s linear 1s infinite backwards status-pulsate-main}.status-indicator-animated .status-indicator-circle:nth-child(2){animation:2s linear 1s infinite backwards status-pulsate-secondary}.status-indicator-animated .status-indicator-circle:nth-child(3){animation:2s linear 1s infinite backwards status-pulsate-tertiary}.switch-icon{display:inline-block;line-height:1;border:0;padding:0;background:0 0;width:1.25rem;height:1.25rem;vertical-align:bottom;position:relative;cursor:pointer}.switch-icon.disabled{pointer-events:none;opacity:.4}.switch-icon:focus{outline:0}.switch-icon svg{display:block;width:100%;height:100%}.switch-icon .switch-icon-a,.switch-icon .switch-icon-b{display:block;width:100%;height:100%}.switch-icon .switch-icon-a{opacity:1}.switch-icon .switch-icon-b{position:absolute;top:0;left:0;opacity:0}.switch-icon.active .switch-icon-a{opacity:0}.switch-icon.active .switch-icon-b{opacity:1}.switch-icon-fade .switch-icon-a,.switch-icon-fade .switch-icon-b{transition:opacity .5s}@media (prefers-reduced-motion:reduce){.switch-icon-fade .switch-icon-a,.switch-icon-fade .switch-icon-b{transition:none}}.switch-icon-scale .switch-icon-a,.switch-icon-scale .switch-icon-b{transition:opacity .5s,transform 0s .5s}@media (prefers-reduced-motion:reduce){.switch-icon-scale .switch-icon-a,.switch-icon-scale .switch-icon-b{transition:none}}.switch-icon-scale .switch-icon-b{transform:scale(1.5)}.switch-icon-scale.active .switch-icon-a,.switch-icon-scale.active .switch-icon-b{transition:opacity 0s,transform .5s}@media (prefers-reduced-motion:reduce){.switch-icon-scale.active .switch-icon-a,.switch-icon-scale.active .switch-icon-b{transition:none}}.switch-icon-scale.active .switch-icon-b{transform:scale(1)}.switch-icon-flip{perspective:10em}.switch-icon-flip .switch-icon-a,.switch-icon-flip .switch-icon-b{backface-visibility:hidden;transform-style:preserve-3d;transition:opacity 0s .2s,transform .4s ease-in-out}@media (prefers-reduced-motion:reduce){.switch-icon-flip .switch-icon-a,.switch-icon-flip .switch-icon-b{transition:none}}.switch-icon-flip .switch-icon-a{opacity:1;transform:rotateY(0)}.switch-icon-flip .switch-icon-b{opacity:1;transform:rotateY(-180deg)}.switch-icon-flip.active .switch-icon-a{opacity:1;transform:rotateY(180deg)}.switch-icon-flip.active .switch-icon-b{opacity:1;transform:rotateY(0)}.switch-icon-slide-down,.switch-icon-slide-end,.switch-icon-slide-left,.switch-icon-slide-right,.switch-icon-slide-start,.switch-icon-slide-up{overflow:hidden}.switch-icon-slide-down .switch-icon-a,.switch-icon-slide-down .switch-icon-b,.switch-icon-slide-end .switch-icon-a,.switch-icon-slide-end .switch-icon-b,.switch-icon-slide-left .switch-icon-a,.switch-icon-slide-left .switch-icon-b,.switch-icon-slide-right .switch-icon-a,.switch-icon-slide-right .switch-icon-b,.switch-icon-slide-start .switch-icon-a,.switch-icon-slide-start .switch-icon-b,.switch-icon-slide-up .switch-icon-a,.switch-icon-slide-up .switch-icon-b{transition:opacity .3s,transform .3s}@media (prefers-reduced-motion:reduce){.switch-icon-slide-down .switch-icon-a,.switch-icon-slide-down .switch-icon-b,.switch-icon-slide-end .switch-icon-a,.switch-icon-slide-end .switch-icon-b,.switch-icon-slide-left .switch-icon-a,.switch-icon-slide-left .switch-icon-b,.switch-icon-slide-right .switch-icon-a,.switch-icon-slide-right .switch-icon-b,.switch-icon-slide-start .switch-icon-a,.switch-icon-slide-start .switch-icon-b,.switch-icon-slide-up .switch-icon-a,.switch-icon-slide-up .switch-icon-b{transition:none}}.switch-icon-slide-down .switch-icon-a,.switch-icon-slide-end .switch-icon-a,.switch-icon-slide-left .switch-icon-a,.switch-icon-slide-right .switch-icon-a,.switch-icon-slide-start .switch-icon-a,.switch-icon-slide-up .switch-icon-a{transform:translateY(0)}.switch-icon-slide-down .switch-icon-b,.switch-icon-slide-end .switch-icon-b,.switch-icon-slide-left .switch-icon-b,.switch-icon-slide-right .switch-icon-b,.switch-icon-slide-start .switch-icon-b,.switch-icon-slide-up .switch-icon-b{transform:translateY(100%)}.switch-icon-slide-down.active .switch-icon-a,.switch-icon-slide-end.active .switch-icon-a,.switch-icon-slide-left.active .switch-icon-a,.switch-icon-slide-right.active .switch-icon-a,.switch-icon-slide-start.active .switch-icon-a,.switch-icon-slide-up.active .switch-icon-a{transform:translateY(-100%)}.switch-icon-slide-down.active .switch-icon-b,.switch-icon-slide-end.active .switch-icon-b,.switch-icon-slide-left.active .switch-icon-b,.switch-icon-slide-right.active .switch-icon-b,.switch-icon-slide-start.active .switch-icon-b,.switch-icon-slide-up.active .switch-icon-b{transform:translateY(0)}.switch-icon-slide-left .switch-icon-a,.switch-icon-slide-start .switch-icon-a{transform:translateX(0)}.switch-icon-slide-left .switch-icon-b,.switch-icon-slide-start .switch-icon-b{transform:translateX(100%)}.switch-icon-slide-left.active .switch-icon-a,.switch-icon-slide-start.active .switch-icon-a{transform:translateX(-100%)}.switch-icon-slide-left.active .switch-icon-b,.switch-icon-slide-start.active .switch-icon-b{transform:translateX(0)}.switch-icon-slide-end .switch-icon-a,.switch-icon-slide-right .switch-icon-a{transform:translateX(0)}.switch-icon-slide-end .switch-icon-b,.switch-icon-slide-right .switch-icon-b{transform:translateX(-100%)}.switch-icon-slide-end.active .switch-icon-a,.switch-icon-slide-right.active .switch-icon-a{transform:translateX(100%)}.switch-icon-slide-end.active .switch-icon-b,.switch-icon-slide-right.active .switch-icon-b{transform:translateX(0)}.switch-icon-slide-down .switch-icon-a{transform:translateY(0)}.switch-icon-slide-down .switch-icon-b{transform:translateY(-100%)}.switch-icon-slide-down.active .switch-icon-a{transform:translateY(100%)}.switch-icon-slide-down.active .switch-icon-b{transform:translateY(0)}.markdown>table thead th,.table thead th{background:var(--tblr-bg-surface-tertiary);font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);padding-top:.5rem;padding-bottom:.5rem;white-space:nowrap}@media print{.markdown>table thead th,.table thead th{background:0 0}}.table-responsive .markdown>table,.table-responsive .table{margin-bottom:0}.table-responsive+.card-footer{border-top:0}.table-transparent thead th{background:0 0}.table-nowrap>:not(caption)>*>*{white-space:nowrap}.table-vcenter>:not(caption)>*>*{vertical-align:middle}.table-center>:not(caption)>*>*{text-align:center}.td-truncate{max-width:1px;width:100%}.table-mobile{display:block}.table-mobile thead{display:none}.table-mobile tbody,.table-mobile tr{display:flex;flex-direction:column}.table-mobile td{display:block;padding:.75rem .75rem!important;border:none;color:var(--tblr-body-color)!important}.table-mobile td[data-label]:before{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);content:attr(data-label);display:block}.table-mobile tr{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)}.table-mobile .btn{display:block}@media (max-width:575.98px){.table-mobile-sm{display:block}.table-mobile-sm thead{display:none}.table-mobile-sm tbody,.table-mobile-sm tr{display:flex;flex-direction:column}.table-mobile-sm td{display:block;padding:.75rem .75rem!important;border:none;color:var(--tblr-body-color)!important}.table-mobile-sm td[data-label]:before{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);content:attr(data-label);display:block}.table-mobile-sm tr{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)}.table-mobile-sm .btn{display:block}}@media (max-width:767.98px){.table-mobile-md{display:block}.table-mobile-md thead{display:none}.table-mobile-md tbody,.table-mobile-md tr{display:flex;flex-direction:column}.table-mobile-md td{display:block;padding:.75rem .75rem!important;border:none;color:var(--tblr-body-color)!important}.table-mobile-md td[data-label]:before{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);content:attr(data-label);display:block}.table-mobile-md tr{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)}.table-mobile-md .btn{display:block}}@media (max-width:991.98px){.table-mobile-lg{display:block}.table-mobile-lg thead{display:none}.table-mobile-lg tbody,.table-mobile-lg tr{display:flex;flex-direction:column}.table-mobile-lg td{display:block;padding:.75rem .75rem!important;border:none;color:var(--tblr-body-color)!important}.table-mobile-lg td[data-label]:before{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);content:attr(data-label);display:block}.table-mobile-lg tr{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)}.table-mobile-lg .btn{display:block}}@media (max-width:1199.98px){.table-mobile-xl{display:block}.table-mobile-xl thead{display:none}.table-mobile-xl tbody,.table-mobile-xl tr{display:flex;flex-direction:column}.table-mobile-xl td{display:block;padding:.75rem .75rem!important;border:none;color:var(--tblr-body-color)!important}.table-mobile-xl td[data-label]:before{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);content:attr(data-label);display:block}.table-mobile-xl tr{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)}.table-mobile-xl .btn{display:block}}@media (max-width:1399.98px){.table-mobile-xxl{display:block}.table-mobile-xxl thead{display:none}.table-mobile-xxl tbody,.table-mobile-xxl tr{display:flex;flex-direction:column}.table-mobile-xxl td{display:block;padding:.75rem .75rem!important;border:none;color:var(--tblr-body-color)!important}.table-mobile-xxl td[data-label]:before{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);content:attr(data-label);display:block}.table-mobile-xxl tr{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent)}.table-mobile-xxl .btn{display:block}}.table-sort{font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;border:0;background:inherit;display:block;width:100%;text-align:inherit;transition:color .3s;margin:-.5rem -.75rem;padding:.5rem .75rem}@media (prefers-reduced-motion:reduce){.table-sort{transition:none}}.table-sort.asc,.table-sort.desc,.table-sort:hover{color:var(--tblr-body-color)}.table-sort:after{content:"";display:inline-flex;width:1rem;height:1rem;vertical-align:bottom;-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,");background:currentColor;margin-left:.25rem}.table-sort.asc:after{-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.table-sort.desc:after{-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.table-borderless thead th{background:0 0}.table-selectable tbody tr .on-checked{display:none}.table-selectable tbody tr .on-unchecked{display:initial}.table-selectable tbody tr:has(.table-selectable-check:checked){background-color:var(--tblr-active-bg)}.table-selectable tbody tr:has(.table-selectable-check:checked) .on-checked{display:initial}.table-selectable tbody tr:has(.table-selectable-check:checked) .on-unchecked{display:none}.tag{--tblr-tag-height:1.5rem;border:1px solid var(--tblr-border-color);display:inline-flex;align-items:center;height:var(--tblr-tag-height);border-radius:var(--tblr-border-radius);padding:0 .5rem;background:var(--tblr-bg-surface);box-shadow:var(--tblr-shadow-input);gap:.5rem}.tag .btn-close{margin-right:-.25rem;margin-left:-.125rem;padding:0;width:1rem;height:1rem;font-size:.5rem}.tag-badge{--tblr-badge-font-size:0.625rem;--tblr-badge-padding-x:.25rem;--tblr-badge-padding-y:.125rem;margin-right:-.25rem}.tag-avatar,.tag-check,.tag-flag,.tag-icon,.tag-payment{margin-left:-.25rem}.tag-icon{color:var(--tblr-secondary);margin-right:-.125rem;width:1rem;height:1rem}.tag-check{width:1rem;height:1rem;background-size:1rem}.tags-list{--tblr-list-gap:0.5rem;display:flex;flex-wrap:wrap;gap:var(--tblr-list-gap)}.toast{border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent);box-shadow:rgba(31,41,55,.04) 0 2px 4px 0}.toast .toast-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.toast button[data-bs-dismiss=toast]{outline:0}.toast-primary{--tblr-toast-color:#066fd1}.toast-secondary{--tblr-toast-color:#6b7280}.toast-success{--tblr-toast-color:#2fb344}.toast-info{--tblr-toast-color:#4299e1}.toast-warning{--tblr-toast-color:#f59f00}.toast-danger{--tblr-toast-color:#d63939}.toast-light{--tblr-toast-color:#f9fafb}.toast-dark{--tblr-toast-color:#1f2937}.toast-muted{--tblr-toast-color:#6b7280}.toast-blue{--tblr-toast-color:#066fd1}.toast-azure{--tblr-toast-color:#4299e1}.toast-indigo{--tblr-toast-color:#4263eb}.toast-purple{--tblr-toast-color:#ae3ec9}.toast-pink{--tblr-toast-color:#d6336c}.toast-red{--tblr-toast-color:#d63939}.toast-orange{--tblr-toast-color:#f76707}.toast-yellow{--tblr-toast-color:#f59f00}.toast-lime{--tblr-toast-color:#74b816}.toast-green{--tblr-toast-color:#2fb344}.toast-teal{--tblr-toast-color:#0ca678}.toast-cyan{--tblr-toast-color:#17a2b8}.toolbar{display:flex;flex-wrap:nowrap;flex-shrink:0;margin:0 -.5rem}.toolbar>*{margin:0 .5rem}.tracking{--tblr-tracking-height:1.5rem;--tblr-tracking-gap-width:0.125rem;--tblr-tracking-block-border-radius:var(--tblr-border-radius);display:flex;gap:var(--tblr-tracking-gap-width)}.tracking-squares{--tblr-tracking-block-border-radius:var(--tblr-border-radius-sm)}.tracking-squares .tracking-block{height:auto}.tracking-squares .tracking-block:before{content:"";display:block;padding-top:100%}.tracking-block{flex:1;border-radius:var(--tblr-tracking-block-border-radius);height:var(--tblr-tracking-height);min-width:.25rem;background:var(--tblr-border-color)}.timeline{--tblr-timeline-icon-size:2.5rem;position:relative;list-style:none;padding:0}.timeline-event{position:relative}.timeline-event:not(:last-child){margin-bottom:var(--tblr-page-padding)}.timeline-event:not(:last-child):before{content:"";position:absolute;top:var(--tblr-timeline-icon-size);left:calc(var(--tblr-timeline-icon-size)/ 2);bottom:calc(-1 * var(--tblr-page-padding));width:var(--tblr-border-width);background-color:var(--tblr-border-color);border-radius:var(--tblr-border-radius)}.timeline-event-icon{position:absolute;display:flex;align-items:center;justify-content:center;width:var(--tblr-timeline-icon-size,2.5rem);height:var(--tblr-timeline-icon-size,2.5rem);background:var(--tblr-bg-surface-secondary);color:var(--tblr-secondary);border-radius:var(--tblr-border-radius);z-index:5}.timeline-event-card{margin-left:calc(var(--tblr-timeline-icon-size,2.5rem) + var(--tblr-page-padding))}.timeline-simple .timeline-event-icon{display:none}.timeline-simple .timeline-event-card{margin-left:0}.hr-text{display:flex;align-items:center;margin:2rem 0;font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary);height:1px}.hr-text:after,.hr-text:before{flex:1 1 auto;height:1px;background-color:var(--tblr-border-color)}.hr-text:before{content:"";margin-right:.5rem}.hr-text:after{content:"";margin-left:.5rem}.hr-text>:first-child{padding-right:.5rem;padding-left:0;color:var(--tblr-secondary)}.hr-text.hr-text-left:before,.hr-text.hr-text-start:before{content:none}.hr-text.hr-text-left>:first-child,.hr-text.hr-text-start>:first-child{padding-right:.5rem;padding-left:.5rem}.hr-text.hr-text-end:before,.hr-text.hr-text-right:before{content:""}.hr-text.hr-text-end:after,.hr-text.hr-text-right:after{content:none}.hr-text.hr-text-end>:first-child,.hr-text.hr-text-right>:first-child{padding-right:0;padding-left:.5rem}.card>.hr-text{margin:0}.hr-text-spaceless{margin:-.5rem 0}.lead{color:var(--tblr-secondary);font-size:inherit}a{-webkit-text-decoration-skip:ink;text-decoration-skip-ink:auto;color:color-mix(in srgb,transparent,var(--tblr-link-color) var(--tblr-link-opacity,100%))}a:hover{color:color-mix(in srgb,transparent,var(--tblr-link-hover-color) var(--tblr-link-opacity,100%))}.h1 a,.h2 a,.h3 a,.h4 a,.h5 a,.h6 a,h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{color:inherit}.h1 a:hover,.h2 a:hover,.h3 a:hover,.h4 a:hover,.h5 a:hover,.h6 a:hover,h1 a:hover,h2 a:hover,h3 a:hover,h4 a:hover,h5 a:hover,h6 a:hover{color:inherit}.h1,h1{font-size:var(--tblr-font-size-h1);line-height:var(--tblr-line-height-h1)}.h2,h2{font-size:var(--tblr-font-size-h2);line-height:var(--tblr-line-height-h2)}.h3,h3{font-size:var(--tblr-font-size-h3);line-height:var(--tblr-line-height-h3)}.h4,h4{font-size:var(--tblr-font-size-h4);line-height:var(--tblr-line-height-h4)}.h5,h5{font-size:var(--tblr-font-size-h5);line-height:var(--tblr-line-height-h5)}.h6,h6{font-size:var(--tblr-font-size-h6);line-height:var(--tblr-line-height-h6)}.fs-base{font-size:var(--tblr-body-font-size)}.strong,b,strong{font-weight:var(--tblr-font-weight-bold)}blockquote{padding:1rem 1rem 1rem;border-left:2px var(--tblr-border-style) var(--tblr-border-color)}blockquote p{margin-bottom:1rem}blockquote cite{display:block;text-align:right}blockquote cite:before{content:"— "}ol,ul{padding-left:1.5rem}.hr,hr{margin:2rem 0}dl dd:last-child{margin-bottom:0}pre{--tblr-scrollbar-color:var(--tblr-light);padding:1rem;background:var(--tblr-bg-surface-dark);color:var(--tblr-light);border-radius:var(--tblr-border-radius);line-height:1.4285714286}pre{scrollbar-color:color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 20%,transparent) transparent}pre::-webkit-scrollbar{width:1rem;height:1rem;-webkit-transition:background .3s;transition:background .3s}@media (prefers-reduced-motion:reduce){pre::-webkit-scrollbar{-webkit-transition:none;transition:none}}pre::-webkit-scrollbar-thumb{border-radius:1rem;border:5px solid transparent;box-shadow:inset 0 0 0 1rem color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 20%,transparent)}pre::-webkit-scrollbar-track{background:0 0}pre:hover::-webkit-scrollbar-thumb{box-shadow:inset 0 0 0 1rem color-mix(in srgb,var(--tblr-scrollbar-color,var(--tblr-body-color)) 40%,transparent)}pre::-webkit-scrollbar-corner{background:0 0}pre code{background:0 0;padding:0}code{background:var(--tblr-code-bg);padding:2px 4px;border-radius:var(--tblr-border-radius)}abbr{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}.kbd,kbd{border:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color);display:inline-block;box-sizing:border-box;max-width:100%;font-size:var(--tblr-font-size-h5);font-weight:var(--tblr-font-weight-medium);line-height:1;vertical-align:baseline;border-radius:var(--tblr-border-radius)}img{max-width:100%;height:auto}.list-unstyled{margin-left:0}::-moz-selection{background-color:color-mix(in srgb,var(--tblr-primary) 10%,transparent)}.text-selected,::selection{background-color:color-mix(in srgb,var(--tblr-primary) 10%,transparent)}.text-selected{display:inline-block}[class*=" link-"].disabled,[class^=link-].disabled{color:var(--tblr-disabled-color)!important;pointer-events:none}a:hover:has(.icon){text-decoration:none}.link-hoverable{border-radius:var(--tblr-border-radius);transition:background-color .15s ease-in-out}.link-hoverable:hover{text-decoration:none;color:var(--tblr-primary);background:color-mix(in srgb,var(--tblr-secondary) 4%,transparent)}.subheader{font-size:.75rem;font-weight:var(--tblr-font-weight-medium);text-transform:uppercase;letter-spacing:.04em;line-height:1rem;color:var(--tblr-secondary)}.mention{display:inline-block;box-shadow:var(--tblr-shadow-border);border-radius:var(--tblr-border-radius-pill);line-height:1.3333333333em;font-size:.8571428571em;color:var(--tblr-body-color);background:var(--tblr-bg-surface-tertiary);padding:.1666666667em .6666666667em;font-weight:var(--tblr-font-weight-medium)}a.mention{cursor:pointer}a.mention.hover,a.mention:hover{background:var(--tblr-bg-surface-secondary);text-decoration:underline}.mention-app,.mention-avatar,.mention-color{width:1.1666666667em;height:1.1666666667em;border-radius:var(--tblr-border-radius-pill);margin:-.1666666667em .3333333333em 0 -.3333333333em;display:inline-flex;background:no-repeat center center/cover;box-shadow:var(--tblr-shadow-border);vertical-align:middle;text-align:center}.mention-app{box-shadow:none;background:0 0;border-radius:0}.mention-count{color:var(--tblr-secondary);margin-left:.6666666667em}.text-incorrect{background:color-mix(in srgb,var(--tblr-red) 4%,transparent);background:color-mix(in srgb,var(--tblr-red) 4%,transparent);text-decoration:underline;text-decoration-thickness:1px;text-decoration-color:var(--tblr-red)}.text-correct{background:color-mix(in srgb,var(--tblr-green) 4%,transparent);background:color-mix(in srgb,var(--tblr-green) 4%,transparent);text-decoration:underline;text-decoration-thickness:1px;text-decoration-color:var(--tblr-green)}.steps{--tblr-steps-padding:2rem;--tblr-steps-item-size:1.5rem;margin-left:1rem;padding-left:var(--tblr-steps-padding);counter-reset:step;border-left:1px solid var(--tblr-border-color);margin-bottom:2rem}.steps .h3,.steps h3{counter-increment:step}.steps .h3:not(:first-child),.steps h3:not(:first-child){margin-top:2.5rem!important}.steps .h3:before,.steps h3:before{content:counter(step);display:inline-block;position:absolute;margin-top:1px;margin-left:calc(-1 * var(--tblr-steps-padding) - var(--tblr-steps-item-size)/ 2);width:var(--tblr-steps-item-size);height:var(--tblr-steps-item-size);text-align:center;color:var(--tblr-body-color);border:1px solid var(--tblr-border-color);background:var(--tblr-bg-surface);border-radius:var(--tblr-border-radius);line-height:calc(var(--tblr-steps-item-size) - 2px);font-size:var(--tblr-font-size-h4);font-weight:var(--tblr-font-weight-bold)}.steps>:last-child{margin-bottom:0}.callout{margin-bottom:1.5rem;border:1px solid var(--tblr-primary-200);border-radius:var(--tblr-border-radius);padding:.5rem 1rem;background:var(--tblr-primary-lt)}.callout>:last-child{margin-bottom:0}.chart{display:block;min-height:10rem}.chart text{font-family:inherit}.chart-sm{height:2.5rem}.chart-lg{height:15rem}.chart-square{height:5.75rem}.chart-sparkline{position:relative;width:4rem;height:2.5rem;line-height:1;min-height:0!important}.chart-sparkline-sm{height:1.5rem}.chart-sparkline-square{width:2.5rem}.chart-sparkline-wide{width:6rem}.chart-sparkline-label{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;font-size:.625rem}.chart-sparkline-label .icon{width:1rem;height:1rem;font-size:1rem}.offcanvas-header{border-bottom:var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color)}.offcanvas-footer{padding:1.5rem 1.5rem}.offcanvas-title{font-size:1rem;font-weight:var(--tblr-font-weight-medium);line-height:1.5rem}.offcanvas-narrow{width:20rem}.chat-bubbles{display:flex;flex-direction:column;gap:1rem}.chat-bubble{background:var(--tblr-bg-surface-secondary);border-radius:var(--tblr-border-radius-lg);padding:1rem;position:relative}.chat-bubble-me{background-color:var(--tblr-primary-lt);box-shadow:none}.chat-bubble-title{margin-bottom:.25rem}.chat-bubble-author{font-weight:600}.chat-bubble-date{color:var(--tblr-secondary)}.chat-bubble-body>:last-child{margin-bottom:0}.signature{--tblr-signature-padding:var(--tblr-spacer-1);--tblr-signature-border-radius:var(--tblr-border-radius);border:var(--tblr-border-width) solid var(--tblr-border-color);padding:var(--tblr-signature-padding);border-radius:var(--tblr-border-radius)}.signature-canvas{border:var(--tblr-border-width) dashed var(--tblr-border-color);border-radius:calc(var(--tblr-signature-border-radius) - var(--tblr-signature-padding));display:block;cursor:crosshair;width:100%}.clearfix::after{display:block;clear:both;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--tblr-border-width);min-height:1em;background-color:currentcolor;opacity:.16}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.visually-hidden *,.visually-hidden-focusable:not(:focus):not(:focus-within) *{overflow:hidden!important}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:sticky;top:0;z-index:1020}.sticky-bottom{position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:sticky;bottom:0;z-index:1020}}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--tblr-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--tblr-aspect-ratio:100%}.ratio-2x1{--tblr-aspect-ratio:50%}.ratio-1x2{--tblr-aspect-ratio:200%}.ratio-3x1{--tblr-aspect-ratio:33.3333333333%}.ratio-1x3{--tblr-aspect-ratio:300%}.ratio-4x1{--tblr-aspect-ratio:25%}.ratio-1x4{--tblr-aspect-ratio:400%}.ratio-4x3{--tblr-aspect-ratio:75%}.ratio-3x4{--tblr-aspect-ratio:133.3333333333%}.ratio-16x9{--tblr-aspect-ratio:56.25%}.ratio-9x16{--tblr-aspect-ratio:177.7777777778%}.ratio-21x9{--tblr-aspect-ratio:42.8571428571%}.ratio-9x21{--tblr-aspect-ratio:233.3333333333%}.focus-ring:focus{outline:0;box-shadow:var(--tblr-focus-ring-x,0) var(--tblr-focus-ring-y,0) var(--tblr-focus-ring-blur,0) var(--tblr-focus-ring-width) var(--tblr-focus-ring-color)}.bg-white-overlay{color:#fff;background-color:rgba(249,250,251,.24)}.bg-dark-overlay{color:#fff;background-color:rgba(31,41,55,.24)}.bg-cover{background-repeat:no-repeat;background-size:cover;background-position:center}.bg-primary{background-color:color-mix(in srgb,var(--tblr-primary) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-primary-lt{color:color-mix(in srgb,var(--tblr-primary) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-primary-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-primary{border-color:color-mix(in srgb,var(--tblr-primary) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-primary{--tblr-gradient-from:var(--tblr-primary)}.bg-gradient-to-primary{--tblr-gradient-to:var(--tblr-primary)}.bg-gradient-via-primary{--tblr-gradient-via:var(--tblr-primary);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-primary{color:#f9fafb!important;background-color:RGBA(var(--tblr-primary-rgb),var(--tblr-bg-opacity,1))!important}.link-primary{color:color-mix(in srgb,var(--tblr-primary) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-primary) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-primary:focus,.link-primary:hover{color:RGBA(5,89,167,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(5,89,167,var(--tblr-link-underline-opacity,1))!important}.bg-secondary{background-color:color-mix(in srgb,var(--tblr-secondary) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-secondary-lt{color:color-mix(in srgb,var(--tblr-secondary) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-secondary-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-secondary{border-color:color-mix(in srgb,var(--tblr-secondary) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-secondary{--tblr-gradient-from:var(--tblr-secondary)}.bg-gradient-to-secondary{--tblr-gradient-to:var(--tblr-secondary)}.bg-gradient-via-secondary{--tblr-gradient-via:var(--tblr-secondary);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-secondary{color:#f9fafb!important;background-color:RGBA(var(--tblr-secondary-rgb),var(--tblr-bg-opacity,1))!important}.link-secondary{color:color-mix(in srgb,var(--tblr-secondary) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-secondary) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,91,102,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(86,91,102,var(--tblr-link-underline-opacity,1))!important}.bg-success{background-color:color-mix(in srgb,var(--tblr-success) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-success-lt{color:color-mix(in srgb,var(--tblr-success) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-success-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-success{border-color:color-mix(in srgb,var(--tblr-success) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-success{--tblr-gradient-from:var(--tblr-success)}.bg-gradient-to-success{--tblr-gradient-to:var(--tblr-success)}.bg-gradient-via-success{--tblr-gradient-via:var(--tblr-success);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-success{color:#f9fafb!important;background-color:RGBA(var(--tblr-success-rgb),var(--tblr-bg-opacity,1))!important}.link-success{color:color-mix(in srgb,var(--tblr-success) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-success) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-success:focus,.link-success:hover{color:RGBA(38,143,54,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(38,143,54,var(--tblr-link-underline-opacity,1))!important}.bg-info{background-color:color-mix(in srgb,var(--tblr-info) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-info-lt{color:color-mix(in srgb,var(--tblr-info) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-info-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-info{border-color:color-mix(in srgb,var(--tblr-info) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-info{--tblr-gradient-from:var(--tblr-info)}.bg-gradient-to-info{--tblr-gradient-to:var(--tblr-info)}.bg-gradient-via-info{--tblr-gradient-via:var(--tblr-info);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-info{color:#f9fafb!important;background-color:RGBA(var(--tblr-info-rgb),var(--tblr-bg-opacity,1))!important}.link-info{color:color-mix(in srgb,var(--tblr-info) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-info) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-info:focus,.link-info:hover{color:RGBA(53,122,180,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(53,122,180,var(--tblr-link-underline-opacity,1))!important}.bg-warning{background-color:color-mix(in srgb,var(--tblr-warning) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-warning-lt{color:color-mix(in srgb,var(--tblr-warning) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-warning-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-warning{border-color:color-mix(in srgb,var(--tblr-warning) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-warning{--tblr-gradient-from:var(--tblr-warning)}.bg-gradient-to-warning{--tblr-gradient-to:var(--tblr-warning)}.bg-gradient-via-warning{--tblr-gradient-via:var(--tblr-warning);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-warning{color:#f9fafb!important;background-color:RGBA(var(--tblr-warning-rgb),var(--tblr-bg-opacity,1))!important}.link-warning{color:color-mix(in srgb,var(--tblr-warning) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-warning) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-warning:focus,.link-warning:hover{color:RGBA(196,127,0,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(196,127,0,var(--tblr-link-underline-opacity,1))!important}.bg-danger{background-color:color-mix(in srgb,var(--tblr-danger) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-danger-lt{color:color-mix(in srgb,var(--tblr-danger) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-danger-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-danger{border-color:color-mix(in srgb,var(--tblr-danger) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-danger{--tblr-gradient-from:var(--tblr-danger)}.bg-gradient-to-danger{--tblr-gradient-to:var(--tblr-danger)}.bg-gradient-via-danger{--tblr-gradient-via:var(--tblr-danger);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-danger{color:#f9fafb!important;background-color:RGBA(var(--tblr-danger-rgb),var(--tblr-bg-opacity,1))!important}.link-danger{color:color-mix(in srgb,var(--tblr-danger) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-danger) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-danger:focus,.link-danger:hover{color:RGBA(171,46,46,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(171,46,46,var(--tblr-link-underline-opacity,1))!important}.bg-light{background-color:color-mix(in srgb,var(--tblr-light) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-light-lt{color:color-mix(in srgb,var(--tblr-light) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-light-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-light{border-color:color-mix(in srgb,var(--tblr-light) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-light{--tblr-gradient-from:var(--tblr-light)}.bg-gradient-to-light{--tblr-gradient-to:var(--tblr-light)}.bg-gradient-via-light{--tblr-gradient-via:var(--tblr-light);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-light{color:#1f2937!important;background-color:RGBA(var(--tblr-light-rgb),var(--tblr-bg-opacity,1))!important}.link-light{color:color-mix(in srgb,var(--tblr-light) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-light) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-light:focus,.link-light:hover{color:RGBA(250,251,252,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(250,251,252,var(--tblr-link-underline-opacity,1))!important}.bg-dark{background-color:color-mix(in srgb,var(--tblr-dark) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-dark-lt{color:color-mix(in srgb,var(--tblr-dark) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-dark-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-dark{border-color:color-mix(in srgb,var(--tblr-dark) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-dark{--tblr-gradient-from:var(--tblr-dark)}.bg-gradient-to-dark{--tblr-gradient-to:var(--tblr-dark)}.bg-gradient-via-dark{--tblr-gradient-via:var(--tblr-dark);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-dark{color:#f9fafb!important;background-color:RGBA(var(--tblr-dark-rgb),var(--tblr-bg-opacity,1))!important}.link-dark{color:color-mix(in srgb,var(--tblr-dark) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-dark) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-dark:focus,.link-dark:hover{color:RGBA(25,33,44,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(25,33,44,var(--tblr-link-underline-opacity,1))!important}.bg-muted{background-color:color-mix(in srgb,var(--tblr-muted) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-muted-lt{color:color-mix(in srgb,var(--tblr-muted) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-muted-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-muted{border-color:color-mix(in srgb,var(--tblr-muted) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-muted{--tblr-gradient-from:var(--tblr-muted)}.bg-gradient-to-muted{--tblr-gradient-to:var(--tblr-muted)}.bg-gradient-via-muted{--tblr-gradient-via:var(--tblr-muted);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-muted{color:#f9fafb!important;background-color:RGBA(var(--tblr-muted-rgb),var(--tblr-bg-opacity,1))!important}.link-muted{color:color-mix(in srgb,var(--tblr-muted) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-muted) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-muted:focus,.link-muted:hover{color:RGBA(86,91,102,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(86,91,102,var(--tblr-link-underline-opacity,1))!important}.bg-blue{background-color:color-mix(in srgb,var(--tblr-blue) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-blue-lt{color:color-mix(in srgb,var(--tblr-blue) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-blue-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-blue{border-color:color-mix(in srgb,var(--tblr-blue) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-blue{--tblr-gradient-from:var(--tblr-blue)}.bg-gradient-to-blue{--tblr-gradient-to:var(--tblr-blue)}.bg-gradient-via-blue{--tblr-gradient-via:var(--tblr-blue);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-blue{color:#f9fafb!important;background-color:RGBA(var(--tblr-blue-rgb),var(--tblr-bg-opacity,1))!important}.link-blue{color:color-mix(in srgb,var(--tblr-blue) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-blue) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-blue:focus,.link-blue:hover{color:RGBA(5,89,167,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(5,89,167,var(--tblr-link-underline-opacity,1))!important}.bg-azure{background-color:color-mix(in srgb,var(--tblr-azure) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-azure-lt{color:color-mix(in srgb,var(--tblr-azure) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-azure-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-azure{border-color:color-mix(in srgb,var(--tblr-azure) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-azure{--tblr-gradient-from:var(--tblr-azure)}.bg-gradient-to-azure{--tblr-gradient-to:var(--tblr-azure)}.bg-gradient-via-azure{--tblr-gradient-via:var(--tblr-azure);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-azure{color:#f9fafb!important;background-color:RGBA(var(--tblr-azure-rgb),var(--tblr-bg-opacity,1))!important}.link-azure{color:color-mix(in srgb,var(--tblr-azure) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-azure) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-azure:focus,.link-azure:hover{color:RGBA(53,122,180,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(53,122,180,var(--tblr-link-underline-opacity,1))!important}.bg-indigo{background-color:color-mix(in srgb,var(--tblr-indigo) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-indigo-lt{color:color-mix(in srgb,var(--tblr-indigo) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-indigo-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-indigo{border-color:color-mix(in srgb,var(--tblr-indigo) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-indigo{--tblr-gradient-from:var(--tblr-indigo)}.bg-gradient-to-indigo{--tblr-gradient-to:var(--tblr-indigo)}.bg-gradient-via-indigo{--tblr-gradient-via:var(--tblr-indigo);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-indigo{color:#f9fafb!important;background-color:RGBA(var(--tblr-indigo-rgb),var(--tblr-bg-opacity,1))!important}.link-indigo{color:color-mix(in srgb,var(--tblr-indigo) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-indigo) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-indigo:focus,.link-indigo:hover{color:RGBA(53,79,188,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(53,79,188,var(--tblr-link-underline-opacity,1))!important}.bg-purple{background-color:color-mix(in srgb,var(--tblr-purple) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-purple-lt{color:color-mix(in srgb,var(--tblr-purple) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-purple-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-purple{border-color:color-mix(in srgb,var(--tblr-purple) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-purple{--tblr-gradient-from:var(--tblr-purple)}.bg-gradient-to-purple{--tblr-gradient-to:var(--tblr-purple)}.bg-gradient-via-purple{--tblr-gradient-via:var(--tblr-purple);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-purple{color:#f9fafb!important;background-color:RGBA(var(--tblr-purple-rgb),var(--tblr-bg-opacity,1))!important}.link-purple{color:color-mix(in srgb,var(--tblr-purple) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-purple) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-purple:focus,.link-purple:hover{color:RGBA(139,50,161,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(139,50,161,var(--tblr-link-underline-opacity,1))!important}.bg-pink{background-color:color-mix(in srgb,var(--tblr-pink) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-pink-lt{color:color-mix(in srgb,var(--tblr-pink) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-pink-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-pink{border-color:color-mix(in srgb,var(--tblr-pink) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-pink{--tblr-gradient-from:var(--tblr-pink)}.bg-gradient-to-pink{--tblr-gradient-to:var(--tblr-pink)}.bg-gradient-via-pink{--tblr-gradient-via:var(--tblr-pink);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-pink{color:#f9fafb!important;background-color:RGBA(var(--tblr-pink-rgb),var(--tblr-bg-opacity,1))!important}.link-pink{color:color-mix(in srgb,var(--tblr-pink) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-pink) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-pink:focus,.link-pink:hover{color:RGBA(171,41,86,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(171,41,86,var(--tblr-link-underline-opacity,1))!important}.bg-red{background-color:color-mix(in srgb,var(--tblr-red) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-red-lt{color:color-mix(in srgb,var(--tblr-red) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-red-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-red{border-color:color-mix(in srgb,var(--tblr-red) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-red{--tblr-gradient-from:var(--tblr-red)}.bg-gradient-to-red{--tblr-gradient-to:var(--tblr-red)}.bg-gradient-via-red{--tblr-gradient-via:var(--tblr-red);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-red{color:#f9fafb!important;background-color:RGBA(var(--tblr-red-rgb),var(--tblr-bg-opacity,1))!important}.link-red{color:color-mix(in srgb,var(--tblr-red) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-red) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-red:focus,.link-red:hover{color:RGBA(171,46,46,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(171,46,46,var(--tblr-link-underline-opacity,1))!important}.bg-orange{background-color:color-mix(in srgb,var(--tblr-orange) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-orange-lt{color:color-mix(in srgb,var(--tblr-orange) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-orange-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-orange{border-color:color-mix(in srgb,var(--tblr-orange) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-orange{--tblr-gradient-from:var(--tblr-orange)}.bg-gradient-to-orange{--tblr-gradient-to:var(--tblr-orange)}.bg-gradient-via-orange{--tblr-gradient-via:var(--tblr-orange);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-orange{color:#f9fafb!important;background-color:RGBA(var(--tblr-orange-rgb),var(--tblr-bg-opacity,1))!important}.link-orange{color:color-mix(in srgb,var(--tblr-orange) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-orange) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-orange:focus,.link-orange:hover{color:RGBA(198,82,6,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(198,82,6,var(--tblr-link-underline-opacity,1))!important}.bg-yellow{background-color:color-mix(in srgb,var(--tblr-yellow) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-yellow-lt{color:color-mix(in srgb,var(--tblr-yellow) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-yellow-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-yellow{border-color:color-mix(in srgb,var(--tblr-yellow) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-yellow{--tblr-gradient-from:var(--tblr-yellow)}.bg-gradient-to-yellow{--tblr-gradient-to:var(--tblr-yellow)}.bg-gradient-via-yellow{--tblr-gradient-via:var(--tblr-yellow);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-yellow{color:#f9fafb!important;background-color:RGBA(var(--tblr-yellow-rgb),var(--tblr-bg-opacity,1))!important}.link-yellow{color:color-mix(in srgb,var(--tblr-yellow) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-yellow) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-yellow:focus,.link-yellow:hover{color:RGBA(196,127,0,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(196,127,0,var(--tblr-link-underline-opacity,1))!important}.bg-lime{background-color:color-mix(in srgb,var(--tblr-lime) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-lime-lt{color:color-mix(in srgb,var(--tblr-lime) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-lime-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-lime{border-color:color-mix(in srgb,var(--tblr-lime) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-lime{--tblr-gradient-from:var(--tblr-lime)}.bg-gradient-to-lime{--tblr-gradient-to:var(--tblr-lime)}.bg-gradient-via-lime{--tblr-gradient-via:var(--tblr-lime);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-lime{color:#f9fafb!important;background-color:RGBA(var(--tblr-lime-rgb),var(--tblr-bg-opacity,1))!important}.link-lime{color:color-mix(in srgb,var(--tblr-lime) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-lime) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-lime:focus,.link-lime:hover{color:RGBA(93,147,18,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(93,147,18,var(--tblr-link-underline-opacity,1))!important}.bg-green{background-color:color-mix(in srgb,var(--tblr-green) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-green-lt{color:color-mix(in srgb,var(--tblr-green) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-green-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-green{border-color:color-mix(in srgb,var(--tblr-green) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-green{--tblr-gradient-from:var(--tblr-green)}.bg-gradient-to-green{--tblr-gradient-to:var(--tblr-green)}.bg-gradient-via-green{--tblr-gradient-via:var(--tblr-green);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-green{color:#f9fafb!important;background-color:RGBA(var(--tblr-green-rgb),var(--tblr-bg-opacity,1))!important}.link-green{color:color-mix(in srgb,var(--tblr-green) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-green) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-green:focus,.link-green:hover{color:RGBA(38,143,54,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(38,143,54,var(--tblr-link-underline-opacity,1))!important}.bg-teal{background-color:color-mix(in srgb,var(--tblr-teal) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-teal-lt{color:color-mix(in srgb,var(--tblr-teal) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-teal-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-teal{border-color:color-mix(in srgb,var(--tblr-teal) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-teal{--tblr-gradient-from:var(--tblr-teal)}.bg-gradient-to-teal{--tblr-gradient-to:var(--tblr-teal)}.bg-gradient-via-teal{--tblr-gradient-via:var(--tblr-teal);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-teal{color:#f9fafb!important;background-color:RGBA(var(--tblr-teal-rgb),var(--tblr-bg-opacity,1))!important}.link-teal{color:color-mix(in srgb,var(--tblr-teal) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-teal) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-teal:focus,.link-teal:hover{color:RGBA(10,133,96,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(10,133,96,var(--tblr-link-underline-opacity,1))!important}.bg-cyan{background-color:color-mix(in srgb,var(--tblr-cyan) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-cyan-lt{color:color-mix(in srgb,var(--tblr-cyan) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-cyan-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-cyan{border-color:color-mix(in srgb,var(--tblr-cyan) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-cyan{--tblr-gradient-from:var(--tblr-cyan)}.bg-gradient-to-cyan{--tblr-gradient-to:var(--tblr-cyan)}.bg-gradient-via-cyan{--tblr-gradient-via:var(--tblr-cyan);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-cyan{color:#f9fafb!important;background-color:RGBA(var(--tblr-cyan-rgb),var(--tblr-bg-opacity,1))!important}.link-cyan{color:color-mix(in srgb,var(--tblr-cyan) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-cyan) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-cyan:focus,.link-cyan:hover{color:RGBA(18,130,147,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(18,130,147,var(--tblr-link-underline-opacity,1))!important}.bg-white{background-color:color-mix(in srgb,var(--tblr-white) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.bg-white-lt{color:color-mix(in srgb,var(--tblr-white) calc(var(--tblr-text-opacity,1) * 100%),transparent)!important;background-color:color-mix(in srgb,var(--tblr-white-lt) calc(var(--tblr-bg-opacity,1) * 100%),transparent)!important}.border-white{border-color:color-mix(in srgb,var(--tblr-white) calc(var(--tblr-border-opacity,1) * 100%),transparent)!important}.bg-gradient-from-white{--tblr-gradient-from:var(--tblr-white)}.bg-gradient-to-white{--tblr-gradient-to:var(--tblr-white)}.bg-gradient-via-white{--tblr-gradient-via:var(--tblr-white);--tblr-gradient-stops:var(--tblr-gradient-from, transparent),var(--tblr-gradient-via, transparent),var(--tblr-gradient-to, transparent)}.text-bg-white{color:#1f2937!important;background-color:RGBA(var(--tblr-white-rgb),var(--tblr-bg-opacity,1))!important}.link-white{color:color-mix(in srgb,var(--tblr-white) calc(var(--tblr-link-opacity,1) * 100%),transparent)!important;text-decoration-color:color-mix(in srgb,var(--tblr-white) calc(var(--tblr-link-underline-opacity,1) * 100%),transparent)!important}.link-white:focus,.link-white:hover{color:RGBA(255,255,255,var(--tblr-link-opacity,1))!important;text-decoration-color:RGBA(255,255,255,var(--tblr-link-underline-opacity,1))!important}.text-primary{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-primary) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-primary-fg{color:var(--tblr-primary-fg)!important}.text-secondary{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-secondary) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-secondary-fg{color:var(--tblr-secondary-fg)!important}.text-success{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-success) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-success-fg{color:var(--tblr-success-fg)!important}.text-info{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-info) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-info-fg{color:var(--tblr-info-fg)!important}.text-warning{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-warning) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-warning-fg{color:var(--tblr-warning-fg)!important}.text-danger{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-danger) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-danger-fg{color:var(--tblr-danger-fg)!important}.text-light{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-light) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-light-fg{color:var(--tblr-light-fg)!important}.text-dark{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-dark) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-dark-fg{color:var(--tblr-dark-fg)!important}.text-muted{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-muted) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-muted-fg{color:var(--tblr-muted-fg)!important}.text-blue{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-blue) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-blue-fg{color:var(--tblr-blue-fg)!important}.text-azure{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-azure) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-azure-fg{color:var(--tblr-azure-fg)!important}.text-indigo{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-indigo) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-indigo-fg{color:var(--tblr-indigo-fg)!important}.text-purple{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-purple) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-purple-fg{color:var(--tblr-purple-fg)!important}.text-pink{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-pink) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-pink-fg{color:var(--tblr-pink-fg)!important}.text-red{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-red) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-red-fg{color:var(--tblr-red-fg)!important}.text-orange{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-orange) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-orange-fg{color:var(--tblr-orange-fg)!important}.text-yellow{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-yellow) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-yellow-fg{color:var(--tblr-yellow-fg)!important}.text-lime{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-lime) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-lime-fg{color:var(--tblr-lime-fg)!important}.text-green{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-green) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-green-fg{color:var(--tblr-green-fg)!important}.text-teal{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-teal) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-teal-fg{color:var(--tblr-teal-fg)!important}.text-cyan{--tblr-text-opacity:1;color:color-mix(in srgb,var(--tblr-cyan) calc(var(--tblr-text-opacity) * 100%),transparent)!important}.text-cyan-fg{color:var(--tblr-cyan-fg)!important}.bg-gray-50{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-50) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-50-fg{color:var(--tblr-gray-50-fg)!important}.bg-gray-100{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-100) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-100-fg{color:var(--tblr-gray-100-fg)!important}.bg-gray-200{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-200) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-200-fg{color:var(--tblr-gray-200-fg)!important}.bg-gray-300{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-300) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-300-fg{color:var(--tblr-gray-300-fg)!important}.bg-gray-400{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-400) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-400-fg{color:var(--tblr-gray-400-fg)!important}.bg-gray-500{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-500) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-500-fg{color:var(--tblr-gray-500-fg)!important}.bg-gray-600{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-600) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-600-fg{color:var(--tblr-gray-600-fg)!important}.bg-gray-700{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-700) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-700-fg{color:var(--tblr-gray-700-fg)!important}.bg-gray-800{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-800) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-800-fg{color:var(--tblr-gray-800-fg)!important}.bg-gray-900{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-900) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-900-fg{color:var(--tblr-gray-900-fg)!important}.bg-gray-950{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-gray-950) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-gray-950-fg{color:var(--tblr-gray-950-fg)!important}.bg-x{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-x) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-x-fg{color:var(--tblr-x-fg)!important}.bg-facebook{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-facebook) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-facebook-fg{color:var(--tblr-facebook-fg)!important}.bg-twitter{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-twitter) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-twitter-fg{color:var(--tblr-twitter-fg)!important}.bg-linkedin{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-linkedin) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-linkedin-fg{color:var(--tblr-linkedin-fg)!important}.bg-google{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-google) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-google-fg{color:var(--tblr-google-fg)!important}.bg-youtube{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-youtube) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-youtube-fg{color:var(--tblr-youtube-fg)!important}.bg-vimeo{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-vimeo) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-vimeo-fg{color:var(--tblr-vimeo-fg)!important}.bg-dribbble{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-dribbble) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-dribbble-fg{color:var(--tblr-dribbble-fg)!important}.bg-github{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-github) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-github-fg{color:var(--tblr-github-fg)!important}.bg-instagram{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-instagram) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-instagram-fg{color:var(--tblr-instagram-fg)!important}.bg-pinterest{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-pinterest) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-pinterest-fg{color:var(--tblr-pinterest-fg)!important}.bg-vk{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-vk) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-vk-fg{color:var(--tblr-vk-fg)!important}.bg-rss{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-rss) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-rss-fg{color:var(--tblr-rss-fg)!important}.bg-flickr{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-flickr) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-flickr-fg{color:var(--tblr-flickr-fg)!important}.bg-bitbucket{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-bitbucket) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-bitbucket-fg{color:var(--tblr-bitbucket-fg)!important}.bg-tabler{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-tabler) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.text-tabler-fg{color:var(--tblr-tabler-fg)!important}.bg-inverted{--tblr-bg-opacity:1;background-color:color-mix(in srgb,var(--tblr-bg-surface-inverted) calc(var(--tblr-bg-opacity) * 100%),transparent)!important}.bg-surface{background-color:var(--tblr-bg-surface)!important}.bg-surface-secondary{background-color:var(--tblr-bg-surface-secondary)!important}.bg-surface-tertiary{background-color:var(--tblr-bg-surface-tertiary)!important}.bg-surface-backdrop{background-color:color-mix(in srgb,var(--tblr-gray-800) 24%,transparent)!important}.scrollable{overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch}.scrollable.hover{overflow-y:hidden}.scrollable.hover>*{margin-top:-1px}.scrollable.hover:active,.scrollable.hover:focus,.scrollable.hover:hover{overflow:visible;overflow-y:auto}.touch .scrollable{overflow-y:auto!important}.scroll-x,.scroll-y{overflow:hidden;-webkit-overflow-scrolling:touch}.scroll-y{overflow-y:auto}.scroll-x{overflow-x:auto}.no-scroll{overflow:hidden}.w-0{width:0!important}.h-0{height:0!important}.w-1{width:.25rem!important}.h-1{height:.25rem!important}.w-2{width:.5rem!important}.h-2{height:.5rem!important}.w-3{width:1rem!important}.h-3{height:1rem!important}.w-4{width:1.5rem!important}.h-4{height:1.5rem!important}.w-5{width:2rem!important}.h-5{height:2rem!important}.w-6{width:2.5rem!important}.h-6{height:2.5rem!important}.w-auto{width:auto!important}.h-auto{height:auto!important}.w-px{width:1px!important}.h-px{height:1px!important}.w-full{width:100%!important}.h-full{height:100%!important}.opacity-0{opacity:calc(0 / 100)!important}.opacity-5{opacity:calc(5 / 100)!important}.opacity-10{opacity:calc(10 / 100)!important}.opacity-15{opacity:calc(15 / 100)!important}.opacity-20{opacity:calc(20 / 100)!important}.opacity-25{opacity:calc(25 / 100)!important}.opacity-30{opacity:calc(30 / 100)!important}.opacity-35{opacity:calc(35 / 100)!important}.opacity-40{opacity:calc(40 / 100)!important}.opacity-45{opacity:calc(45 / 100)!important}.opacity-50{opacity:calc(50 / 100)!important}.opacity-55{opacity:calc(55 / 100)!important}.opacity-60{opacity:calc(60 / 100)!important}.opacity-65{opacity:calc(65 / 100)!important}.opacity-70{opacity:calc(70 / 100)!important}.opacity-75{opacity:calc(75 / 100)!important}.opacity-80{opacity:calc(80 / 100)!important}.opacity-85{opacity:calc(85 / 100)!important}.opacity-90{opacity:calc(90 / 100)!important}.opacity-95{opacity:calc(95 / 100)!important}.opacity-100{opacity:calc(100 / 100)!important}.hover-shadow-sm:hover{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.hover-shadow:hover{box-shadow:rgba(var(--tblr-body-color-rgb),.04) 0 2px 4px 0!important}.hover-shadow-lg:hover{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.hover-shadow-none:hover{box-shadow:none!important}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.hover-elevate-down,.hover-elevate-up,.hover-rotate-end,.hover-rotate-start,.hover-scale{transition:transform .3s ease}.hover-elevate-down:hover,.hover-elevate-up:hover,.hover-rotate-end:hover,.hover-rotate-start:hover,.hover-scale:hover{will-change:transform}.hover-elevate-up:hover{transform:translateY(-4px)}.hover-elevate-down:hover{transform:translateY(4px)}.hover-scale:hover{transform:scale(1.1)}.hover-rotate-end:hover{transform:rotate(4deg)}.hover-rotate-start:hover{transform:rotate(-4deg)} +/*# sourceMappingURL=tabler.min.css.map */ \ No newline at end of file diff --git a/public/assets/vendor/tabler/tabler.min.js b/public/assets/vendor/tabler/tabler.min.js new file mode 100644 index 0000000..5be114e --- /dev/null +++ b/public/assets/vendor/tabler/tabler.min.js @@ -0,0 +1,13 @@ +/*! + * Tabler v1.4.0 (https://tabler.io) + * Copyright 2018-2025 The Tabler Authors + * Copyright 2018-2025 codecalm.net Paweł Kuna + * Licensed under MIT (https://github.com/tabler/tabler/blob/master/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).tabler={})}(this,function(t){"use strict";const e=document.querySelectorAll('[data-bs-toggle="autosize"]');e.length&&e.forEach(function(t){window.autosize&&window.autosize(t)});const i=document.querySelectorAll("[data-countup]");i.length&&i.forEach(function(t){let e={};try{const i=t.getAttribute("data-countup")?JSON.parse(t.getAttribute("data-countup")):{};e=Object.assign({enableScrollSpy:!0},i)}catch(t){}const i=parseInt(t.innerHTML,10);if(window.countUp&&window.countUp.CountUp){const n=new window.countUp.CountUp(t,i,e);n.error||n.start()}}),[].slice.call(document.querySelectorAll("[data-mask]")).map(function(t){window.IMask&&new window.IMask(t,{mask:t.dataset.mask,lazy:"true"===t.dataset["mask-visible"]})});var n="top",s="bottom",o="right",r="left",a="auto",l=[n,s,o,r],c="start",h="end",u="clippingParents",d="viewport",f="popper",p="reference",m=l.reduce(function(t,e){return t.concat([e+"-"+c,e+"-"+h])},[]),g=[].concat(l,[a]).reduce(function(t,e){return t.concat([e,e+"-"+c,e+"-"+h])},[]),_="beforeRead",b="read",v="afterRead",y="beforeMain",w="main",A="afterMain",E="beforeWrite",T="write",C="afterWrite",O=[_,b,v,y,w,A,E,T,C];function x(t){return t?(t.nodeName||"").toLowerCase():null}function k(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function S(t){return t instanceof k(t).Element||t instanceof Element}function L(t){return t instanceof k(t).HTMLElement||t instanceof HTMLElement}function $(t){return"undefined"!=typeof ShadowRoot&&(t instanceof k(t).ShadowRoot||t instanceof ShadowRoot)}const D={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach(function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];L(s)&&x(s)&&(Object.assign(s.style,i),Object.keys(n).forEach(function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)}))})},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach(function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce(function(t,e){return t[e]="",t},{});L(n)&&x(n)&&(Object.assign(n.style,o),Object.keys(s).forEach(function(t){n.removeAttribute(t)}))})}},requires:["computeStyles"]};function I(t){return t.split("-")[0]}var P=Math.max,N=Math.min,M=Math.round;function j(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map(function(t){return t.brand+"/"+t.version}).join(" "):navigator.userAgent}function F(){return!/^((?!chrome|android).)*safari/i.test(j())}function H(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&L(t)&&(s=t.offsetWidth>0&&M(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&M(n.height)/t.offsetHeight||1);var r=(S(t)?k(t):window).visualViewport,a=!F()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,u=n.height/o;return{width:h,height:u,top:c,right:l+h,bottom:c+u,left:l,x:l,y:c}}function z(t){var e=H(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function B(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&$(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function W(t){return k(t).getComputedStyle(t)}function q(t){return["table","td","th"].indexOf(x(t))>=0}function R(t){return((S(t)?t.ownerDocument:t.document)||window.document).documentElement}function V(t){return"html"===x(t)?t:t.assignedSlot||t.parentNode||($(t)?t.host:null)||R(t)}function U(t){return L(t)&&"fixed"!==W(t).position?t.offsetParent:null}function K(t){for(var e=k(t),i=U(t);i&&q(i)&&"static"===W(i).position;)i=U(i);return i&&("html"===x(i)||"body"===x(i)&&"static"===W(i).position)?e:i||function(t){var e=/firefox/i.test(j());if(/Trident/i.test(j())&&L(t)&&"fixed"===W(t).position)return null;var i=V(t);for($(i)&&(i=i.host);L(i)&&["html","body"].indexOf(x(i))<0;){var n=W(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Q(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function X(t,e,i){return P(t,N(e,i))}function Y(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function G(t,e){return e.reduce(function(e,i){return e[i]=t,e},{})}const J={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,a=t.name,c=t.options,h=i.elements.arrow,u=i.modifiersData.popperOffsets,d=I(i.placement),f=Q(d),p=[r,o].indexOf(d)>=0?"height":"width";if(h&&u){var m=function(t,e){return Y("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:G(t,l))}(c.padding,i),g=z(h),_="y"===f?n:r,b="y"===f?s:o,v=i.rects.reference[p]+i.rects.reference[f]-u[f]-i.rects.popper[p],y=u[f]-i.rects.reference[f],w=K(h),A=w?"y"===f?w.clientHeight||0:w.clientWidth||0:0,E=v/2-y/2,T=m[_],C=A-g[p]-m[b],O=A/2-g[p]/2+E,x=X(T,O,C),k=f;i.modifiersData[a]=((e={})[k]=x,e.centerOffset=x-O,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&B(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Z(t){return t.split("-")[1]}var tt={top:"auto",right:"auto",bottom:"auto",left:"auto"};function et(t){var e,i=t.popper,a=t.popperRect,l=t.placement,c=t.variation,u=t.offsets,d=t.position,f=t.gpuAcceleration,p=t.adaptive,m=t.roundOffsets,g=t.isFixed,_=u.x,b=void 0===_?0:_,v=u.y,y=void 0===v?0:v,w="function"==typeof m?m({x:b,y:y}):{x:b,y:y};b=w.x,y=w.y;var A=u.hasOwnProperty("x"),E=u.hasOwnProperty("y"),T=r,C=n,O=window;if(p){var x=K(i),S="clientHeight",L="clientWidth";x===k(i)&&"static"!==W(x=R(i)).position&&"absolute"===d&&(S="scrollHeight",L="scrollWidth"),(l===n||(l===r||l===o)&&c===h)&&(C=s,y-=(g&&x===O&&O.visualViewport?O.visualViewport.height:x[S])-a.height,y*=f?1:-1),l!==r&&(l!==n&&l!==s||c!==h)||(T=o,b-=(g&&x===O&&O.visualViewport?O.visualViewport.width:x[L])-a.width,b*=f?1:-1)}var $,D=Object.assign({position:d},p&&tt),I=!0===m?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:M(i*s)/s||0,y:M(n*s)/s||0}}({x:b,y:y},k(i)):{x:b,y:y};return b=I.x,y=I.y,f?Object.assign({},D,(($={})[C]=E?"0":"",$[T]=A?"0":"",$.transform=(O.devicePixelRatio||1)<=1?"translate("+b+"px, "+y+"px)":"translate3d("+b+"px, "+y+"px, 0)",$)):Object.assign({},D,((e={})[C]=E?y+"px":"",e[T]=A?b+"px":"",e.transform="",e))}const it={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:I(e.placement),variation:Z(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,et(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,et(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var nt={passive:!0};const st={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=k(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach(function(t){t.addEventListener("scroll",i.update,nt)}),a&&l.addEventListener("resize",i.update,nt),function(){o&&c.forEach(function(t){t.removeEventListener("scroll",i.update,nt)}),a&&l.removeEventListener("resize",i.update,nt)}},data:{}};var ot={left:"right",right:"left",bottom:"top",top:"bottom"};function rt(t){return t.replace(/left|right|bottom|top/g,function(t){return ot[t]})}var at={start:"end",end:"start"};function lt(t){return t.replace(/start|end/g,function(t){return at[t]})}function ct(t){var e=k(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function ht(t){return H(R(t)).left+ct(t).scrollLeft}function ut(t){var e=W(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function dt(t){return["html","body","#document"].indexOf(x(t))>=0?t.ownerDocument.body:L(t)&&ut(t)?t:dt(V(t))}function ft(t,e){var i;void 0===e&&(e=[]);var n=dt(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=k(n),r=s?[o].concat(o.visualViewport||[],ut(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(ft(V(r)))}function pt(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function mt(t,e,i){return e===d?pt(function(t,e){var i=k(t),n=R(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=F();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+ht(t),y:l}}(t,i)):S(e)?function(t,e){var i=H(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):pt(function(t){var e,i=R(t),n=ct(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=P(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=P(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+ht(t),l=-n.scrollTop;return"rtl"===W(s||i).direction&&(a+=P(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(R(t)))}function gt(t){var e,i=t.reference,a=t.element,l=t.placement,u=l?I(l):null,d=l?Z(l):null,f=i.x+i.width/2-a.width/2,p=i.y+i.height/2-a.height/2;switch(u){case n:e={x:f,y:i.y-a.height};break;case s:e={x:f,y:i.y+i.height};break;case o:e={x:i.x+i.width,y:p};break;case r:e={x:i.x-a.width,y:p};break;default:e={x:i.x,y:i.y}}var m=u?Q(u):null;if(null!=m){var g="y"===m?"height":"width";switch(d){case c:e[m]=e[m]-(i[g]/2-a[g]/2);break;case h:e[m]=e[m]+(i[g]/2-a[g]/2)}}return e}function _t(t,e){void 0===e&&(e={});var i=e,r=i.placement,a=void 0===r?t.placement:r,c=i.strategy,h=void 0===c?t.strategy:c,m=i.boundary,g=void 0===m?u:m,_=i.rootBoundary,b=void 0===_?d:_,v=i.elementContext,y=void 0===v?f:v,w=i.altBoundary,A=void 0!==w&&w,E=i.padding,T=void 0===E?0:E,C=Y("number"!=typeof T?T:G(T,l)),O=y===f?p:f,k=t.rects.popper,$=t.elements[A?O:y],D=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=ft(V(t)),i=["absolute","fixed"].indexOf(W(t).position)>=0&&L(t)?K(t):t;return S(i)?e.filter(function(t){return S(t)&&B(t,i)&&"body"!==x(t)}):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce(function(e,i){var s=mt(t,i,n);return e.top=P(s.top,e.top),e.right=N(s.right,e.right),e.bottom=N(s.bottom,e.bottom),e.left=P(s.left,e.left),e},mt(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(S($)?$:$.contextElement||R(t.elements.popper),g,b,h),I=H(t.elements.reference),M=gt({reference:I,element:k,placement:a}),j=pt(Object.assign({},k,M)),F=y===f?j:I,z={top:D.top-F.top+C.top,bottom:F.bottom-D.bottom+C.bottom,left:D.left-F.left+C.left,right:F.right-D.right+C.right},q=t.modifiersData.offset;if(y===f&&q){var U=q[a];Object.keys(z).forEach(function(t){var e=[o,s].indexOf(t)>=0?1:-1,i=[n,s].indexOf(t)>=0?"y":"x";z[t]+=U[i]*e})}return z}function bt(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,c=i.allowedAutoPlacements,h=void 0===c?g:c,u=Z(n),d=u?a?m:m.filter(function(t){return Z(t)===u}):l,f=d.filter(function(t){return h.indexOf(t)>=0});0===f.length&&(f=d);var p=f.reduce(function(e,i){return e[i]=_t(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[I(i)],e},{});return Object.keys(p).sort(function(t,e){return p[t]-p[e]})}const vt={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,l=t.name;if(!e.modifiersData[l]._skip){for(var h=i.mainAxis,u=void 0===h||h,d=i.altAxis,f=void 0===d||d,p=i.fallbackPlacements,m=i.padding,g=i.boundary,_=i.rootBoundary,b=i.altBoundary,v=i.flipVariations,y=void 0===v||v,w=i.allowedAutoPlacements,A=e.options.placement,E=I(A),T=p||(E!==A&&y?function(t){if(I(t)===a)return[];var e=rt(t);return[lt(t),e,lt(e)]}(A):[rt(A)]),C=[A].concat(T).reduce(function(t,i){return t.concat(I(i)===a?bt(e,{placement:i,boundary:g,rootBoundary:_,padding:m,flipVariations:y,allowedAutoPlacements:w}):i)},[]),O=e.rects.reference,x=e.rects.popper,k=new Map,S=!0,L=C[0],$=0;$=0,j=M?"width":"height",F=_t(e,{placement:D,boundary:g,rootBoundary:_,altBoundary:b,padding:m}),H=M?N?o:r:N?s:n;O[j]>x[j]&&(H=rt(H));var z=rt(H),B=[];if(u&&B.push(F[P]<=0),f&&B.push(F[H]<=0,F[z]<=0),B.every(function(t){return t})){L=D,S=!1;break}k.set(D,B)}if(S)for(var W=function(t){var e=C.find(function(e){var i=k.get(e);if(i)return i.slice(0,t).every(function(t){return t})});if(e)return L=e,"break"},q=y?3:1;q>0&&"break"!==W(q);q--);e.placement!==L&&(e.modifiersData[l]._skip=!0,e.placement=L,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function yt(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function wt(t){return[n,o,s,r].some(function(e){return t[e]>=0})}const At={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=_t(e,{elementContext:"reference"}),a=_t(e,{altBoundary:!0}),l=yt(r,n),c=yt(a,s,o),h=wt(l),u=wt(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:u},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":u})}},Et={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,s=t.name,a=i.offset,l=void 0===a?[0,0]:a,c=g.reduce(function(t,i){return t[i]=function(t,e,i){var s=I(t),a=[r,n].indexOf(s)>=0?-1:1,l="function"==typeof i?i(Object.assign({},e,{placement:t})):i,c=l[0],h=l[1];return c=c||0,h=(h||0)*a,[r,o].indexOf(s)>=0?{x:h,y:c}:{x:c,y:h}}(i,e.rects,l),t},{}),h=c[e.placement],u=h.x,d=h.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=u,e.modifiersData.popperOffsets.y+=d),e.modifiersData[s]=c}},Tt={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=gt({reference:e.rects.reference,element:e.rects.popper,placement:e.placement})},data:{}},Ct={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,a=t.name,l=i.mainAxis,h=void 0===l||l,u=i.altAxis,d=void 0!==u&&u,f=i.boundary,p=i.rootBoundary,m=i.altBoundary,g=i.padding,_=i.tether,b=void 0===_||_,v=i.tetherOffset,y=void 0===v?0:v,w=_t(e,{boundary:f,rootBoundary:p,padding:g,altBoundary:m}),A=I(e.placement),E=Z(e.placement),T=!E,C=Q(A),O="x"===C?"y":"x",x=e.modifiersData.popperOffsets,k=e.rects.reference,S=e.rects.popper,L="function"==typeof y?y(Object.assign({},e.rects,{placement:e.placement})):y,$="number"==typeof L?{mainAxis:L,altAxis:L}:Object.assign({mainAxis:0,altAxis:0},L),D=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,M={x:0,y:0};if(x){if(h){var j,F="y"===C?n:r,H="y"===C?s:o,B="y"===C?"height":"width",W=x[C],q=W+w[F],R=W-w[H],V=b?-S[B]/2:0,U=E===c?k[B]:S[B],Y=E===c?-S[B]:-k[B],G=e.elements.arrow,J=b&&G?z(G):{width:0,height:0},tt=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},et=tt[F],it=tt[H],nt=X(0,k[B],J[B]),st=T?k[B]/2-V-nt-et-$.mainAxis:U-nt-et-$.mainAxis,ot=T?-k[B]/2+V+nt+it+$.mainAxis:Y+nt+it+$.mainAxis,rt=e.elements.arrow&&K(e.elements.arrow),at=rt?"y"===C?rt.clientTop||0:rt.clientLeft||0:0,lt=null!=(j=null==D?void 0:D[C])?j:0,ct=W+ot-lt,ht=X(b?N(q,W+st-lt-at):q,W,b?P(R,ct):R);x[C]=ht,M[C]=ht-W}if(d){var ut,dt="x"===C?n:r,ft="x"===C?s:o,pt=x[O],mt="y"===O?"height":"width",gt=pt+w[dt],bt=pt-w[ft],vt=-1!==[n,r].indexOf(A),yt=null!=(ut=null==D?void 0:D[O])?ut:0,wt=vt?gt:pt-k[mt]-S[mt]-yt+$.altAxis,At=vt?pt+k[mt]+S[mt]-yt-$.altAxis:bt,Et=b&&vt?function(t,e,i){var n=X(t,e,i);return n>i?i:n}(wt,pt,At):X(b?wt:gt,pt,b?At:bt);x[O]=Et,M[O]=Et-pt}e.modifiersData[a]=M}},requiresIfExists:["offset"]};function Ot(t,e,i){void 0===i&&(i=!1);var n,s,o=L(e),r=L(e)&&function(t){var e=t.getBoundingClientRect(),i=M(e.width)/t.offsetWidth||1,n=M(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=R(e),l=H(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==x(e)||ut(a))&&(c=(n=e)!==k(n)&&L(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:ct(n)),L(e)?((h=H(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=ht(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function xt(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach(function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}}),n.push(t)}return t.forEach(function(t){e.set(t.name,t)}),t.forEach(function(t){i.has(t.name)||s(t)}),n}var kt={placement:"bottom",modifiers:[],strategy:"absolute"};function St(){for(var t=arguments.length,e=new Array(t),i=0;iNt.has(t)&&Nt.get(t).get(e)||null,remove(t,e){if(!Nt.has(t))return;const i=Nt.get(t);i.delete(e),0===i.size&&Nt.delete(t)}},jt="transitionend",Ft=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,(t,e)=>`#${CSS.escape(e)}`)),t),Ht=t=>null==t?`${t}`:Object.prototype.toString.call(t).match(/\s([a-z]+)/i)[1].toLowerCase(),zt=t=>{t.dispatchEvent(new Event(jt))},Bt=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),Wt=t=>Bt(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(Ft(t)):null,qt=t=>{if(!Bt(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},Rt=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),Vt=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?Vt(t.parentNode):null},Ut=()=>{},Kt=t=>{t.offsetHeight},Qt=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,Xt=[],Yt=()=>"rtl"===document.documentElement.dir,Gt=t=>{var e;e=()=>{const e=Qt();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(Xt.length||document.addEventListener("DOMContentLoaded",()=>{for(const t of Xt)t()}),Xt.push(e)):e()},Jt=(t,e=[],i=t)=>"function"==typeof t?t.call(...e):i,Zt=(t,e,i=!0)=>{if(!i)return void Jt(t);const n=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let s=!1;const o=({target:i})=>{i===e&&(s=!0,e.removeEventListener(jt,o),Jt(t))};e.addEventListener(jt,o),setTimeout(()=>{s||zt(e)},n)},te=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},ee=/[^.]*(?=\..*)\.|.*/,ie=/\..*/,ne=/::\d+$/,se={}; +/*! + * Bootstrap v5.3.7 (https://getbootstrap.com/) + * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */let oe=1;const re={mouseenter:"mouseover",mouseleave:"mouseout"},ae=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function le(t,e){return e&&`${e}::${oe++}`||t.uidEvent||oe++}function ce(t){const e=le(t);return t.uidEvent=e,se[e]=se[e]||{},se[e]}function he(t,e,i=null){return Object.values(t).find(t=>t.callable===e&&t.delegationSelector===i)}function ue(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=me(t);return ae.has(o)||(o=t),[n,s,o]}function de(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=ue(e,i,n);if(e in re){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=ce(t),c=l[a]||(l[a]={}),h=he(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const u=le(r,e.replace(ee,"")),d=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return _e(s,{delegateTarget:r}),n.oneOff&&ge.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return _e(n,{delegateTarget:t}),i.oneOff&&ge.off(t,n.type,e),e.apply(t,[n])}}(t,r);d.delegationSelector=o?i:null,d.callable=r,d.oneOff=s,d.uidEvent=u,c[u]=d,t.addEventListener(a,d,o)}function fe(t,e,i,n,s){const o=he(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function pe(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&fe(t,e,i,r.callable,r.delegationSelector)}function me(t){return t=t.replace(ie,""),re[t]||t}const ge={on(t,e,i,n){de(t,e,i,n,!1)},one(t,e,i,n){de(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=ue(e,i,n),a=r!==e,l=ce(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))pe(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(ne,"");a&&!e.includes(s)||fe(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;fe(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=Qt();let s=null,o=!0,r=!0,a=!1;e!==me(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=_e(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function _e(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function be(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function ve(t){return t.replace(/[A-Z]/g,t=>`-${t.toLowerCase()}`)}const ye={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${ve(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${ve(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter(t=>t.startsWith("bs")&&!t.startsWith("bsConfig"));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1),e[i]=be(t.dataset[n])}return e},getDataAttribute:(t,e)=>be(t.getAttribute(`data-bs-${ve(e)}`))};class we{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=Bt(e)?ye.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...Bt(e)?ye.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[i,n]of Object.entries(e)){const e=t[i],s=Bt(e)?"element":Ht(e);if(!new RegExp(n).test(s))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${i}" provided type "${s}" but expected type "${n}".`)}}}class Ae extends we{constructor(t,e){super(),(t=Wt(t))&&(this._element=t,this._config=this._getConfig(e),Mt.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Mt.remove(this._element,this.constructor.DATA_KEY),ge.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){Zt(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return Mt.get(Wt(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.7"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const Ee=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map(t=>Ft(t)).join(","):null},Te={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter(t=>t.matches(e)),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map(t=>`${t}:not([tabindex^="-"])`).join(",");return this.find(e,t).filter(t=>!Rt(t)&&qt(t))},getSelectorFromElement(t){const e=Ee(t);return e&&Te.findOne(e)?e:null},getElementFromSelector(t){const e=Ee(t);return e?Te.findOne(e):null},getMultipleElementsFromSelector(t){const e=Ee(t);return e?Te.find(e):[]}},Ce=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;ge.on(document,i,`[data-bs-dismiss="${n}"]`,function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),Rt(this))return;const s=Te.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()})},Oe=".bs.alert",xe=`close${Oe}`,ke=`closed${Oe}`;class Se extends Ae{static get NAME(){return"alert"}close(){if(ge.trigger(this._element,xe).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback(()=>this._destroyElement(),this._element,t)}_destroyElement(){this._element.remove(),ge.trigger(this._element,ke),this.dispose()}static jQueryInterface(t){return this.each(function(){const e=Se.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}})}}Ce(Se,"close"),Gt(Se);const Le='[data-bs-toggle="button"]';class $e extends Ae{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each(function(){const e=$e.getOrCreateInstance(this);"toggle"===t&&e[t]()})}}ge.on(document,"click.bs.button.data-api",Le,t=>{t.preventDefault();const e=t.target.closest(Le);$e.getOrCreateInstance(e).toggle()}),Gt($e);const De=".bs.swipe",Ie=`touchstart${De}`,Pe=`touchmove${De}`,Ne=`touchend${De}`,Me=`pointerdown${De}`,je=`pointerup${De}`,Fe={endCallback:null,leftCallback:null,rightCallback:null},He={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class ze extends we{constructor(t,e){super(),this._element=t,t&&ze.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return Fe}static get DefaultType(){return He}static get NAME(){return"swipe"}dispose(){ge.off(this._element,De)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),Jt(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&Jt(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(ge.on(this._element,Me,t=>this._start(t)),ge.on(this._element,je,t=>this._end(t)),this._element.classList.add("pointer-event")):(ge.on(this._element,Ie,t=>this._start(t)),ge.on(this._element,Pe,t=>this._move(t)),ge.on(this._element,Ne,t=>this._end(t)))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const Be=".bs.carousel",We=".data-api",qe="ArrowLeft",Re="ArrowRight",Ve="next",Ue="prev",Ke="left",Qe="right",Xe=`slide${Be}`,Ye=`slid${Be}`,Ge=`keydown${Be}`,Je=`mouseenter${Be}`,Ze=`mouseleave${Be}`,ti=`dragstart${Be}`,ei=`load${Be}${We}`,ii=`click${Be}${We}`,ni="carousel",si="active",oi=".active",ri=".carousel-item",ai=oi+ri,li={[qe]:Qe,[Re]:Ke},ci={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},hi={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class ui extends Ae{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=Te.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===ni&&this.cycle()}static get Default(){return ci}static get DefaultType(){return hi}static get NAME(){return"carousel"}next(){this._slide(Ve)}nextWhenVisible(){!document.hidden&&qt(this._element)&&this.next()}prev(){this._slide(Ue)}pause(){this._isSliding&&zt(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval(()=>this.nextWhenVisible(),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?ge.one(this._element,Ye,()=>this.cycle()):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void ge.one(this._element,Ye,()=>this.to(t));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?Ve:Ue;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&ge.on(this._element,Ge,t=>this._keydown(t)),"hover"===this._config.pause&&(ge.on(this._element,Je,()=>this.pause()),ge.on(this._element,Ze,()=>this._maybeEnableCycle())),this._config.touch&&ze.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of Te.find(".carousel-item img",this._element))ge.on(t,ti,t=>t.preventDefault());const t={leftCallback:()=>this._slide(this._directionToOrder(Ke)),rightCallback:()=>this._slide(this._directionToOrder(Qe)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(()=>this._maybeEnableCycle(),500+this._config.interval))}};this._swipeHelper=new ze(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=li[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=Te.findOne(oi,this._indicatorsElement);e.classList.remove(si),e.removeAttribute("aria-current");const i=Te.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(si),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===Ve,s=e||te(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>ge.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(Xe).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),Kt(s),i.classList.add(l),s.classList.add(l),this._queueCallback(()=>{s.classList.remove(l,c),s.classList.add(si),i.classList.remove(si,c,l),this._isSliding=!1,r(Ye)},i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return Te.findOne(ai,this._element)}_getItems(){return Te.find(ri,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return Yt()?t===Ke?Ue:Ve:t===Ke?Ve:Ue}_orderToDirection(t){return Yt()?t===Ue?Ke:Qe:t===Ue?Qe:Ke}static jQueryInterface(t){return this.each(function(){const e=ui.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)})}}ge.on(document,ii,"[data-bs-slide], [data-bs-slide-to]",function(t){const e=Te.getElementFromSelector(this);if(!e||!e.classList.contains(ni))return;t.preventDefault();const i=ui.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===ye.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())}),ge.on(window,ei,()=>{const t=Te.find('[data-bs-ride="carousel"]');for(const e of t)ui.getOrCreateInstance(e)}),Gt(ui);const di=".bs.collapse",fi=`show${di}`,pi=`shown${di}`,mi=`hide${di}`,gi=`hidden${di}`,_i=`click${di}.data-api`,bi="show",vi="collapse",yi="collapsing",wi=`:scope .${vi} .${vi}`,Ai='[data-bs-toggle="collapse"]',Ei={parent:null,toggle:!0},Ti={parent:"(null|element)",toggle:"boolean"};class Ci extends Ae{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=Te.find(Ai);for(const t of i){const e=Te.getSelectorFromElement(t),i=Te.find(e).filter(t=>t===this._element);null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ei}static get DefaultType(){return Ti}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter(t=>t!==this._element).map(t=>Ci.getOrCreateInstance(t,{toggle:!1}))),t.length&&t[0]._isTransitioning)return;if(ge.trigger(this._element,fi).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(vi),this._element.classList.add(yi),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback(()=>{this._isTransitioning=!1,this._element.classList.remove(yi),this._element.classList.add(vi,bi),this._element.style[e]="",ge.trigger(this._element,pi)},this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(ge.trigger(this._element,mi).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,Kt(this._element),this._element.classList.add(yi),this._element.classList.remove(vi,bi);for(const t of this._triggerArray){const e=Te.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback(()=>{this._isTransitioning=!1,this._element.classList.remove(yi),this._element.classList.add(vi),ge.trigger(this._element,gi)},this._element,!0)}_isShown(t=this._element){return t.classList.contains(bi)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=Wt(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ai);for(const e of t){const t=Te.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=Te.find(wi,this._config.parent);return Te.find(t,this._config.parent).filter(t=>!e.includes(t))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each(function(){const i=Ci.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}})}}ge.on(document,_i,Ai,function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of Te.getMultipleElementsFromSelector(this))Ci.getOrCreateInstance(t,{toggle:!1}).toggle()}),Gt(Ci);const Oi="dropdown",xi=".bs.dropdown",ki=".data-api",Si="ArrowUp",Li="ArrowDown",$i=`hide${xi}`,Di=`hidden${xi}`,Ii=`show${xi}`,Pi=`shown${xi}`,Ni=`click${xi}${ki}`,Mi=`keydown${xi}${ki}`,ji=`keyup${xi}${ki}`,Fi="show",Hi='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',zi=`${Hi}.${Fi}`,Bi=".dropdown-menu",Wi=Yt()?"top-end":"top-start",qi=Yt()?"top-start":"top-end",Ri=Yt()?"bottom-end":"bottom-start",Vi=Yt()?"bottom-start":"bottom-end",Ui=Yt()?"left-start":"right-start",Ki=Yt()?"right-start":"left-start",Qi={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},Xi={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class Yi extends Ae{constructor(t,e){super(t,e),this._popper=null,this._parent=this._element.parentNode,this._menu=Te.next(this._element,Bi)[0]||Te.prev(this._element,Bi)[0]||Te.findOne(Bi,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return Qi}static get DefaultType(){return Xi}static get NAME(){return Oi}toggle(){return this._isShown()?this.hide():this.show()}show(){if(Rt(this._element)||this._isShown())return;const t={relatedTarget:this._element};if(!ge.trigger(this._element,Ii,t).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const t of[].concat(...document.body.children))ge.on(t,"mouseover",Ut);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(Fi),this._element.classList.add(Fi),ge.trigger(this._element,Pi,t)}}hide(){if(Rt(this._element)||!this._isShown())return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){if(!ge.trigger(this._element,$i,t).defaultPrevented){if("ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ge.off(t,"mouseover",Ut);this._popper&&this._popper.destroy(),this._menu.classList.remove(Fi),this._element.classList.remove(Fi),this._element.setAttribute("aria-expanded","false"),ye.removeDataAttribute(this._menu,"popper"),ge.trigger(this._element,Di,t),this._element.focus()}}_getConfig(t){if("object"==typeof(t=super._getConfig(t)).reference&&!Bt(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Oi.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(){if(void 0===Pt)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org/docs/v2/)");let t=this._element;"parent"===this._config.reference?t=this._parent:Bt(this._config.reference)?t=Wt(this._config.reference):"object"==typeof this._config.reference&&(t=this._config.reference);const e=this._getPopperConfig();this._popper=It(t,this._menu,e)}_isShown(){return this._menu.classList.contains(Fi)}_getPlacement(){const t=this._parent;if(t.classList.contains("dropend"))return Ui;if(t.classList.contains("dropstart"))return Ki;if(t.classList.contains("dropup-center"))return"top";if(t.classList.contains("dropdown-center"))return"bottom";const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?qi:Wi:e?Vi:Ri}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(ye.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...Jt(this._config.popperConfig,[void 0,t])}}_selectMenuItem({key:t,target:e}){const i=Te.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(t=>qt(t));i.length&&te(i,e,t===Li,!i.includes(e)).focus()}static jQueryInterface(t){return this.each(function(){const e=Yi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}})}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=Te.find(zi);for(const i of e){const e=Yi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Si,Li].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Hi)?this:Te.prev(this,Hi)[0]||Te.next(this,Hi)[0]||Te.findOne(Hi,t.delegateTarget.parentNode),o=Yi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}ge.on(document,Mi,Hi,Yi.dataApiKeydownHandler),ge.on(document,Mi,Bi,Yi.dataApiKeydownHandler),ge.on(document,Ni,Yi.clearMenus),ge.on(document,ji,Yi.clearMenus),ge.on(document,Ni,Hi,function(t){t.preventDefault(),Yi.getOrCreateInstance(this).toggle()}),Gt(Yi);const Gi="backdrop",Ji="show",Zi=`mousedown.bs.${Gi}`,tn={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},en={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class nn extends we{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return tn}static get DefaultType(){return en}static get NAME(){return Gi}show(t){if(!this._config.isVisible)return void Jt(t);this._append();const e=this._getElement();this._config.isAnimated&&Kt(e),e.classList.add(Ji),this._emulateAnimation(()=>{Jt(t)})}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ji),this._emulateAnimation(()=>{this.dispose(),Jt(t)})):Jt(t)}dispose(){this._isAppended&&(ge.off(this._element,Zi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=Wt(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),ge.on(t,Zi,()=>{Jt(this._config.clickCallback)}),this._isAppended=!0}_emulateAnimation(t){Zt(t,this._getElement(),this._config.isAnimated)}}const sn=".bs.focustrap",on=`focusin${sn}`,rn=`keydown.tab${sn}`,an="backward",ln={autofocus:!0,trapElement:null},cn={autofocus:"boolean",trapElement:"element"};class hn extends we{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return ln}static get DefaultType(){return cn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),ge.off(document,sn),ge.on(document,on,t=>this._handleFocusin(t)),ge.on(document,rn,t=>this._handleKeydown(t)),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,ge.off(document,sn))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=Te.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===an?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?an:"forward")}}const un=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",dn=".sticky-top",fn="padding-right",pn="margin-right";class mn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,fn,e=>e+t),this._setElementAttributes(un,fn,e=>e+t),this._setElementAttributes(dn,pn,e=>e-t)}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,fn),this._resetElementAttributes(un,fn),this._resetElementAttributes(dn,pn)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)})}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&ye.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,t=>{const i=ye.getDataAttribute(t,e);null!==i?(ye.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)})}_applyManipulationCallback(t,e){if(Bt(t))e(t);else for(const i of Te.find(t,this._element))e(i)}}const gn=".bs.modal",_n=`hide${gn}`,bn=`hidePrevented${gn}`,vn=`hidden${gn}`,yn=`show${gn}`,wn=`shown${gn}`,An=`resize${gn}`,En=`click.dismiss${gn}`,Tn=`mousedown.dismiss${gn}`,Cn=`keydown.dismiss${gn}`,On=`click${gn}.data-api`,xn="modal-open",kn="show",Sn="modal-static",Ln={backdrop:!0,focus:!0,keyboard:!0},$n={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Dn extends Ae{constructor(t,e){super(t,e),this._dialog=Te.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new mn,this._addEventListeners()}static get Default(){return Ln}static get DefaultType(){return $n}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||ge.trigger(this._element,yn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(xn),this._adjustDialog(),this._backdrop.show(()=>this._showElement(t)))}hide(){this._isShown&&!this._isTransitioning&&(ge.trigger(this._element,_n).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(kn),this._queueCallback(()=>this._hideModal(),this._element,this._isAnimated())))}dispose(){ge.off(window,gn),ge.off(this._dialog,gn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new nn({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new hn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=Te.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),Kt(this._element),this._element.classList.add(kn),this._queueCallback(()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,ge.trigger(this._element,wn,{relatedTarget:t})},this._dialog,this._isAnimated())}_addEventListeners(){ge.on(this._element,Cn,t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())}),ge.on(window,An,()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()}),ge.on(this._element,Tn,t=>{ge.one(this._element,En,e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())})})}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove(xn),this._resetAdjustments(),this._scrollBar.reset(),ge.trigger(this._element,vn)})}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(ge.trigger(this._element,bn).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Sn)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Sn),this._queueCallback(()=>{this._element.classList.remove(Sn),this._queueCallback(()=>{this._element.style.overflowY=e},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=Yt()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=Yt()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each(function(){const i=Dn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}})}}ge.on(document,On,'[data-bs-toggle="modal"]',function(t){const e=Te.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),ge.one(e,yn,t=>{t.defaultPrevented||ge.one(e,vn,()=>{qt(this)&&this.focus()})});const i=Te.findOne(".modal.show");i&&Dn.getInstance(i).hide(),Dn.getOrCreateInstance(e).toggle(this)}),Ce(Dn),Gt(Dn);const In=".bs.offcanvas",Pn=".data-api",Nn=`load${In}${Pn}`,Mn="show",jn="showing",Fn="hiding",Hn=".offcanvas.show",zn=`show${In}`,Bn=`shown${In}`,Wn=`hide${In}`,qn=`hidePrevented${In}`,Rn=`hidden${In}`,Vn=`resize${In}`,Un=`click${In}${Pn}`,Kn=`keydown.dismiss${In}`,Qn={backdrop:!0,keyboard:!0,scroll:!1},Xn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class Yn extends Ae{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Qn}static get DefaultType(){return Xn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||ge.trigger(this._element,zn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new mn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(jn),this._queueCallback(()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Mn),this._element.classList.remove(jn),ge.trigger(this._element,Bn,{relatedTarget:t})},this._element,!0))}hide(){this._isShown&&(ge.trigger(this._element,Wn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(Fn),this._backdrop.hide(),this._queueCallback(()=>{this._element.classList.remove(Mn,Fn),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new mn).reset(),ge.trigger(this._element,Rn)},this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new nn({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():ge.trigger(this._element,qn)}:null})}_initializeFocusTrap(){return new hn({trapElement:this._element})}_addEventListeners(){ge.on(this._element,Kn,t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():ge.trigger(this._element,qn))})}static jQueryInterface(t){return this.each(function(){const e=Yn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}})}}ge.on(document,Un,'[data-bs-toggle="offcanvas"]',function(t){const e=Te.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),Rt(this))return;ge.one(e,Rn,()=>{qt(this)&&this.focus()});const i=Te.findOne(Hn);i&&i!==e&&Yn.getInstance(i).hide(),Yn.getOrCreateInstance(e).toggle(this)}),ge.on(window,Nn,()=>{for(const t of Te.find(Hn))Yn.getOrCreateInstance(t).show()}),ge.on(window,Vn,()=>{for(const t of Te.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&Yn.getOrCreateInstance(t).hide()}),Ce(Yn),Gt(Yn);const Gn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Jn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Zn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,ts=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Jn.has(i)||Boolean(Zn.test(t.nodeValue)):e.filter(t=>t instanceof RegExp).some(t=>t.test(i))},es={allowList:Gn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},is={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},ns={entry:"(string|element|function|null)",selector:"(string|element)"};class ss extends we{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return es}static get DefaultType(){return is}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map(t=>this._resolvePossibleFunction(t)).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},ns)}_setContent(t,e,i){const n=Te.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?Bt(e)?this._putElementInTemplate(Wt(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)ts(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return Jt(t,[void 0,this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const os=new Set(["sanitize","allowList","sanitizeFn"]),rs="fade",as="show",ls=".tooltip-inner",cs=".modal",hs="hide.bs.modal",us="hover",ds="focus",fs="click",ps={AUTO:"auto",TOP:"top",RIGHT:Yt()?"left":"right",BOTTOM:"bottom",LEFT:Yt()?"right":"left"},ms={allowList:Gn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},gs={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class _s extends Ae{constructor(t,e){if(void 0===Pt)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return ms}static get DefaultType(){return gs}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),ge.off(this._element.closest(cs),hs,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=ge.trigger(this._element,this.constructor.eventName("show")),e=(Vt(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),ge.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(as),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ge.on(t,"mouseover",Ut);this._queueCallback(()=>{ge.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1},this.tip,this._isAnimated())}hide(){if(this._isShown()&&!ge.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(as),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))ge.off(t,"mouseover",Ut);this._activeTrigger[fs]=!1,this._activeTrigger[ds]=!1,this._activeTrigger[us]=!1,this._isHovered=null,this._queueCallback(()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),ge.trigger(this._element,this.constructor.eventName("hidden")))},this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(rs,as),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(rs),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new ss({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{[ls]:this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(rs)}_isShown(){return this.tip&&this.tip.classList.contains(as)}_createPopper(t){const e=Jt(this._config.placement,[this,t,this._element]),i=ps[e.toUpperCase()];return It(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return Jt(t,[this._element,this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...Jt(this._config.popperConfig,[void 0,e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)ge.on(this._element,this.constructor.eventName("click"),this._config.selector,t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger[fs]=!(e._isShown()&&e._activeTrigger[fs]),e.toggle()});else if("manual"!==e){const t=e===us?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===us?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");ge.on(this._element,t,this._config.selector,t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?ds:us]=!0,e._enter()}),ge.on(this._element,i,this._config.selector,t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?ds:us]=e._element.contains(t.relatedTarget),e._leave()})}this._hideModalHandler=()=>{this._element&&this.hide()},ge.on(this._element.closest(cs),hs,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout(()=>{this._isHovered&&this.show()},this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout(()=>{this._isHovered||this.hide()},this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=ye.getDataAttributes(this._element);for(const t of Object.keys(e))os.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:Wt(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each(function(){const e=_s.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}})}}Gt(_s);const bs=".popover-header",vs=".popover-body",ys={..._s.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ws={..._s.DefaultType,content:"(null|string|element|function)"};class As extends _s{static get Default(){return ys}static get DefaultType(){return ws}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{[bs]:this._getTitle(),[vs]:this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each(function(){const e=As.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}})}}Gt(As);const Es=".bs.scrollspy",Ts=`activate${Es}`,Cs=`click${Es}`,Os=`load${Es}.data-api`,xs="active",ks="[href]",Ss=".nav-link",Ls=`${Ss}, .nav-item > ${Ss}, .list-group-item`,$s={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},Ds={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Is extends Ae{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return $s}static get DefaultType(){return Ds}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=Wt(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map(t=>Number.parseFloat(t))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(ge.off(this._config.target,Cs),ge.on(this._config.target,Cs,ks,t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}}))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver(t=>this._observerCallback(t),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=Te.find(ks,this._config.target);for(const e of t){if(!e.hash||Rt(e))continue;const t=Te.findOne(decodeURI(e.hash),this._element);qt(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(xs),this._activateParents(t),ge.trigger(this._element,Ts,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))Te.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(xs);else for(const e of Te.parents(t,".nav, .list-group"))for(const t of Te.prev(e,Ls))t.classList.add(xs)}_clearActiveClass(t){t.classList.remove(xs);const e=Te.find(`${ks}.${xs}`,t);for(const t of e)t.classList.remove(xs)}static jQueryInterface(t){return this.each(function(){const e=Is.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}})}}ge.on(window,Os,()=>{for(const t of Te.find('[data-bs-spy="scroll"]'))Is.getOrCreateInstance(t)}),Gt(Is);const Ps=".bs.tab",Ns=`hide${Ps}`,Ms=`hidden${Ps}`,js=`show${Ps}`,Fs=`shown${Ps}`,Hs=`click${Ps}`,zs=`keydown${Ps}`,Bs=`load${Ps}`,Ws="ArrowLeft",qs="ArrowRight",Rs="ArrowUp",Vs="ArrowDown",Us="Home",Ks="End",Qs="active",Xs="fade",Ys="show",Gs=".dropdown-toggle",Js=`:not(${Gs})`,Zs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',to=`.nav-link${Js}, .list-group-item${Js}, [role="tab"]${Js}, ${Zs}`,eo=`.${Qs}[data-bs-toggle="tab"], .${Qs}[data-bs-toggle="pill"], .${Qs}[data-bs-toggle="list"]`;class io extends Ae{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),ge.on(this._element,zs,t=>this._keydown(t)))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?ge.trigger(e,Ns,{relatedTarget:t}):null;ge.trigger(t,js,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Qs),this._activate(Te.getElementFromSelector(t)),this._queueCallback(()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),ge.trigger(t,Fs,{relatedTarget:e})):t.classList.add(Ys)},t,t.classList.contains(Xs)))}_deactivate(t,e){t&&(t.classList.remove(Qs),t.blur(),this._deactivate(Te.getElementFromSelector(t)),this._queueCallback(()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),ge.trigger(t,Ms,{relatedTarget:e})):t.classList.remove(Ys)},t,t.classList.contains(Xs)))}_keydown(t){if(![Ws,qs,Rs,Vs,Us,Ks].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter(t=>!Rt(t));let i;if([Us,Ks].includes(t.key))i=e[t.key===Us?0:e.length-1];else{const n=[qs,Vs].includes(t.key);i=te(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),io.getOrCreateInstance(i).show())}_getChildren(){return Te.find(to,this._parent)}_getActiveElem(){return this._getChildren().find(t=>this._elemIsActive(t))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=Te.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=Te.findOne(t,i);s&&s.classList.toggle(n,e)};n(Gs,Qs),n(".dropdown-menu",Ys),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Qs)}_getInnerElement(t){return t.matches(to)?t:Te.findOne(to,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each(function(){const e=io.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}})}}ge.on(document,Hs,Zs,function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),Rt(this)||io.getOrCreateInstance(this).show()}),ge.on(window,Bs,()=>{for(const t of Te.find(eo))io.getOrCreateInstance(t)}),Gt(io);const no=".bs.toast",so=`mouseover${no}`,oo=`mouseout${no}`,ro=`focusin${no}`,ao=`focusout${no}`,lo=`hide${no}`,co=`hidden${no}`,ho=`show${no}`,uo=`shown${no}`,fo="hide",po="show",mo="showing",go={animation:"boolean",autohide:"boolean",delay:"number"},_o={animation:!0,autohide:!0,delay:5e3};class bo extends Ae{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return _o}static get DefaultType(){return go}static get NAME(){return"toast"}show(){ge.trigger(this._element,ho).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(fo),Kt(this._element),this._element.classList.add(po,mo),this._queueCallback(()=>{this._element.classList.remove(mo),ge.trigger(this._element,uo),this._maybeScheduleHide()},this._element,this._config.animation))}hide(){this.isShown()&&(ge.trigger(this._element,lo).defaultPrevented||(this._element.classList.add(mo),this._queueCallback(()=>{this._element.classList.add(fo),this._element.classList.remove(mo,po),ge.trigger(this._element,co)},this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(po),super.dispose()}isShown(){return this._element.classList.contains(po)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){ge.on(this._element,so,t=>this._onInteraction(t,!0)),ge.on(this._element,oo,t=>this._onInteraction(t,!1)),ge.on(this._element,ro,t=>this._onInteraction(t,!0)),ge.on(this._element,ao,t=>this._onInteraction(t,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each(function(){const e=bo.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}})}}Ce(bo),Gt(bo);const vo=Object.freeze(Object.defineProperty({__proto__:null,Alert:Se,Button:$e,Carousel:ui,Collapse:Ci,Dropdown:Yi,Modal:Dn,Offcanvas:Yn,Popover:As,ScrollSpy:Is,Tab:io,Toast:bo,Tooltip:_s},Symbol.toStringTag,{value:"Module"}));[].slice.call(document.querySelectorAll('[data-bs-toggle="dropdown"]')).map(function(t){let e={boundary:"viewport"===t.getAttribute("data-bs-boundary")?document.querySelector(".btn"):"clippingParents"};return new Yi(t,e)}),[].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map(function(t){let e={delay:{show:50,hide:50},html:"true"===t.getAttribute("data-bs-html")??!1,placement:t.getAttribute("data-bs-placement")??"auto"};return new _s(t,e)}),[].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')).map(function(t){let e={delay:{show:50,hide:50},html:"true"===t.getAttribute("data-bs-html")??!1,placement:t.getAttribute("data-bs-placement")??"auto"};return new As(t,e)}),[].slice.call(document.querySelectorAll('[data-bs-toggle="switch-icon"]')).map(function(t){t.addEventListener("click",e=>{e.stopPropagation(),t.classList.toggle("active")})}),(()=>{const t=window.location.hash;t&&[].slice.call(document.querySelectorAll('[data-bs-toggle="tab"]')).filter(e=>e.hash===t).map(t=>{new io(t).show()})})(),[].slice.call(document.querySelectorAll('[data-bs-toggle="toast"]')).map(function(t){if(!t.hasAttribute("data-bs-target"))return;const e=new bo(t.getAttribute("data-bs-target"));t.addEventListener("click",()=>{e.show()})});const yo="tblr-",wo=(t,e)=>{const i=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(t);return i?`rgba(${parseInt(i[1],16)}, ${parseInt(i[2],16)}, ${parseInt(i[3],16)}, ${e})`:null},Ao=Object.freeze(Object.defineProperty({__proto__:null,getColor:(t,e=1)=>{const i=getComputedStyle(document.body).getPropertyValue(`--${yo}${t}`).trim();return 1!==e?wo(i,e):i},hexToRgba:wo,prefix:yo},Symbol.toStringTag,{value:"Module"}));t.Alert=Se,t.Button=$e,t.Carousel=ui,t.Collapse=Ci,t.Dropdown=Yi,t.Modal=Dn,t.Offcanvas=Yn,t.Popover=As,t.ScrollSpy=Is,t.Tab=io,t.Toast=bo,t.Tooltip=_s,t.bootstrap=vo,t.tabler=Ao,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"})}); +//# sourceMappingURL=tabler.min.js.map \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..c1118e1 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..76ba198 --- /dev/null +++ b/public/index.php @@ -0,0 +1,263 @@ +Maintenance — Globinours

Globinours fait une petite pause 🐾

Une mise à jour sécurisée est en cours. Réessayez dans quelques instants.

'; + exit(); +} + +$bootstrapPath = rtrim((string) (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'), '/') ?: '/'; +$bootstrapIssues = InstallationService::blockingRuntimeIssues(); +if ($bootstrapIssues) { + I18n::setLocale('fr'); + Auth::start(); + if ($bootstrapPath !== '/setup') { + header('Location: /setup'); + exit(); + } + AuthController::setupUnavailable(InstallationService::runtimeChecks(true)); + exit(); +} + +function render(string $view, array $vars = []): void +{ + extract($vars, EXTR_SKIP); + $viewFile = __DIR__ . '/../app/Views/' . $view; + require __DIR__ . '/../app/Views/layout.php'; +} + +Migrations::run(__DIR__ . '/../migrations'); +PrivateMediaService::migrateLegacy(); +SecurityService::hardenRuntimeFiles(); +I18n::setLocale(AppSettings::get('app_language')); + +$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/'; +$path = rtrim($path, '/') ?: '/'; +Auth::start(); +header_remove('X-Powered-By'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: strict-origin-when-cross-origin'); +header('Permissions-Policy: camera=(self), geolocation=(self), microphone=()'); +header( + "Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://tile.openstreetmap.org https://*.tile.openstreetmap.org; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'", +); +header('Cross-Origin-Opener-Policy: same-origin'); +header('Cross-Origin-Resource-Policy: same-origin'); +if (Auth::isSecureRequest()) { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); +} + +if ($path === '/gestion') { + header('Location: ' . (Auth::user() ? '/dashboard' : '/login?next=%2Fdashboard')); + exit(); +} +if (PublicSiteController::handles($path)) { + PublicSiteController::dispatch($path); + exit(); +} + +if ($path === '/auth/logo') { + AuthController::logo(); + exit(); +} +if ($path === '/pwa-icon-192.png') { + AuthController::pwaIcon(192); + exit(); +} +if ($path === '/pwa-icon-512.png') { + AuthController::pwaIcon(512); + exit(); +} +if ($path === '/setup') { + AuthController::setup(); + exit(); +} +if ($path === '/login') { + AuthController::login(); + exit(); +} +if ($path === '/logout') { + AuthController::logout(); + exit(); +} +if ($path === '/agenda/feed.ics') { + AgendaController::feed(); + exit(); +} +if (Auth::countUsers() === 0) { + header('Location: /setup'); + exit(); +} +if (!Auth::user()) { + header('Location: /login?next=' . rawurlencode($_SERVER['REQUEST_URI'] ?? '/dashboard')); + exit(); +} +header('Cache-Control: private, no-store'); +if ($path === '/account/devices') { + DevicesController::index(); + exit(); +} +if ($path === '/account/devices/revoke') { + DevicesController::revoke(); + exit(); +} +if ($path === '/account/devices/revoke-all') { + DevicesController::revokeAll(); + exit(); +} +if ($path === '/agenda/feed/regenerate') { + AgendaController::regenerateFeed(); + exit(); +} +if ($path === '/agenda/feed/revoke') { + AgendaController::revokeFeed(); + exit(); +} +if ($path === '/' && !PermissionService::can('dashboard')) { + header('Location: ' . PermissionService::firstAllowedPath()); + exit(); +} +PermissionService::enforce($path, (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); +if (str_starts_with($path, '/dev/')) { + $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + if (getenv('APP_ENV') !== 'development' || !in_array($remote, ['127.0.0.1', '::1'], true) || !Auth::is('admin')) { + http_response_code(404); + echo 'Page introuvable.'; + exit(); + } +} +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + if (!Auth::validCsrf($_POST['csrf'] ?? null)) { + http_response_code(419); + echo 'Session expirée. Rechargez la page puis réessayez.'; + exit(); + } + if (!Auth::canWrite($path)) { + http_response_code(403); + echo 'Votre rôle ne permet pas cette action.'; + exit(); + } + if (!in_array($path, ['/settings/users/save', '/settings/backups/restore'], true)) { + $entityId = (int) ($_POST['animal_id'] ?? ($_POST['id'] ?? 0)) ?: null; + $fields = implode(',', array_keys($_POST)); + register_shutdown_function(static function () use ($path, $entityId, $fields): void { + $error = error_get_last(); + if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { + return; + } + $code = http_response_code(); + if ($code === false || $code < 400) { + AuditService::log('post', $path, 'Action effectuée', $entityId ? 'animal' : null, $entityId, [ + 'fields' => $fields, + ]); + } + }); + } +} + +require __DIR__ . '/../app/Routes/web.php'; diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..3ba72b6 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1 @@ +{"id":"/gestion","name":"Globinours","short_name":"Globinours","description":"Gestion quotidienne d’un refuge animalier","start_url":"/gestion","scope":"/","display":"standalone","background_color":"#f7f3eb","theme_color":"#d87945","icons":[{"src":"/pwa-icon-192.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"/pwa-icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]} diff --git a/public/router.php b/public/router.php new file mode 100644 index 0000000..c36819e --- /dev/null +++ b/public/router.php @@ -0,0 +1,20 @@ + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()))); +self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()))); +self.addEventListener('fetch', event => {const request=event.request;if(request.method!=='GET')return;const url=new URL(request.url);if(url.origin!==location.origin||request.mode==='navigate'||!SHELL.includes(url.pathname))return;event.respondWith(caches.match(request).then(cached=>cached||fetch(request)));}); diff --git a/resources/branding/Logo_Globinours_maxi.png b/resources/branding/Logo_Globinours_maxi.png new file mode 100644 index 0000000..090bd0e Binary files /dev/null and b/resources/branding/Logo_Globinours_maxi.png differ diff --git a/resources/demo/twix.webp b/resources/demo/twix.webp new file mode 100644 index 0000000..ccce6ce Binary files /dev/null and b/resources/demo/twix.webp differ diff --git a/resources/documents/README.md b/resources/documents/README.md new file mode 100644 index 0000000..4fedee3 --- /dev/null +++ b/resources/documents/README.md @@ -0,0 +1,17 @@ +# Modèles documentaires locaux + +Les modèles ODT ne sont pas inclus dans le dépôt public : ils contiennent les +coordonnées, clauses et procédures propres à l’association qui les utilise. + +Globinours recherche localement les fichiers suivants : + +- `CONTRAT ADOPTION V1.5.odt` +- `ABANDON V1.0.odt` +- `CONTRAT BENEVOLE V2.0.odt` +- `PROPOSITION FA V1.1.odt` +- `CERTIFICAT VISITE PRE-ADOPTION V1.1.odt` + +Ils doivent être relus juridiquement, personnalisés puis déposés dans ce +répertoire par l’administrateur de l’installation. Les fichiers `*.odt` sont +ignorés par Git afin d’éviter de publier accidentellement des coordonnées ou +des informations internes. diff --git a/resources/lang/en.php b/resources/lang/en.php new file mode 100644 index 0000000..d794af5 --- /dev/null +++ b/resources/lang/en.php @@ -0,0 +1,122 @@ +'Fixed price','pricing.group.surgery_discount'=>'Surgical procedures', + 'statistics.location_coverage'=>'Origin data reliability','statistics.location_documented'=>':count documented','statistics.location_inferred'=>':count inferred from siblings','statistics.location_born_in_care'=>':count born in shelter care','statistics.location_unknown'=>':count not provided','statistics.location_coverage_help'=>'Animals born in shelter care are separated from unknown origins. Inferred municipalities rely on a documented litter or sibling group. Ambiguous mentions (veterinary clinic or third-party address) remain deliberately excluded.', + 'error.unexpected_reference'=>'An internal error occurred. Reference: :reference.', + 'auth.remember_device'=>'Stay signed in on this device','auth.remember_device_help'=>'A revocable session will be kept for 90 days. Your password is never stored.','devices.title'=>'Connected devices','devices.intro'=>'Persistent sessions authorized for your account. Immediately revoke a lost or shared device.','devices.revoke_all'=>'Sign out everywhere','devices.revoke_all_confirm'=>'Sign out all devices, including this one?','devices.revoked'=>'Device signed out.','devices.this_device'=>'This device','devices.active'=>'Active','devices.inactive'=>'Revoked or expired','devices.created'=>'Added on :date','devices.last_used'=>'Last used: :date','devices.expires'=>'Expires: :date','devices.revoke'=>'Revoke','devices.none'=>'No trusted device registered.', + 'public_site.to_complete'=>'To be completed','public_site.default_legal'=>"Website publisher\n\nThis website is published by :association, an association registered under RNA number :rna and SIRET number :siret.\nRegistered address: :address\nContact: :email\n\nPublication director\n\nThe publication director is the association’s legal representative. Name: [to be completed].\n\nHosting\n\nHosting provider: [legal name, address and contact details to be completed].\n\nIntellectual property\n\nUnless otherwise stated, the texts, photographs and graphic elements belong to the association or are used with their owners’ permission. Reuse must respect applicable rights and the image rights of the animals and people shown.\n\nLiability\n\nThe association endeavours to provide accurate and current information. Animal availability and characteristics may change; publication does not constitute automatic acceptance of an adoption.",'public_site.default_privacy'=>"Data controller\n\nThe data controller is :association.\nAddress: :address\nContact for exercising your rights: :email\n\nData and purposes\n\nThe public website presents animals available for adoption and ways to contact the association. When you contact the association, the information you provide may be used to answer your request, assess an adoption, manage a donation or provide the corresponding administrative follow-up.\n\nRecipients and retention\n\nData is accessible to authorised association members and, where necessary, authorised partners. It is not sold. It is retained for as long as required for its purpose and applicable legal or accounting obligations, then deleted or anonymised under the association’s policy.\n\nCookies and external services\n\nThe public website does not use advertising or audience-measurement tools built into Globinours. The contact map uses OpenStreetMap tiles; displaying it connects to the relevant map servers and transmits technical data such as the IP address. The management area uses a session cookie required for authentication.\n\nYour rights\n\nYou may request access, correction, deletion or restriction of your data and, where applicable, object to processing or request portability. Send your request to :email. You may also contact the competent supervisory authority.\n\nUpdates\n\nThis policy must be adapted to the association’s actual processing activities and updated whenever they change.", + 'privacy.heading'=>'Privacy and retention','privacy.no_auto_delete'=>'Nothing is deleted automatically: this list is for human review.','privacy.reason'=>'Reason','privacy.none_review'=>'No contact to review.','privacy.access_register'=>'Private access register','privacy.contact_not_found'=>'Contact not found.','privacy.export_failed'=>'The export could not be created.','privacy.reason_required'=>'A reason is required.','privacy.anonymous_contact'=>'Anonymized contact', + 'settings.privacy'=>'Privacy / GDPR','privacy.title'=>'Privacy and retention','privacy.intro'=>'Manage contact retention, exports and access to private files.','privacy.saved'=>'Retention policy saved.','privacy.retention'=>'Retention period','privacy.retention_years'=>'Years after last activity','privacy.retention_help'=>'This rule creates a review list; nothing is deleted automatically.','privacy.review'=>'Contacts to review','privacy.no_review'=>'No contact currently exceeds this period.','privacy.last_activity'=>'Last activity','privacy.export'=>'Export','privacy.anonymize'=>'Anonymize','privacy.anonymize_reason'=>'Required reason','privacy.anonymize_confirm'=>'Anonymize this contact? A backup will be created first.','privacy.access_log'=>'Private file access','privacy.no_access'=>'No access recorded.','privacy.resource'=>'Resource','privacy.actor'=>'User','privacy.action'=>'Action','privacy.date'=>'Date','privacy.anonymized'=>'Contact anonymized. Backup: :backup','public_site.legal'=>'Legal notice','public_site.privacy'=>'Privacy','public_site.legal_privacy'=>'Legal notice and privacy','public_site.legal_hint'=>'These texts are published on two dedicated pages. Have them reviewed for your organisation.', + 'settings.group_general'=>'Configuration','settings.group_business'=>'Business references','settings.group_access'=>'Access','settings.group_maintenance'=>'Maintenance','public_site.main_address'=>'Page shown at the main address','public_site.routing_management'=>'Globinours at root','public_site.routing_management_help'=>'Management stays at / and the mini-site at /site.','public_site.routing_public'=>'Public website at root','public_site.routing_public_help'=>'The website uses / and management is opened through /gestion.','public_site.routing_no_server'=>'Globinours handles this choice: no Apache or Nginx change is required.', + 'public_site.published'=>'Published on website','public_site.waiting_publication'=>'Publication waiting for availability', + 'settings.public_site'=>'Public website','common.title'=>'Title','common.text'=>'Text','public_site.unavailable'=>'Website unavailable','public_site.unavailable_help'=>'The public website is not available yet.','public_site.not_found'=>'Page not found','public_site.animals'=>'Our animals','public_site.contact'=>'Contact','public_site.home'=>'Home','public_site.donation'=>'Donations','public_site.navigation'=>'Main navigation','public_site.powered_by'=>'Powered by','public_site.rna'=>'RNA registration','public_site.siret'=>'SIRET','public_site.map_title'=>'Find us','public_site.map_marker'=>'Association address','public_site.discover'=>'Meet the animals','public_site.latest_animals'=>'Looking for a family','public_site.no_animals'=>'No animal is currently published in this category.','public_site.all_species'=>'All species','public_site.sterilized'=>'Sterilised','public_site.age_unknown'=>'Age not provided','public_site.months'=>':count months','public_site.years'=>':count year(s)','public_site.years_months'=>':years year(s) and :months months','public_site.back_animals'=>'Back to animals','public_site.age'=>'Age','public_site.breed'=>'Breed','public_site.coat'=>'Coat','public_site.dogs'=>'Dog-friendly','public_site.cats'=>'Cat-friendly','public_site.children'=>'Child-friendly','public_site.clean'=>'House-trained','public_site.ask_adoption'=>'Contact us about adoption','public_site.make_donation'=>'Make a donation', + 'public_site.settings_title'=>'Settings — Public website','public_site.settings_heading'=>'Public mini-site','public_site.settings_intro'=>'A lightweight website powered only by information deliberately made public.','public_site.preview'=>'View website','public_site.saved'=>'Public website settings were saved.','public_site.activation_style'=>'Activation and appearance','public_site.enable'=>'Enable public website','public_site.enable_help'=>'When disabled, no public page or animal profile can be viewed.','public_site.theme'=>'Visual theme','public_site.theme_warm'=>'Warm','public_site.theme_nature'=>'Natural','public_site.theme_minimal'=>'Minimal','public_site.free_page'=>'Free-form page','public_site.show_free_page'=>'Show this page in the menu','public_site.donation_url'=>'Donation platform link','public_site.contact_reuse'=>'Address, phone number, email, and logo come from the association’s general settings.','public_site.invalid_url'=>'One of the entered links is invalid.','public_site.publish_animal'=>'Publish this animal on the mini-site','public_site.publish_animal_help'=>'The profile will only be visible when the animal is also available for adoption. Only public photos and the text below are used.','public_site.public_description'=>'Public description','public_site.public_description_example'=>'Personality, habits, and the kind of family they need…', + 'common.open'=>'Open','settings.lab_references'=>'Lab references','medical_document.menu_prescription'=>'Prescription','medical_document.menu_analysis'=>'Lab results','lab_reference.title'=>'Settings — Lab references','lab_reference.heading'=>'Lab reference ranges','lab_reference.intro'=>'Ranges by analyser and species used to prefill lab reports and interpret their charts.','lab_reference.saved'=>'Lab reference ranges were saved.','lab_reference.priority_title'=>'Local reference takes priority.','lab_reference.priority_help'=>'If your analyser or clinic prints different ranges, enter them here: they take precedence over manufacturer values. An empty bound does not trigger colour interpretation.','lab_reference.manufacturer_ranges'=>'Initial manufacturer ranges; all values remain editable.','lab_reference.code'=>'Code','lab_reference.source'=>'Source / note','lab_reference.invalid'=>'Invalid reference data: check labels, units, and bounds.', + 'nav.dashboard'=>'Dashboard','nav.animals'=>'Animals','nav.directory'=>'Directory','nav.care_round'=>'Care rounds','nav.statistics'=>'Statistics','nav.administrative'=>'Administration','nav.grants'=>'Grants', + 'account.settings'=>'Account and settings','account.settings_heading'=>'Settings','account.logout'=>'Log out', + 'settings.general'=>'General','settings.users'=>'Users','settings.permissions'=>'Roles and permissions','settings.species'=>'Species','settings.pricing'=>'Pricing','settings.backups'=>'Backups','settings.audit'=>'Audit log','settings.asm3'=>'ASM3 migration','settings.data'=>'Data and demo', + 'settings.general_title'=>'General settings','settings.general_intro'=>'Association identity and shared Globinours rules.','settings.language'=>'Application language','settings.language_help'=>'Default interface language. French is used as a fallback whenever a translation is missing.','settings.save'=>'Save settings', + 'meta.dashboard'=>'Shelter overview, medical priorities and recent activity.','meta.statistics'=>'Annual activity, trends and profiles of animals taken into care.','meta.administrative'=>'Globinours shelter administrative register.','meta.animals'=>'Management and monitoring of animals in Globinours.','meta.default'=>'Globinours — Born from Twix’s story, designed to protect those who follow.', + 'auth.tagline'=>'Born from Twix’s story, designed to protect those who follow.','auth.setup'=>'Create the administrator account','auth.login'=>'Sign in','auth.display_name'=>'Display name','auth.username'=>'Username','auth.password'=>'Password','auth.password_confirm'=>'Confirm password','auth.create'=>'Create account and continue','auth.sign_in'=>'Sign in','auth.restore_success'=>'Backup restored. Sign in using an account from that backup.','auth.fix_fields'=>'Please correct the highlighted fields.','auth.required'=>'This field is required.','auth.password_min'=>'The password must contain at least :count characters.','auth.invalid_format'=>'The entered format is invalid.','auth.check_field'=>'Please check this field.','auth.password_mismatch'=>'Passwords do not match.','auth.session_expired'=>'Your session has expired. Reload the page.','auth.invalid_username'=>'Invalid username (3 to 40 characters).','auth.display_required'=>'The display name is required.','auth.invalid_credentials'=>'Incorrect username or password.','auth.first_setup'=>'Initial setup', + 'dashboard.today_priorities'=>'Today’s priorities','dashboard.watch_points'=>':count item(s) to monitor','dashboard.controlled'=>'Everything is under control','dashboard.open_round'=>'Open care round','dashboard.active_alerts'=>'active alerts','dashboard.active_treatments'=>'active treatments','dashboard.morning_round'=>'morning round','dashboard.evening_round'=>'evening round','dashboard.vaccines_7'=>'vaccines within 7 days','dashboard.quick_search'=>'Quick search (name, code, microchip)','common.search'=>'Search','dashboard.overview'=>'Overview','dashboard.overview_help'=>'Shelter figures today','dashboard.total'=>'Total','dashboard.shelter'=>'Shelter','dashboard.foster'=>'Foster care','dashboard.medical_pole'=>'Medical unit', + 'animals.search_placeholder'=>'Search: name, internal code, microchip','animals.all_species'=>'All species','animals.all_availability'=>'Any availability','animals.available'=>'Available for adoption','animals.unavailable'=>'Not available','animals.to_define'=>'To be defined','animals.add'=>'Add an animal','animals.add_litter'=>'Add a litter','animals.table_view'=>'Table view','animals.mosaic_view'=>'Mosaic view','animals.archived'=>'Archived animals','animals.trash'=>'Trash','animals.photo'=>'Photo','animals.name'=>'Name','animals.sex'=>'Sex','animals.species'=>'Species','animals.age'=>'Age','animals.status'=>'Status','animals.location'=>'Found in','animals.preview'=>'Preview','animals.no_results'=>'No results.', + 'status.refuge'=>'Shelter','status.fa'=>'Foster care','status.fa_permanente'=>'Permanent foster care','status.chat_libre'=>'Free-roaming cat','status.quarantaine'=>'Quarantine','status.soin'=>'Medical unit','status.isolation'=>'Isolation','status.hospitalise'=>'Hospitalised','status.reserve'=>'Reserved','status.adopte'=>'Adopted','status.decede'=>'Deceased','status.enfui'=>'Missing','sex.male'=>'Male','sex.female'=>'Female','sex.unknown'=>'Unknown sex','common.yes'=>'Yes','common.no'=>'No','common.unknown'=>'Unknown', + 'animals.all_statuses'=>'All statuses', + 'dashboard.round_anomalies'=>'Issues reported during rounds:','dashboard.adults'=>'adults','dashboard.kittens'=>'kittens','dashboard.unknown_age'=>'unknown age','dashboard.silver_room'=>'Silver room','dashboard.twix_room'=>'Twix room','dashboard.unassigned'=>'unassigned','dashboard.temporary'=>'temporary','dashboard.permanent'=>'permanent','dashboard.in_quarantine'=>'in quarantine','dashboard.in_medical'=>'in the medical unit','dashboard.data_quality'=>'Record quality','dashboard.data_quality_help'=>'Reliable data for better monitoring and statistics','dashboard.current_records'=>'Currently monitored records','dashboard.records_complete'=>'All essential records are complete','dashboard.records_to_complete'=>':count records to complete','dashboard.see_statistics'=>'View statistics','dashboard.medical'=>'Care & medical','dashboard.medical_help'=>'Priorities and follow-ups requiring attention','dashboard.vaccines_overdue'=>'Overdue vaccines','dashboard.vaccines_soon'=>'Vaccines within 7 days','dashboard.vaccines_month'=>'Vaccines in 8 to 30 days','dashboard.nothing'=>'Nothing to report.','dashboard.quarantine_followup'=>'Quarantine monitoring','dashboard.entry'=>'Entry','dashboard.expected_exit'=>'Expected exit','dashboard.exit_followup'=>'Exit monitoring','dashboard.no_quarantine'=>'No animals in quarantine.','dashboard.weight_alerts'=>'Weight alerts (≥ 15% over 30 days)','dashboard.current'=>'Current','dashboard.variation'=>'Change','dashboard.no_alert'=>'No alerts.','dashboard.treatments_close'=>'Treatments to close','dashboard.current_treatments'=>'Current treatments','dashboard.medication'=>'Medication','dashboard.start'=>'Start','dashboard.end'=>'End','dashboard.no_treatment'=>'No current treatments.','dashboard.recent_activity'=>'Recent activity','dashboard.recent_activity_help'=>'What has been completed recently','dashboard.medical_notes_7'=>'Medical notes (7 days)','dashboard.vaccines_30'=>'Vaccines administered (30 days)','dashboard.treatments_30'=>'Treatments started (30 days)','dashboard.latest_notes'=>'Latest medical notes','dashboard.date'=>'Date','dashboard.type'=>'Type','dashboard.reason'=>'Reason','dashboard.no_note'=>'No notes.','common.animal'=>'Animal', + 'animals.removed_management'=>'Removed animal management','animals.quality_filter'=>'Quality filter','animals.records'=>':count records','animals.remove_filter'=>'Remove filter','animals.open_record'=>'Open :name’s record','animals.photo_of'=>'Photo of :name','animals.foster'=>'Foster care','animals.free_cat_site'=>'Free-roaming cat site','animals.dogs'=>'Dogs','animals.cats'=>'Cats','animals.children'=>'Children','animals.cleanliness'=>'House-trained','animals.compatible_dogs'=>'Dog-friendly','animals.compatible_cats'=>'Cat-friendly','animals.compatible_children'=>'Child-friendly','animals.sort_by'=>'Sort by :label','animals.bonded'=>'Bonded','animals.litter'=>'Litter','animals.enlarge_photo'=>'Enlarge :name’s photo','animals.coat_unknown'=>'Coat not provided','animals.adopt_together'=>'Must be adopted together','animals.not_sterilized'=>'Not sterilised','animals.sterilization_unknown'=>'Sterilisation unknown','animals.not_chipped'=>'Not microchipped','animals.adoptable'=>'Adoptable','animals.not_adoptable'=>'Not adoptable','animals.medical_preview'=>'Medical preview','animals.fiv_felv'=>'FIV / FeLV status','animals.last_weight'=>'Latest weight','animals.next_vaccine'=>'Next vaccine','animals.current_treatment'=>'Current treatment','animals.dewormer'=>'Deworming','animals.latest_diagnosis'=>'Latest diagnosis / point of concern','common.close'=>'Close','animals.open_full_record'=>'Open full record', + 'dashboard.watch_point'=>':count item to monitor','dashboard.morning'=>'morning','dashboard.evening'=>'evening','dashboard.adult'=>'adult','dashboard.kitten'=>'kitten','dashboard.record_to_complete'=>':count record to complete','dashboard.quality_note'=>'“Not sterilised” is monitoring information, not missing data. The other counters flag fields that have not been completed.','quality.birth'=>'Unknown age','quality.sex'=>'Unknown sex','quality.color'=>'Missing colour','quality.breed'=>'Missing breed','quality.origin'=>'Missing origin','quality.chip'=>'Not identified','quality.registration'=>'Unknown registration','quality.sterilization'=>'Unknown sterilisation','quality.adoption'=>'Adoption status to define','quality.unsterilized'=>'Not sterilised','animals.life_profile'=>'Compatibility with dogs, cats and children, and house-training','animals.species_label'=>'Species: :name','animals.test_unknown'=>'Not tested','animals.test_negative'=>'Negative','animals.test_positive'=>'Positive','animals.test_doubtful'=>'Inconclusive','animals.tests_not_applicable'=>'FIV/FeLV tests not applicable','animals.age_unknown'=>'Age not provided','animals.age_estimated'=>'estimated','animals.sex_not_provided'=>'Sex not provided','animals.available_title'=>'Available for adoption','animals.unavailable_title'=>'Not available for adoption','animals.weight_unknown'=>'Not provided','animals.no_vaccine'=>'No vaccine reminder scheduled','animals.no_current_treatment'=>'No current treatment','animals.no_recent_medical'=>'No recent medical concern', + 'dashboard.grants_check'=>'Grant monitoring requires attention','dashboard.open_grants'=>'Open monitoring','dashboard.quarantine_tip'=>'Tip: enter the admission and expected exit dates to enable monitoring.','animals.enlarged_preview'=>'Enlarged preview','animals.short_hair'=>'Short hair','animals.medium_hair'=>'Medium-length hair','animals.long_hair'=>'Long hair','animals.curly_hair'=>'Curly hair','animals.hairless'=>'Hairless','animals.hair_unknown'=>'Hair not provided','animals.breed_unknown'=>'Breed not provided', + 'animals.latest'=>'Latest','animals.next'=>'Next','animals.never_provided'=>'Never provided','animals.last_update'=>'Last updated: :date', + 'animal.archived_record'=>'Archived record','animal.archived_help'=>'Viewing preserved history:date. Restore the record before resuming active monitoring.','animal.archived_on'=>' · archived on :date','animal.health_alert'=>'Health alert: :status','animal.species'=>'Species','animal.sex'=>'Sex','animal.breed'=>'Breed','animal.coat'=>'Coat','animal.hair'=>'Hair','animal.hair_short'=>'Short','animal.hair_medium'=>'Medium-length','animal.hair_long'=>'Long','animal.hair_curly'=>'Curly','animal.hairless'=>'Hairless','animal.microchip'=>'Microchip','animal.tattoo'=>'Tattoo','animal.identification_unknown'=>'Identification not provided','animal.placed_on'=>'implanted on :date','animal.status'=>'Status','animal.room'=>'Room','animal.assign'=>'To assign','animal.site'=>'Site','animal.to_provide'=>'To provide','animal.free_cat_followup'=>'Free-roaming cat monitoring','animal.present_on_site'=>'Present on site','animal.missing'=>'Missing','animal.moved'=>'Moved','animal.site_or_colony'=>'Site or colony','animal.caretaker'=>'Caretaker','animal.capture_release'=>'Capture / release','animal.internal_location'=>'Internal location','animal.sterilized'=>'Sterilised','animal.adoption_fee_hint'=>'Indicative adoption fee:','animal.availability_define'=>'Availability to define','animal.registration_unknown'=>'Registration unknown','animal.registration_pending'=>'Registration pending','animal.registration_done'=>'Registered','animal.available_from'=>'from :date','common.edit'=>'Edit','common.add'=>'Add','common.actions'=>'Actions','common.back'=>'Back','common.restore'=>'Restore record','common.print_record'=>'Print record','animal.medical_status'=>'Medical status','animal.medical_note'=>'Medical note','animal.treatment'=>'Treatment','animal.vaccine'=>'Vaccine','animal.veterinary_expense'=>'Veterinary expense','animal.bonded_group'=>'Bonded group','animal.placement_return'=>'Placement / return','animal.validate_adoption'=>'Confirm adoption','animal.record_death'=>'Record a death','animal.archive'=>'Archive','animal.move_to_trash'=>'Move to trash','animal.tab.identity'=>'Identity','animal.tab.activity'=>'Activity','animal.tab.medical'=>'Medical','animal.tab.treatments'=>'Treatments','animal.tab.vaccines'=>'Vaccines','animal.tab.more'=>'More','animal.tab.locations'=>'Living locations','animal.tab.placements'=>'Foster and adoption journey','animal.tab.litters'=>'Litters','animal.tab.documents'=>'Documents','animal.tab.history'=>'History','animal.tab.photos'=>'Photos', + 'common.name'=>'Name','common.weight'=>'Weight (kg)','common.optional'=>'Optional','common.disabled'=>'disabled','animal.form.species_hint'=>'Manage this list in Settings → Species.','animal.form.weight_hint'=>'Optional; initialises the weight chart.','animal.form.status_hint'=>'The status controls the available location and journeys.','animal.form.shelter_room'=>'Shelter room','animal.form.foster'=>'Foster family','animal.form.provide_later'=>'Provide later','animal.form.foster_hint'=>'Contacts with the “Foster” role in the directory.','animal.form.hair_type'=>'Hair type','animal.form.sterilization'=>'Sterilisation','animal.form.identification'=>'Identification','animal.form.type'=>'Type','animal.form.number'=>'Number','animal.form.implant_date'=>'Implant date','animal.form.registration'=>'Registration','animal.form.none_identification'=>'Unknown / none','animal.form.pending'=>'Pending','animal.form.registered'=>'Registered','animal.form.adoption'=>'Adoption','animal.form.availability'=>'Availability','animal.form.not_available'=>'Not available','animal.form.available_from'=>'Available from','animal.form.unavailable_reason'=>'Reason unavailable', + 'common.not_provided'=>'Not provided','common.save'=>'Save','common.notes'=>'Notes','common.date'=>'Date','animal.form.sterilization_date'=>'Sterilisation date','animal.form.veterinarian'=>'Veterinarian','animal.form.clinic'=>'Clinic','animal.form.chip_number'=>'Microchip or tattoo number','animal.form.life_profile'=>'Life profile','animal.form.life_profile_hint'=>'Use “Unknown” until the behaviour has actually been observed.','animal.form.age'=>'Age','animal.form.birth_date'=>'Date of birth','animal.form.estimated_birth'=>'Estimated birth?','animal.form.estimated_years'=>'Estimated age (years)','animal.form.estimated_months'=>'Estimated age (months)','animal.form.location'=>'Location','animal.form.origin_city'=>'Town of origin','animal.form.origin_hint'=>'The town alone is enough for statistics. A detailed address remains optional.','animal.form.current_location'=>'Current location','animal.form.current_location_hint'=>'For now, this is fixed to the shelter.','animal.form.intake'=>'Shelter intake','animal.form.intake_hint'=>'This information automatically feeds the register and statistics.','animal.form.intake_date'=>'Intake date','animal.form.intake_type'=>'Intake method','animal.form.intake_reason'=>'Main reason','animal.form.depositor'=>'Depositor','animal.form.no_depositor'=>'No known depositor','animal.form.new_depositor'=>'Create a depositor','animal.form.new_depositor_name'=>'New depositor name','animal.form.depositor_hint'=>'The contact will be added to the directory with the “Depositor” role.','animal.form.circumstances'=>'Circumstances','animal.form.quarantine_exit'=>'Quarantine exit','animal.form.quarantine_hint'=>'Calculated automatically :count day(s) after intake when the status is “Quarantine”.','animal.form.location_reason'=>'Reason for moving','animal.form.location_reason_hint'=>'Recorded only when the status, room, box or address changes.', + 'common.cancel'=>'Cancel','animal.treatment_start'=>'Start treatment','animal.medication'=>'Medication','animal.route'=>'Route','animal.new_medication'=>'New medication name','animal.molecule'=>'Active ingredient','animal.form_form'=>'Form','animal.dosage'=>'Dosage','animal.administer_during'=>'Administer during','animal.morning_round'=>'Morning round','animal.evening_round'=>'Evening round','animal.as_needed'=>'As needed','animal.start'=>'Start','animal.end_optional'=>'End (optional)','animal.vaccine_record'=>'Record a vaccine','animal.done_on'=>'Administered on','animal.reminder_due'=>'Reminder due','animal.batch'=>'Batch','animal.manufacturer'=>'Manufacturer','animal.batch_expiry'=>'Batch expiry','animal.administered_by'=>'Administered by','animal.person_name'=>'Person’s name', + 'animal.medical_status_global'=>'Overall medical status','animal.positive_test_help'=>'Changing a result to “Positive” automatically adds a medical note and history entry.','animal.fiv_status'=>'FIV status','animal.felv_status'=>'FeLV status','animal.test_date'=>'Test date','animal.test_note'=>'Test note','animal.conditions'=>'Other conditions or points of concern','animal.conditions_help'=>'An active concern appears in the medical summary.','animal.label'=>'Label','animal.state'=>'Status','animal.diagnosis'=>'Diagnosis','animal.note'=>'Note','animal.active'=>'Active','animal.resolved'=>'Resolved','animal.add_condition'=>'Add a condition or point of concern','animal.save_medical_status'=>'Save medical status','animal.add_medical_note'=>'Add a medical note','animal.reason'=>'Reason','animal.sterilization_act'=>'This procedure is the animal’s sterilisation','animal.already_sterilized'=>'This animal is already recorded as sterilised.','animal.sterilization_auto'=>'The record will automatically change to “Sterilised” and the indicative fee to €:price.','animal.temperature'=>'Temperature (°C)','animal.symptoms'=>'Symptoms','animal.exam'=>'Examination','animal.medical_photos'=>'Medical photos','animal.medical_photos_hint'=>'JPG, PNG, WebP — maximum 20 MB per image, automatically optimised','animal.plan'=>'Plan','animal.consultation'=>'Consultation','animal.checkup'=>'Check-up','animal.symptom'=>'Symptom','animal.procedure'=>'Procedure','animal.hospitalization'=>'Hospitalisation','animal.other'=>'Other', + 'animal.deworming_record'=>'Record deworming','animal.product'=>'Product','animal.dose'=>'Dose','animal.next_due'=>'Next due date','animal.next_due_hint'=>'Calculated automatically according to age if this field is left empty.','animal.apply_litter'=>'Also administer to the mother and every kitten in the litter','animal.comment'=>'Comment','animal.add_placement'=>'Add a placement event','animal.event'=>'Event','animal.reservation'=>'Reservation','animal.foster_placement'=>'Foster placement','animal.return_shelter'=>'Return to shelter','animal.cancellation'=>'Cancellation','animal.placement_hint'=>'Final adoption retains its dedicated button.','animal.related_contact'=>'Related contact','animal.no_contact'=>'No contact','animal.return_reason_placeholder'=>'Required for a return or cancellation','common.choose'=>'Choose', + 'animal.veterinary_clinic'=>'Veterinary clinic','animal.create_clinic'=>'Create a clinic…','animal.new_clinic_name'=>'New clinic name','animal.create_vet'=>'Create a veterinarian…','animal.new_vet_name'=>'New veterinarian name','animal.medical_pair_hint'=>'Selecting a veterinarian automatically selects their clinic. Selecting a clinic first limits the list to its veterinarians.','animal.tariff_record'=>'Charge to record','animal.no_expense'=>'Do not add an expense','animal.net'=>'net','animal.expense_hint'=>'The expense will automatically be linked to this animal.','common.choose_from_list'=>'Choose from the list', + 'animal.death_recorded'=>'Death recorded','animal.year'=>'Year :year','animal.date_unknown'=>'Unknown date','animal.statistics'=>'Statistics','animal.in_shelter_figures'=>'Included in shelter figures','animal.outside_care'=>'Outside shelter care','animal.place'=>'Place','animal.care'=>'Care','animal.context'=>'Context','animal.euthanasia'=>'Euthanasia','animal.natural_death'=>'Natural or accidental death','animal.cause'=>'Cause','animal.age_at_death'=>'Age at death','animal.bonded_cats'=>'Bonded animals','animal.bonded_help'=>'These animals must be placed or adopted together.','animal.dissolve_group'=>'Dissolve group','animal.intake_origin_help'=>'Administrative origin and intake circumstances.','animal.intake_mode'=>'Intake method','animal.origin_city'=>'Town of origin','animal.life_profile_help'=>'Observed compatibility and house-training.','animal.location'=>'Location','animal.rescue'=>'Rescue','animal.current'=>'Current','animal.rescue_place'=>'Rescue location','animal.coordinates_missing'=>'Coordinates missing.','animal.adopted_by'=>'Adopted by','animal.distance_story'=>':name travelled :distance km to reach us.','animal.distance_caption'=>'A short journey on the map… but a great step towards safety.','animal.last_sync'=>'Last sync: :date','animal.open_icad'=>'Open i-CAD','common.refresh'=>'Refresh','animal.icad_error'=>'i-CAD error','animal.no_icad'=>'No i-CAD data yet.','animal.icad_name'=>'i-CAD name','animal.birth'=>'Birth','animal.appearance'=>'Appearance','animal.holder'=>'Keeper (i-CAD)','animal.do_not_share'=>'DO NOT SHARE','common.phone'=>'Phone','common.email'=>'Email','common.address'=>'Address','animal.icad_history'=>'i-CAD history','animal.no_event'=>'No events.','animal.recorded'=>'Recorded','animal.placement_journey'=>'Foster and adoption journey','animal.placement_journey_help'=>'Reservations, foster placements, adoptions, returns and cancellations.','animal.add_event'=>'Add event','animal.final_adoption'=>'Final adoption','animal.no_placement'=>'No placement recorded.','common.by'=>'By :name','animal.location_history'=>'Living-location history','animal.location_history_help'=>'Every change of status, room, box, family or address is automatically retained.','animal.step'=>':count step','animal.steps'=>':count steps','animal.no_location'=>'No location recorded.','animal.no_activity'=>'No activity recorded.','animal.system'=>'System','animal.care_round'=>'Care round', + 'animal.current_health_help'=>'Current status and long-term concerns','animal.conditions_short'=>'Conditions and concerns','animal.no_active_condition'=>'No active concerns.','animal.deworming_schedule'=>'Kittens: 3, 5 and 7 weeks, then monthly until 6 months · adults: every 3 months.','animal.no_deworming'=>'No deworming recorded.','animal.latest_deworming'=>'Latest deworming: :date','animal.next_date'=>'Next: :date','animal.recorded_by'=>'Recorded by','animal.weight_evolution'=>'Weight evolution','animal.no_weight'=>'No weight measurement recorded.','animal.no_medical_note'=>'No medical notes.','animal.photos'=>'Photos','animal.no_treatment'=>'No current treatment.','animal.finished'=>'Finished','animal.ongoing'=>'Ongoing','animal.no_vaccine_recorded'=>'No vaccine recorded.','animal.batch_traceability'=>'Batch traceability','animal.expires_on'=>'Expires on :date','animal.litters_parentage'=>'Litters and parentage','animal.litters_help'=>'Litters in which this animal is a parent or kitten.','animal.add_litter'=>'Add a litter','animal.no_litter'=>'No litter recorded.','animal.litter_on'=>'Litter of :date','animal.mother'=>'Mother','animal.father'=>'Father','animal.unknown_female'=>'unknown','animal.unknown_male'=>'unknown','animal.add_existing_cat'=>'Add an existing cat','animal.archived'=>'Archived','animal.sex_define'=>'Sex to define','animal.adoption_contract'=>'Adoption contract','animal.generated_copy_help'=>'A copy is generated on demand; the original template remains unchanged.','animal.prefilled_information'=>'Pre-filled information','animal.adoption_prefill'=>'Identity, sex, birth, coat, microchip, sterilisation, FIV/FeLV, vaccination and reminder.','animal.linked_adopter'=>'Linked adopter: :name','animal.no_linked_adopter'=>'No linked adopter: their section will remain blank.','animal.download_odt'=>'Download ODT','animal.view_print_pdf'=>'View / print PDF','animal.abandon_found'=>'Surrender / found animal','animal.known_info_added'=>'Known animal information is added automatically.','animal.identity_summary'=>'Name, sex, breed, age and identification.','animal.no_history_event'=>'No events recorded.','animal.public_photos'=>'Public photos','animal.visible_online'=>'Visible online','animal.upload_public'=>'Upload (public)','animal.no_public_photo'=>'No public photos.','animal.primary'=>'Primary','animal.public'=>'Public','animal.set_primary'=>'Set as primary photo','animal.private'=>'Private','common.delete_short'=>'Delete','animal.private_photos'=>'Private photos','animal.not_visible_online'=>'Not visible online','animal.upload_private'=>'Upload (private)','animal.no_private_photo'=>'No private photos.','animal.round_photo'=>'Photo taken during a care round', + 'animal.holder_internal'=>'Keeper (internal use)','animal.icad_fetching'=>'Retrieving i-CAD data…','animal.icad_fetch_error'=>'Error while retrieving i-CAD data. Check the technical logs.', + 'animal.medication_saved_hint'=>'The medication will be added to the catalogue and remain available for future treatments.','animal.kitten_vaccine_hint'=>'Change to +1 month for a kitten.','animal.other_person'=>'Other person', + 'directory.title'=>'Directory','directory.add'=>'Add contact','directory.edit'=>'Edit contact','directory.search'=>'Name, phone, email or town','directory.all_roles'=>'All roles','common.filter'=>'Filter','directory.identity'=>'Identity','directory.roles'=>'Roles','directory.contact_details'=>'Contact details','directory.city'=>'Town','directory.organization'=>'Organisation','directory.person'=>'Person','directory.documents'=>'Documents','directory.volunteer_contract'=>'Volunteer agreement','directory.foster_proposal'=>'Foster proposal','directory.no_contact'=>'No contacts.','directory.identity_details'=>'Identity and contact details','directory.kind'=>'Type','directory.organization_link'=>'Linked organisation','directory.organization_hint'=>'Links a person, such as a veterinarian, to their organisation.','directory.postal_code'=>'Postcode','directory.country'=>'Country','directory.internal_notes'=>'Internal notes','directory.name_required'=>'Name required','directory.invalid_organization'=>'Invalid linked organisation.','role.adoptant'=>'Adopter','role.benevole'=>'Volunteer','role.cabinet'=>'Veterinary clinic','role.crematorium'=>'Crematorium','role.deposant'=>'Depositor','role.dirigeant'=>'Manager','role.fa'=>'Foster family','role.fourriere'=>'Animal control','role.veterinaire'=>'Veterinarian', + 'care.title'=>'Care round','care.description'=>'Tablet mode for morning and evening rounds.','care.setup_title'=>'Care-room configuration','care.round_of'=>':period round','care.morning'=>'morning','care.evening'=>'evening','care.fullscreen'=>'Full screen','care.configure_boxes'=>'Configure boxes','care.silver_room'=>'Silver room','care.twix_room'=>'Twix room','care.medical_room'=>'Medical room','care.quarantine_room'=>'Quarantine room','care.cat'=>':count animal','care.cats'=>':count animals','care.validate_room_confirm'=>'Validate every unchecked animal in this room?','care.everyone_well'=>'Everyone is well','care.finished'=>'Complete','care.treatment_planned'=>'Treatment scheduled during this round','care.treatment_unconfigured'=>'Treatment schedule needs configuring','care.validated'=>'Validated','care.todo'=>'To do','care.all_normal'=>'Everything is normal','care.given'=>'Given','care.not_given'=>'Not given','care.anomaly_photo'=>'Photo of an issue','care.photo_comment'=>'Photo comment (e.g. wound on front leg)','care.photo_hint'=>'The photo will be optimised and saved to the animal’s private media.','care.optional_comment'=>'Optional comment','care.validate_animal'=>'Validate :name','care.no_schedule'=>'without a schedule','care.both'=>'Both','care.no_animal_room'=>'No animals in this room.','care.food_normal'=>'Ate','care.food_little'=>'Ate a little','care.food_refused'=>'Refused','care.water_normal'=>'Water OK','care.water_renewed'=>'Renewed','care.abnormal'=>'Abnormal','care.urine_normal'=>'Urine OK','care.absent_feminine'=>'Absent','care.stool_normal'=>'Stool OK','care.state_good'=>'Good condition','care.watch'=>'Monitor','care.alert'=>'Alert','care.box_top_left_double'=>'Upper-left double box','care.box_top_right'=>'Upper-right box','care.box_top_left'=>'Upper-left box','care.box_top_right_double'=>'Upper-right double box','care.box_top_full'=>'Full upper box','care.box_top_center'=>'Upper-centre box','care.box_bottom_full'=>'Full lower box','care.box_bottom_left'=>'Lower-left box','care.box_bottom_right'=>'Lower-right box','care.box_unassigned'=>'No box assigned','care.invalid_photo'=>'Invalid or oversized photo, or missing comment.','care.invalid_photo_format'=>'Photo format not accepted.','care.setup_help'=>'Configure the partitions, save, then assign animals to available boxes.','care.back_round'=>'Back to care round','care.three_top_boxes'=>'Three upper boxes','care.two_bottom_boxes'=>'Two lower boxes','care.three_separate'=>'3 separate boxes','care.double_left'=>'Double left + single right','care.double_right'=>'Single left + double right','care.one_large'=>'1 large box','care.two_separate'=>'2 separate boxes','care.current_assignment'=>'Current assignment','care.room'=>'Room','care.box'=>'Box','care.assignment_reason'=>'Reason for new assignments','care.assignment_placeholder'=>'Optional: box reorganisation, isolation…','care.save_setup'=>'Save configuration', + 'admin.title'=>'Administration','admin.exports_title'=>'Administration — Exports','admin.register'=>'Intake and outcome register','admin.grants'=>'Grants and reports','admin.export_data'=>'Export data','admin.documents'=>'Administrative documents','admin.models'=>':count templates','admin.models_help'=>'Blank original templates, ready to view, print or complete.','admin.blank_model'=>'blank template','admin.adoption_document'=>'Adoption contract','admin.abandon_document'=>'Surrender / found animal','admin.volunteer_document'=>'Volunteer agreement','admin.foster_document'=>'Foster-family proposal','admin.pre_adoption_document'=>'Pre-adoption visit certificate','admin.year'=>'Year','admin.search'=>'Search','admin.search_placeholder'=>'Name / code / microchip…','admin.all_species'=>'All','admin.cat'=>'Cat','admin.dog'=>'Dog','common.reset'=>'Reset','admin.print_register'=>'Print register','admin.birth'=>'Birth','admin.identification'=>'Identification','admin.reason_contact'=>'Reason / Contact details','admin.entry'=>'Intake','admin.exit'=>'Outcome','admin.adoption'=>'Adoption','admin.owner_death'=>'Owner’s death','admin.animal_death'=>'Animal’s death','admin.rescue'=>'Rescue','admin.crematorium'=>'Crematorium','admin.no_data'=>'No data.','admin.exports_help'=>'Open, readable files reusable outside Globinours.','admin.back'=>'Back to administration','admin.sensitive'=>'Sensitive data.','admin.sensitive_help'=>'These exports may contain personal contact details and medical information. Store them securely.','admin.portability'=>'Complete portability export','admin.portability_help'=>'A ZIP archive containing every CSV table, without passwords, media or configuration secrets.','admin.download_all'=>'Download all CSV files','common.download'=>'Download','admin.compatibility'=>'Compatibility','admin.compatibility_help'=>'Files use UTF-8, a header row and semicolon separators. Cells that could be interpreted as formulas are neutralised.','admin.unknown_export'=>'Unknown export.','admin.export_failed'=>'The export could not be generated. Check the server logs.','admin.forbidden'=>'Your role does not permit access to administration.','export.animals'=>'Animals','export.contacts'=>'Directory','export.adoptions'=>'Adoptions','export.placements'=>'Foster journeys and returns','export.medical'=>'Medical notes','export.treatments'=>'Treatments','export.vaccinations'=>'Vaccinations','export.deaths'=>'Deaths','export.measurements'=>'Measurements','export.litters'=>'Litters','export.bonded'=>'Bonded groups','export.care_rounds'=>'Care rounds','export.administrations'=>'Treatment administrations','export.media_index'=>'Media index','export.movements'=>'Movements','export.locations'=>'Living-location history','export.history'=>'History', + 'grant.title'=>'Grants and annual report','grant.forbidden'=>'Your role does not permit this action.','grant.document_not_found'=>'Document not found.','grant.saved'=>'The information has been saved.','grant.save_error'=>'The information could not be saved. Check the fields and document format.','grant.file_year'=>'Application year','common.show'=>'Show','grant.register'=>':year register','grant.print_report'=>'Print report','grant.entries'=>'Intakes','grant.exits'=>'Outcomes','grant.adoptions'=>'Adoptions','grant.deaths'=>'Deaths','grant.births'=>'Kittens born','grant.vaccinations'=>'Vaccinations','grant.treatments'=>'Treatments started','grant.current'=>'Animals currently monitored','grant.indicators_help'=>'The indicators above come directly from Globinours data for :year. “Animals currently monitored” reflects today’s situation, not a historical snapshot on 31 December.','grant.avg_adoption'=>'Average adoption time','common.days'=>':count days','grant.comparison'=>'Change compared with :year','grant.comparison_help'=>'Percentages are only displayed when a previous-year baseline exists.','grant.indicator'=>'Indicator','grant.evolution'=>'Change','grant.sex_entries'=>'Intakes by sex','grant.age_entry'=>'Age at intake','grant.origins'=>'Declared origins','grant.death_causes'=>'Causes of death','grant.adoption_stay'=>'Stay before adoption','grant.no_data_year'=>'No usable data for :year.','grant.annual_report'=>':year annual report','grant.members'=>'Members','grant.volunteers'=>'Active volunteers','grant.employees'=>'Employees','grant.employee_fte'=>'Employee FTE','grant.last_ag'=>'Latest general meeting','grant.beneficiaries'=>'Beneficiaries / target audience','grant.beneficiaries_placeholder'=>'Animals cared for, residents, municipalities covered…','grant.activity_summary'=>'Activity report summary','grant.completed_actions'=>'Actions completed','grant.planned_actions'=>'Planned projects and actions','grant.officers'=>'Officers and organisation','grant.in_kind'=>'In-kind support and volunteer contributions','grant.financial_summary'=>'Financial summary','grant.actual_expenses'=>'Actual expenses','grant.actual_income'=>'Actual income','grant.forecast_expenses'=>'Forecast expenses','grant.forecast_income'=>'Forecast income','grant.treasury'=>'Available cash','grant.voluntary'=>'Valued voluntary contributions','grant.save_report'=>'Save report','grant.applications'=>'Applications by municipality or organisation','grant.organization'=>'Organisation','grant.deadline'=>'Deadline','grant.requested'=>'Requested','grant.previous_aid'=>'Previous grant','grant.decision'=>'Decision tracking','grant.awarded'=>'Awarded','grant.no_application'=>'No applications for :year.','grant.organization_required'=>'Municipality / organisation *','grant.requested_amount'=>'Amount requested','grant.state'=>'Status','grant.notes'=>'Specific details / notes','common.add'=>'Add','grant.checklist'=>'Application checklist','grant.checklist_help'=>'Shared documents, validity and each organisation’s own form.','grant.add_first'=>'Add an application first to track its completeness.','grant.valid_parts'=>':valid/:total valid documents','grant.complete'=>'Application complete','grant.incomplete'=>'To complete','grant.valid'=>'Valid','grant.expiring'=>'Expires soon','grant.expired'=>'Expired','grant.missing'=>'Missing','grant.vault'=>'Supporting-document vault','grant.vault_help'=>'Private PDF and ODT files, included in backups.','grant.other'=>'Other','grant.permanent'=>'Permanent','grant.folder'=>'Application: :name','grant.expired_on'=>'Expired on','grant.valid_until'=>'Valid until','grant.no_document'=>'No supporting documents recorded.','grant.category'=>'Category','grant.related_folder'=>'Related application','grant.common_part'=>'Shared document','grant.document_title'=>'Title','grant.pdf_odt'=>'PDF or ODT file','grant.permanent_document'=>'Permanent document, available for every year','grant.add_document'=>'Add document', + 'grant.status.draft'=>'Draft','grant.status.ready'=>'Ready','grant.status.submitted'=>'Submitted','grant.status.awarded'=>'Awarded','grant.status.refused'=>'Refused','grant.status.withdrawn'=>'Withdrawn','grant.category.official_form'=>'Organisation’s official form','grant.category.statutes'=>'Statutes','grant.category.prefecture'=>'Prefecture receipt','grant.category.jo'=>'Official Journal publication','grant.category.rib'=>'Bank details','grant.category.insurance'=>'Insurance certificate','grant.category.ag'=>'General-meeting minutes','grant.category.activity'=>'Activity report','grant.category.accounts'=>'Approved accounts','grant.category.budget'=>'Forecast budget','grant.category.other'=>'Other', + 'grant.generated'=>'Generated by :association with Globinours on :date at :time','grant.not_calculable'=>'not available','statistics.details'=>'Detailed breakdown','common.no_data'=>'No data.','grant.association_life'=>'Association life','grant.report_activity'=>'Activity report','grant.actions_planned'=>'Planned actions','grant.beneficiaries_short'=>'Beneficiaries','grant.in_kind_short'=>'In-kind support and volunteering','grant.expenses_planned'=>'Planned expenses','grant.income_planned'=>'Planned income','grant.voluntary_short'=>'Voluntary contributions', + 'statistics.title'=>'Shelter statistics','statistics.description'=>'Annual activity, trends and profiles of animals cared for.','statistics.handled'=>'Animals cared for','statistics.entries'=>'Intakes','statistics.exits'=>'Outcomes','statistics.adoptions'=>'Adoptions','statistics.deaths'=>'Deaths','statistics.births'=>'Kittens born','statistics.vaccinations'=>'Vaccinations','statistics.treatments'=>'Treatments','statistics.treatments_started'=>'Treatments started','statistics.medical_notes'=>'Medical notes','statistics.avg_adoption_days'=>'Average adoption time (days)','statistics.memory'=>'Shelter records','statistics.city_report'=>':city report','statistics.figures'=>'Activity in figures','statistics.city_help'=>'Animals originating from this municipality when they entered care.','statistics.help'=>'This page analyses trends. The dashboard remains focused on today’s actions and priorities.','statistics.period'=>'Period analysed','statistics.all_years'=>'All years','statistics.origin_city'=>'Municipality of origin','statistics.all_cities'=>'All municipalities','common.clear'=>'Clear','common.print'=>'Print','statistics.handled_count'=>':count animals cared for','statistics.filter_print'=>'The filter applies to the entire page and printout.','statistics.since_start'=>'since the beginning','statistics.in_year'=>'in :year','statistics.vet_costs'=>'Estimated veterinary costs','statistics.vet_costs_help'=>'Actual rates after discounts, frozen when each expense was recorded.','statistics.animals_concerned'=>':count animals concerned','statistics.by_structure'=>'By organisation','statistics.by_category'=>'By category','statistics.no_expense'=>'No expenses recorded.','statistics.annual_activity'=>'Annual activity trend','statistics.annual_activity_help'=>'Distinct animals cared for, adoptions and deaths recorded since the beginning.','statistics.year'=>'Year','statistics.unknown_date'=>'Unknown date','statistics.no_previous_percent'=>'No percentage means the previous-year value was zero.','statistics.current_snapshot'=>'Current snapshot','statistics.current_snapshot_help'=>'Living animals still in care today.','statistics.animals'=>'animals','statistics.free_cats'=>'Community cats monitored','statistics.sterilized'=>'Sterilised','statistics.chipped'=>'Microchipped','statistics.fiv_positive'=>'FIV positive','statistics.felv_positive'=>'FeLV positive','statistics.free_cat_help'=>'Community cats remain monitored by the organisation without being counted as physically present at the shelter.','statistics.profiles'=>'Profiles and distributions','statistics.profiles_help_all'=>'Profiles cover animals admitted since the beginning, unless stated otherwise.','statistics.profiles_help_year'=>'Profiles cover animals admitted in :year, unless stated otherwise.','statistics.observations'=>':count observations','statistics.click_city'=>'click a municipality','statistics.no_period_data'=>'No usable data for this period.','statistics.show_city'=>'Show :city report','statistics.intake_modes'=>'Intake methods','statistics.intake_reasons'=>'Intake reasons','statistics.colors'=>'Colours of admitted animals','statistics.breeds'=>'Breeds of admitted animals','statistics.species'=>'Species of admitted animals','statistics.sex'=>'Sex of admitted animals','statistics.age_at_entry'=>'Age at intake','statistics.origins'=>'Declared origins','statistics.adoption_delay'=>'Time before adoption','statistics.death_causes'=>'Causes of death','statistics.current_distribution'=>'Current distribution','statistics.free_cat_sites'=>'Community cats by site or municipality','statistics.releases_year'=>'Releases by year','statistics.releases_site'=>'Releases by site','statistics.intake.unknown'=>'Not provided','statistics.intake.found'=>'Found','statistics.intake.owner_surrender'=>'Owner surrender','statistics.intake.transfer'=>'Transfer from another organisation','statistics.intake.born_in_care'=>'Born in shelter care','statistics.intake.return'=>'Return after placement','statistics.intake.seizure'=>'Seizure or requisition','statistics.intake.other'=>'Other intake method','statistics.reason.unknown'=>'Not provided','statistics.reason.stray_found'=>'Stray or found animal','statistics.reason.unwanted_litter'=>'Unwanted litter','statistics.reason.owner_health'=>'Owner’s health or hospitalisation','statistics.reason.owner_death'=>'Owner’s death','statistics.reason.owner_care_home'=>'Owner moved into residential care','statistics.reason.allergy'=>'Allergy','statistics.reason.housing'=>'Housing or relocation','statistics.reason.financial'=>'Financial difficulties','statistics.reason.behavior'=>'Behavioural difficulties','statistics.reason.abandonment'=>'Abandoned on site','statistics.reason.danger'=>'Endangerment or abuse','statistics.reason.transfer'=>'Transfer between organisations','statistics.reason.birth'=>'Birth','statistics.reason.other'=>'Other reason', + 'statistics.city_filter_summary'=>':count animals cared for','statistics.filter_applied'=>'The filter applies to the entire page and printout.','statistics.undated_deaths'=>':total deaths in total. :count historical deaths without a confirmed date are included in the overall total, but not in the chart or annual rows.','statistics.global_memory'=>'Overall record:','statistics.undated_summary'=>':entries intakes, :exits outcomes and :births births without a confirmed year are included in totals, but not in the timeline. The :vaccinations vaccinations concern :vaccinated animals; the :notes medical notes concern :medical animals.','statistics.undated_handled'=>':count animals cared for without a known intake year are included in the overall total, but are not placed artificially on the chart.','statistics.chart_label'=>'Annual activity trend', + 'statistics.value.females'=>'Females','statistics.value.males'=>'Males','statistics.value.unknown'=>'Unknown','statistics.value.not_provided'=>'Not provided','statistics.value.not_provided_f'=>'Not provided','statistics.value.under_year'=>'Under one year','statistics.value.one_two_years'=>'1 to 2 years','statistics.value.three_seven_years'=>'3 to 7 years','statistics.value.eight_plus'=>'8 years and over','statistics.value.under_month'=>'Under one month','statistics.value.one_three_months'=>'1 to 3 months','statistics.value.three_six_months'=>'3 to 6 months','statistics.value.six_twelve_months'=>'6 to 12 months','statistics.value.over_year'=>'Over one year','statistics.value.cats'=>'Cats','statistics.value.dogs'=>'Dogs','statistics.value.unknown_f'=>'Unknown','statistics.value.permanent_foster'=>'Permanent foster care','statistics.value.unprovided_site'=>'Site not provided', + 'pricing.category.consultation'=>'Consultation','pricing.category.surgery'=>'Surgery','pricing.category.identification'=>'Identification','pricing.category.test'=>'Test / analysis','pricing.category.vaccine'=>'Vaccine','pricing.category.euthanasia'=>'Euthanasia','pricing.category.cremation'=>'Cremation','pricing.category.medication'=>'Medication','pricing.category.dewormer'=>'Dewormer','pricing.category.other'=>'Other','death.cause.disease'=>'Disease','death.cause.accident'=>'Accident','death.cause.old_age'=>'Old age','death.cause.congenital'=>'Congenital malformation or condition','death.cause.surgery_complication'=>'Medical or surgical complication','death.cause.poisoning'=>'Poisoning','death.cause.unknown'=>'Unknown cause','death.cause.other'=>'Other cause', + 'settings.saved'=>'Settings have been saved.','settings.association'=>'Organisation','settings.association_name'=>'Organisation name','settings.rna'=>'RNA number','settings.siret'=>'SIRET','settings.address'=>'Address','settings.postcode'=>'Postcode','settings.city'=>'Town','settings.phone'=>'Phone','settings.email'=>'Email','settings.association_logo'=>'Organisation logo','settings.logo_help'=>'PNG, JPEG or WebP, maximum 5 MB. It can be included in official documents.','settings.delete_logo'=>'Delete current logo','settings.logo_alt'=>':name logo','settings.current_preview'=>'Current preview','settings.no_logo'=>'No logo recorded','settings.operation'=>'Operation','settings.quarantine_duration'=>'Quarantine duration','settings.days'=>'days','settings.backups_kept'=>'Backups retained', + 'settings.users_title'=>'Settings — Users','settings.users_intro'=>'Accounts, roles and application access.','settings.configure_permissions'=>'Configure role permissions','settings.add_user'=>'Add a user','settings.display_name'=>'Display name','settings.username'=>'Username','settings.role'=>'Role','settings.initial_password'=>'Initial password','settings.new_password'=>'New password','settings.unchanged'=>'Unchanged','settings.active'=>'Active','settings.create'=>'Create','settings.never_logged'=>'Never signed in','settings.seen'=>'Seen :date','settings.invalid_data'=>'Invalid data','settings.keep_admin'=>'You cannot remove your own administrator rights.','settings.password_short'=>'Password too short','settings.password_required'=>'A password of at least 10 characters is required', + 'settings.permissions_title'=>'Settings — Roles and permissions','settings.permissions_saved'=>'Role permissions have been saved.','settings.permissions_intro'=>'Permissions apply to every user belonging to the relevant role.','settings.safeguard'=>'Safeguard:','settings.safeguard_help'=>'Administrators always retain every permission. Only they can manage users, this matrix, settings, backups and the audit log. Medical access also requires permission to view animal records.','settings.module'=>'Module','settings.view'=>'View','settings.edit'=>'Edit','settings.permission_view'=>'View :module — :role','settings.permission_edit'=>'Edit :module — :role','settings.edit_implies_view'=>'Allowing edits automatically enables viewing the module.','settings.save_permissions'=>'Save permissions', + 'permission.role.admin'=>'Administrator','permission.role.responsable'=>'Manager','permission.role.benevole'=>'Volunteer','permission.role.lecture'=>'Read only','permission.module.dashboard'=>'Dashboard','permission.module.animals'=>'Animals','permission.module.medical'=>'Medical','permission.module.care'=>'Care rounds','permission.module.directory'=>'Directory','permission.module.statistics'=>'Statistics','permission.module.administrative'=>'Administration','permission.module.grants'=>'Grants','permission.module_help.dashboard'=>'Daily priorities and situation','permission.module_help.animals'=>'Records, litters, links, media and adoptions','permission.module_help.medical'=>'Notes, treatments, vaccinations and deaths','permission.module_help.care'=>'Tablet rounds and box configuration','permission.module_help.directory'=>'People, organisations and contact details','permission.module_help.statistics'=>'Annual analyses and trends','permission.module_help.administrative'=>'Register, documents and exports','permission.module_help.grants'=>'Reports, applications and supporting documents', + 'settings.species_title'=>'Settings — Species','settings.species_heading'=>'Animal species','settings.species_intro'=>'Shared catalogue for records, imports, registers and statistics. A species in use can be disabled without losing its animals.','settings.species_saved'=>'The species catalogue has been saved.','settings.add_species'=>'Add a species','settings.name'=>'Name','settings.species_example'=>'E.g. Hermann’s tortoise','settings.code'=>'Code','settings.category'=>'Category','settings.icon'=>'Icon','settings.color'=>'Colour','settings.order'=>'Order','settings.active_f'=>'Active','settings.stable_code'=>'Stable code','settings.animal_count'=>':count animal(s)','common.save_short'=>'Save','species.category.animaux_compagnie'=>'Companion animals','species.category.nac'=>'Exotic pets','species.category.animal_ferme'=>'Farm animals','species.category.faune_sauvage'=>'Wildlife','species.category.autre'=>'Other', + 'settings.association_required'=>'The organisation name is required.','settings.invalid_email'=>'Invalid email address.','settings.species_invalid'=>'Invalid name, code, category or colour.','settings.species_not_found'=>'Species not found.','settings.admin_only'=>'Access restricted to administrators.', + 'pricing.title'=>'Settings — Pricing','pricing.heading'=>'Pricing and costs','pricing.intro'=>'Catalogue by organisation. Recorded expenses retain the rate and discount applicable on the day.','pricing.saved'=>'Pricing saved.','pricing.adoption_fees'=>'Adoption fees','pricing.sterilized'=>'Sterilised','pricing.unsterilized'=>'Not sterilised','pricing.discounts'=>'Discounts by organisation','pricing.discounts_help'=>'Summary view of applied reductions.','pricing.no_discount'=>'No discount','pricing.configure'=>'Configure','pricing.no_structure'=>'First add an eligible organisation to the directory.','pricing.discount_for'=>'Discounts · :name','pricing.future_help'=>'Values will apply to future expenses.','pricing.antibiotic_help'=>'Antibiotics remain at 0% until a specific discount is granted.','pricing.save_discounts'=>'Save discounts','pricing.add_label'=>'Add a pricing item','pricing.add_label_help'=>'Medications can be linked to the existing catalogue. Choose “Antibiotic” to disable the discount.','pricing.structure'=>'Organisation','pricing.label'=>'Label','pricing.label_example'=>'E.g. X-ray','pricing.catalog_price'=>'Catalogue price','pricing.discount_group'=>'Discount group','pricing.unit'=>'Unit','pricing.linked_medication'=>'Linked medication','pricing.active_tariff'=>'Active rate','pricing.add_tariff'=>'Add rate','pricing.grid'=>'Pricing table','pricing.discount'=>'Discount','pricing.catalog'=>'Catalogue','pricing.estimated_cost'=>'Estimated cost','pricing.state'=>'Status','pricing.per_unit'=>'per :unit','pricing.active'=>'Active','pricing.inactive'=>'Inactive','pricing.invalid_structure'=>'Choose an eligible organisation and complete the rate.','pricing.invalid_discount_structure'=>'Invalid pricing organisation.', + 'pricing.group.act'=>'Procedure','pricing.group.medication'=>'Non-antibiotic medication','pricing.group.antibiotic'=>'Antibiotic','pricing.group.ape_api'=>'APE / API','pricing.group.analysis'=>'Analysis','pricing.group.none'=>'No discount','expense.add'=>'Add an expense','expense.history_help'=>'The catalogue rate and discount are frozen in the history.','expense.act_product'=>'Procedure or product','expense.choose'=>'Choose','expense.price_after_discount'=>'Price after discount.','expense.quantity'=>'Quantity','expense.comment'=>'Comment','expense.comment_placeholder'=>'Invoice, details…','expense.save'=>'Save expense','expense.unit_estimate'=>'Estimated unit cost: €:price after a :discount% discount.','expense.estimated_vet'=>'Estimated veterinary cost','expense.recorded'=>':count priced expenses recorded','expense.details'=>'View details','expense.expense'=>'Expense','expense.total'=>'Total', + 'backups.title'=>'Settings — Backups','backups.heading'=>'Backups','backups.intro'=>'Database, photos, documents and visual identity in a ZIP archive.','backups.created'=>'The complete backup has been created.','backups.deleted'=>'The backup has been deleted.','backups.failed'=>'Backup failed:','backups.create'=>'Create a backup','backups.create_help'=>'The ZIP contains a consistent copy of the SQLite database, animal media and the organisation’s custom logo.','backups.manifest_help'=>'Every file is listed in a manifest with its SHA-256 fingerprint. The :count most recent backups are retained on the server.','backups.create_now'=>'Create a backup now','backups.restore'=>'Restore a backup','backups.restore_confirm'=>'Restore this backup?\n\nCurrent data, photos and documents will be replaced. An emergency backup will be created automatically before restoration.','backups.archive'=>'Globinours archive (.zip)','backups.understand'=>'I understand that current data will be replaced.','backups.restore_archive'=>'Restore archive','backups.available'=>'Available backups','backups.archive_column'=>'Archive','backups.size'=>'Size','backups.emergency'=>'Emergency','backups.download'=>'Download','backups.delete_confirm'=>'Permanently delete this backup from the server?','common.delete'=>'Delete','backups.none'=>'No ZIP backups yet.','backups.not_found'=>'Backup not found.','backups.invalid_file'=>'Missing or invalid ZIP file.','backups.too_large'=>'The backup exceeds the 1 GB limit.','unit.mb'=>'MB','unit.kb'=>'KB', + 'data.title'=>'Settings — Data and demonstration','data.heading'=>'Data and demonstration','data.intro'=>'Reset business data without deleting users or organisation settings.','data.reset_success'=>'The business database is empty. Backup created: :backup.','data.demo_success'=>'Demonstration data has been loaded. Backup created: :backup.','data.animals'=>'Animals currently recorded','data.contacts'=>'Contacts currently recorded','data.destructive'=>'Destructive operation.','data.destructive_help'=>'A complete ZIP backup is always created before the action. User accounts, permissions, settings, logo and document templates are retained.','data.reset'=>'Reset the project','data.reset_help'=>'Deletes animals, directory, history, medical records, litters, placements, rounds, imports and grant files.','data.confirmation'=>'Confirmation','data.type_reset'=>'Type CLEAR DATABASE','data.exact_phrase'=>'Exact phrase:','data.backup_reset'=>'Back up and clear the database','data.reset_confirm'=>'Clear all business data? A backup will be created before the operation.','data.demo'=>'Load demonstration data','data.demo_help'=>'Clears business data, then creates fictional cats, a litter, a bonded pair, contacts, a foster family, medical records, vaccines and a death.','data.type_demo'=>'Type LOAD DEMO','data.backup_demo'=>'Back up and load demo data','data.demo_confirm'=>'Replace current data with an entirely fictional dataset?','data.confirm_required'=>'The exact confirmation “:phrase” is required.', + 'audit.title'=>'Settings — Audit log','audit.heading'=>'Audit log','audit.intro'=>'The 500 most recent actions.','audit.user'=>'User','audit.action'=>'Action','audit.item'=>'Item','audit.path'=>'Path','audit.none'=>'No actions recorded.','settings.navigation'=>'Settings navigation', + 'audit.deleted_account'=>'Deleted account', + 'asm3.title'=>'Settings — ASM3 migration','asm3.analyzed_success'=>'Dump analysed. Review the report before importing.','asm3.imported_success'=>'ASM3 migration completed. The preliminary backup remains available.','asm3.new'=>'New ASM3 migration','asm3.new_help'=>'Uploading only triggers an analysis. MySQL and PostgreSQL exports are accepted.','asm3.dump'=>'ASM3 dump (.sql or .pgsql)','asm3.upload_help'=>'Maximum 250 MB · private storage · active PHP limit: :limit.','asm3.php_limit'=>'The active PHP limit appears too low. For the built-in server, restart Globinours with:','asm3.analyze'=>'Analyse dump','asm3.analyzed_at'=>'Analysed on :date','asm3.by'=>'by :name','asm3.status.analyzed'=>'Ready to import','asm3.status.completed'=>'Imported','asm3.status.failed'=>'Failed','asm3.animals'=>'Animals','asm3.contacts'=>'Contacts','asm3.movements'=>'Movements','asm3.vaccinations'=>'Vaccinations','asm3.medical_notes'=>'Medical notes','asm3.weights'=>'Weights','asm3.litters'=>'Litters','asm3.to_review'=>':count to review','asm3.media'=>':count media references. Binary files missing from the dump cannot be recreated.','asm3.mode'=>'Import mode','asm3.merge'=>'Merge','asm3.merge_help'=>'Keep current Globinours data.','asm3.replace'=>'Replace business data','asm3.replace_help'=>'Clear animals, directory and operational history. Users, settings and grants are retained.','asm3.confirmation'=>'Confirmation','asm3.type_import'=>'Type IMPORT ASM3','asm3.type_replace'=>'Type REPLACE WITH ASM3','asm3.expected'=>'Expected confirmation:','asm3.transaction_help'=>'A ZIP backup is created before the operation. Cleanup and import form a single transaction: if an error occurs, no changes are retained.','asm3.backup_import'=>'Back up and import','asm3.technical_report'=>'Technical report','asm3.none'=>'No ASM3 analyses yet.','asm3.post_too_large'=>'The dump exceeds the PHP request-size limit (post_max_size: :limit). Restart the server with the configuration shown on this page.','asm3.select_dump'=>'Select an ASM3 SQL dump.','asm3.upload_unverified'=>'PHP could not verify the uploaded file.','asm3.over_250'=>'The dump exceeds 250 MB.','asm3.extension'=>'The file must have a .sql or .pgsql extension.','asm3.storage_unavailable'=>'Temporary storage is unavailable.','asm3.storage_failed'=>'Unable to store the dump.','asm3.no_animal'=>'This file contains no ASM3 animal records.','asm3.confirm_required'=>'The exact confirmation “:phrase” is required.','asm3.analysis_unavailable'=>'Analysis not found or already used.','asm3.dump_altered'=>'The stored dump is missing or has been altered.','asm3.import_failed'=>'Import stopped; no transaction data was retained: :error','asm3.upload_ini'=>'The dump exceeds the active PHP limit (upload_max_filesize: :limit). Restart the server with the configuration shown on this page.','asm3.upload_form'=>'The dump exceeds the size allowed by the form.','asm3.upload_partial'=>'The dump upload was interrupted. Try again.','asm3.tmp_unavailable'=>'The PHP temporary directory is unavailable.','asm3.write_failed'=>'PHP could not write the dump to disk.','asm3.extension_stopped'=>'A PHP extension stopped the upload.','asm3.upload_failed'=>'The dump upload failed (code :code).', + 'asm3.hash_unavailable'=>'Dump fingerprint unavailable.','asm3.hash_failed'=>'Unable to calculate the dump fingerprint.','asm3.unreadable'=>'ASM3 dump not found or unreadable.','asm3.open_failed'=>'Unable to open the ASM3 dump.','asm3.animal_table_missing'=>'The animal table is missing: no animal migration will be possible.','asm3.animals_without_name'=>':count animal(s) without a name.','asm3.animals_without_birth'=>':count animal(s) without a birth date.', + 'death.title'=>'Death of :name','death.record'=>'Record the death of :name','death.intro'=>'This information will feed the shelter history and statistics.','animal.back_record'=>'Back to record','death.important'=>'Important:','death.important_help'=>'A death occurring after adoption or outside the organisation’s responsibility remains recorded, but is not included in shelter mortality figures.','death.date'=>'Date of death','death.main_cause'=>'Main cause','death.euthanized'=>'Death following euthanasia','death.cause_details'=>'Cause details','death.cause_placeholder'=>'Diagnosis, circumstances, condition…','death.context_place'=>'Context and place','death.place'=>'Place','death.place_details'=>'Place details','death.place_placeholder'=>'Room, address or establishment','death.in_care'=>'Death while in shelter care','death.in_care_help'=>'Check if the animal was still cared for by the organisation, including in foster care or at the veterinarian.','death.veterinarian'=>'Veterinarian','common.not_provided_option'=>'Not provided','death.care_body'=>'Aftercare','death.body_disposition'=>'Disposition of the body','death.crematorium'=>'Crematorium','death.crematorium_help'=>'Crematoriums are managed in the directory.','death.cremation_date'=>'Cremation date','death.internal_notes'=>'Internal notes','death.notes_placeholder'=>'Instructions, ashes follow-up, file reference…','death.confirm'=>'Confirm recording this death?','death.save'=>'Record death','death.invalid'=>'Invalid death information.','death.cause_required'=>'Specify the cause of death.','death.cremation_after'=>'The cremation date must be on or after the date of death.','death.place.refuge'=>'At the shelter','death.place.fa'=>'In foster care','death.place.veterinaire'=>'At the veterinarian','death.place.exterieur'=>'Elsewhere','death.place.inconnu'=>'Unknown place','death.place.autre'=>'Other place','death.disposition.pending'=>'To arrange','death.disposition.collective_cremation'=>'Collective cremation','death.disposition.individual_cremation'=>'Individual cremation','death.disposition.returned_family'=>'Body or ashes returned to the family','death.disposition.burial'=>'Burial','death.disposition.other'=>'Other arrangement', + 'inactive.trash'=>'Trash','inactive.archives'=>'Archives','inactive.deleted_on'=>'Deleted on','inactive.archived_on'=>'Archived on','inactive.trash_help'=>'These records are excluded from the listing, active litters and all statistics.','inactive.archive_help'=>'Records removed from current monitoring: adopted, deceased or manually archived animals.','inactive.back'=>'Back to active animals','inactive.search'=>'Search: name, internal code, microchip or municipality','inactive.reason'=>'Reason','inactive.restore_confirm'=>'Restore this record to the active listing?','inactive.restore'=>'Restore','inactive.no_trash'=>'No deleted records match the search.','inactive.no_archive'=>'No archived records match the search.', + 'litter.create'=>'Add a litter','litter.mother'=>'Mother','litter.father'=>'Father','common.optional_parentheses'=>'(optional)','litter.unknown_parent'=>'Unknown parent','litter.birth_date'=>'Date of birth','litter.kitten_type'=>'Kitten type','litter.new_kittens'=>'New kittens','litter.existing_cats'=>'Existing cats','litter.kitten_count'=>'Number of kittens','litter.create_records'=>'Globinours will create a new record for each one.','litter.new_records'=>'New records','litter.auto_name'=>'The automatic name will be “Parent-Baby-N”.','litter.select_siblings'=>'Select siblings','litter.no_new_record'=>'No new records will be created.','litter.search_name_code'=>'Search by name or code…','animal.no_code'=>'No code','animal.archived_badge'=>'Archived','litter.create_litter'=>'Create litter','litter.name_optional'=>'Name (optional)','litter.auto_name_placeholder'=>'Automatic name','animal.coat'=>'Coat','common.to_define'=>'To define','animal.sex_label'=>'Sex','common.remove'=>'Remove','litter.reduce_confirm'=>'Reducing this number will delete information already entered for the last kittens. Continue?','litter.add_existing'=>'Add cats to the litter','litter.summary'=>'Litter of :date · Mother: :mother · Father: :father','common.unknown_f'=>'unknown','common.unknown_m'=>'unknown','common.unknown_date'=>'unknown date','litter.search_cat'=>'Search for a cat','litter.cat_name_code'=>'Cat name or code…','litter.attached_hidden'=>'Cats already attached to a litter are not offered.','animal.sex_to_define'=>'Sex to define','litter.none_available'=>'No cats available to attach.','litter.select_multiple'=>'You can select one or more existing cats.','litter.add_to'=>'Add to litter', + 'bonded.create'=>'Create a bonded group','bonded.intro'=>'Members must be placed or adopted together.','bonded.group_name'=>'Group name','bonded.group_example'=>'E.g. Caramel & Nougat duo','bonded.note_example'=>'E.g. Very bonded, do not separate','bonded.members'=>'Group members','bonded.select_two'=>'Select at least two cats.','bonded.save'=>'Create bonded group', + 'litter.add_title'=>'Add kittens to a litter','litter.need_kitten'=>'Create or select at least one kitten.','litter.different_parents'=>'The two parents must be different.','litter.invalid_parent'=>'Invalid or ineligible parent.','litter.invalid_selected'=>'A selected cat is unavailable, is a parent of the litter or already belongs to another litter.','litter.not_found'=>'Litter not found.','litter.select_one'=>'Select at least one cat.','litter.invalid_existing'=>'A cat is unavailable, is a parent of this litter or already belongs to another litter.','bonded.unavailable'=>'Animal unavailable or already linked.','bonded.need_two'=>'Select at least two cats.','bonded.invalid_member'=>'One of the cats is unavailable or already belongs to a group.', + 'coat.black_white'=>'Black and white bicolour','coat.grey_white'=>'Grey and white bicolour','coat.ginger_white'=>'Ginger and white bicolour','coat.tabby_white'=>'Tabby and white bicolour','coat.grey'=>'Grey','coat.ginger'=>'Ginger','coat.black'=>'Black','coat.chocolate'=>'Chocolate','coat.white'=>'White','coat.calico'=>'Tricolour / Calico','coat.tortoiseshell'=>'Tortoiseshell','coat.colourpoint'=>'Colourpoint / Siamese','coat.tabby'=>'Tabby', + 'access.denied'=>'Access denied','access.edit_denied'=>'Editing not permitted','access.page_denied'=>'Page unavailable','access.explanation_edit'=>'Your role does not allow editing the :module module. If you need this access, an administrator can adjust your group permissions.','access.explanation_view'=>'Your role does not allow viewing the :module module. If you need this access, an administrator can adjust your group permissions.','access.requested'=>'requested','access.back_space'=>'Back to my area','access.previous'=>'Previous page', + 'deworm.catalog_record'=>'Record a dewormer','deworm.product'=>'Dewormer','deworm.add_catalog'=>'Add a new dewormer to the catalogue','deworm.name'=>'Dewormer name','deworm.ingredient'=>'Active ingredient (optional)','deworm.form'=>'Form','deworm.dose_example'=>'E.g. half a tablet','deworm.next_due'=>'Next due date','deworm.auto_age'=>'Calculated automatically from age if left blank.','deworm.apply_litter'=>'Also administer to the mother and every kitten in the litter', + 'adoption.modal_title'=>'Animal adoption','adoption.directory_adopter'=>'Adopter in the directory','adoption.new_adopter'=>'Create a new adopter','adoption.contact_help'=>'Choose an existing person or create their record directly here.','adoption.adopter_name'=>'Adopter name','adoption.date'=>'Adoption date','adoption.validate'=>'Confirm adoption','history.title'=>'History','history.none'=>'No history.','history.event'=>'Event','history.detail'=>'Details','history.user'=>'User', + 'animal.archive_confirm'=>'Archive this animal?\n\nIt will be removed from current monitoring and can be restored from the archives.','animal.trash_reason'=>'Why move this animal to the trash? (optional)','animal.trash_default_reason'=>'Created by mistake','animal.trash_confirm'=>'Confirm moving to trash?\n\nThe record will no longer be counted or displayed, but can still be restored.','animal.natural_death_short'=>'Natural or accidental death','animal.death_age'=>'Age at death:','animal.age_days'=>'day(s)','animal.age_weeks'=>'week(s)','animal.age_months'=>'months','animal.age_years'=>'year(s)','animal.bonded_dissolve_confirm'=>'Dissolve this bonded group? The cat records will be retained.','animal.delete_photo_confirm'=>'Delete this photo?','animal.medication_form_placeholder'=>'Tablet, syrup…','animal.person_placeholder'=>'Volunteer, external worker…','animal.test_notes_placeholder'=>'Laboratory, confirmation, action to take…','animal.condition_placeholder'=>'E.g. kidney failure','animal.reason_placeholder'=>'E.g. check-up, limping, vomiting…', + 'freecat.journey'=>'Community-cat journey','freecat.journey_help'=>'Living site and monitoring after release.','freecat.tracking_state'=>'Monitoring status','freecat.present'=>'Present','freecat.missing'=>'Missing','freecat.moved'=>'Moved','freecat.death_help'=>'Record a death using the dedicated action.','freecat.precise_location'=>'Precise location','common.internal'=>'internal','freecat.address_placeholder'=>'Address or access directions','freecat.caretaker'=>'Contact or feeder','freecat.directory_person'=>'Person recorded in the directory.','freecat.release_date'=>'Release date','animal.rescue_location'=>'Rescue location','animal.geocode_help'=>'Automatically geocoded via OpenStreetMap.','animal.intake_circumstances_placeholder'=>'Rescue context and details provided at intake…', + 'dashboard.grants_check'=>'Grant follow-up requires attention','dashboard.grants_summary'=>':overdue overdue deadlines · :deadlines within 30 days · :incomplete incomplete applications · :documents expired or expiring documents','dashboard.open_grants'=>'Open follow-up','footer.description'=>'Daily shelter management designed to remain humane, fast and clear.','footer.brewed'=>'Brewed with 🧡','footer.brewed_label'=>'Brewed with love','footer.by_brittany'=>'in 2026 by :developer in Brittany','footer.breton_flag'=>'Breton flag','footer.evolutions'=>'Changes','footer.evolutions_title'=>'View changes and roadmap','footer.source'=>'Source code','footer.source_title'=>'View the Globinours Git repository','map.rescue'=>'Rescue','map.missing_coordinates'=>'Coordinates not provided', + 'animal.icad_not_checked'=>'i-CAD: not checked','animal.sterilization_label'=>'Sterilisation','freecat.site'=>'Site or colony','freecat.site_example'=>'E.g. Harbour colony','common.city'=>'Town','freecat.capture_date'=>'Capture date','animal.rescue_location_placeholder'=>'E.g. street, town… (or simply the town)','footer.tagline'=>'Born from Twix’s story, designed to protect those who follow.','map.current'=>'Current location','map.flight_path'=>'Straight-line route','validation.required'=>'This field is required.','validation.password_min'=>'The password must contain at least :count characters.','validation.invalid_format'=>'The entered format is invalid.','validation.check_field'=>'Please check this field.', + 'error.animal_not_found'=>'Animal not found.','error.vet_clinic_required'=>'The veterinarian and their organisation are required.','error.use_death_action'=>'Use the “Record death” action from the animal record.','error.valid_species'=>'Select a valid species from the catalogue.','error.freecat_cats_only'=>'The community-cat journey is restricted to cats.','error.invalid_sterilization_date'=>'Invalid sterilisation date.','error.name_required'=>'Name is required.','error.invalid_placement'=>'Invalid placement event.','error.placement_reason_required'=>'A reason is required for a return or cancellation.','error.adoption_failed'=>'The adoption could not be recorded. No technical data was displayed; consult the server logs.','error.document_storage'=>'Document storage failed.','error.tariff_not_found'=>'Pricing item not found.', + 'pdf.medical_record'=>'Medical record — :name','pdf.generated_on'=>'Generated on :date','pdf.identity'=>'Identity','pdf.internal_code'=>'Internal code','pdf.main_photo'=>'Main photo','pdf.medical_notes'=>'Medical notes','pdf.no_medical_notes'=>'No medical notes.','pdf.content'=>'Content','pdf.clinic_vet'=>'Clinic / Veterinarian','pdf.reason'=>'Reason','pdf.temperature'=>'Temperature','pdf.symptoms'=>'Symptoms','pdf.exam'=>'Examination','pdf.diagnosis'=>'Diagnosis','pdf.plan'=>'Plan','pdf.treatments'=>'Treatments','pdf.no_treatment'=>'No treatment.','pdf.state'=>'Status','pdf.medication'=>'Medication','pdf.start'=>'Start','pdf.end'=>'End','pdf.ongoing'=>'Ongoing','pdf.finished'=>'Finished','pdf.route'=>'Route','pdf.dosage'=>'Dosage','pdf.vaccines'=>'Vaccines','pdf.no_vaccine'=>'No vaccine.','pdf.vaccine'=>'Vaccine','pdf.done_on'=>'Given on','pdf.booster'=>'Booster','pdf.register'=>'Intake and outcome register — :year','pdf.generated_by'=>'Generated by :association on :date at :time','pdf.movements'=>':count movement(s)','pdf.reason_contact'=>'Reason / Contact details','pdf.entry'=>'Intake','pdf.exit'=>'Outcome','pdf.back_register'=>'Back to register','pdf.print'=>'Print / save as PDF','pdf.no_movement'=>'No movement matches these filters.', + 'species.cat'=>'Cat','species.dog'=>'Dog','species.nac'=>'Other pet','pdf.weight'=>'Weight','pdf.lot'=>'Batch', + 'pdf.back_record'=>'Back to record','pdf.health_alert'=>'HEALTH ALERT','pdf.no_main_photo'=>'No main photo.','pdf.summary'=>'Summary','pdf.last_medical'=>'Latest medical entry','pdf.next_vaccine'=>'Next vaccine','pdf.internal_document'=>'Internal document — :association / Globinours','pdf.death_recorded'=>'Death recorded — :date','pdf.unknown_date'=>'unknown date','pdf.death_in_care'=>'Death while in shelter care','pdf.death_outside_care'=>'Death outside shelter care','pdf.crematorium'=>'Crematorium','pdf.photos'=>'Photos', + 'data.confirm_reset_phrase'=>'CLEAR DATABASE','data.confirm_demo_phrase'=>'LOAD DEMO','asm3.confirm_import_phrase'=>'IMPORT ASM3','asm3.confirm_replace_phrase'=>'REPLACE WITH ASM3', + 'service.document.adoption_missing'=>'Adoption contract template not found.','service.document.template_missing'=>'Document template not found.','service.document.copy_failed'=>'Unable to copy the template.','service.document.open_failed'=>'Unable to open the ODT template.','service.document.xml_invalid'=>'Invalid template XML.','service.document.content_invalid'=>'Invalid ODT content.','service.document.update_contract_failed'=>'Unable to update the contract.','service.document.update_failed'=>'Unable to update the document.','service.document.libreoffice_failed'=>'Unable to start LibreOffice.','service.document.temp_failed'=>'Unable to create the temporary directory.', + 'service.image.format'=>'Unsupported format. Use a JPEG, PNG, WebP or GIF image.','service.image.invalid_upload'=>'The uploaded file is invalid.','service.image.directory_failed'=>'Unable to create the media directory.','service.image.optimized_failed'=>'Unable to finalise the optimised image.','service.image.save_failed'=>'Unable to save the image.', + 'service.logo.upload_failed'=>'The logo could not be uploaded.','service.logo.too_large'=>'The logo must not exceed 5 MB.','service.logo.format'=>'The logo must be a PNG, JPEG or WebP image.','service.logo.finalize_failed'=>'Unable to finalise the logo.', + 'service.private_media.invalid_name'=>'Invalid media name.','service.private_media.not_found'=>'Media file not found.','service.private_media.move_failed'=>'Unable to move the media file.', + 'service.export.zip_missing'=>'The ZIP extension is unavailable.','service.export.prepare_failed'=>'Unable to prepare the export.','service.export.unknown_dataset'=>'Unknown dataset.', + 'service.icad.login_failed'=>'i-CAD login failed: the professional page is not authenticated.','service.icad.no_result'=>'No i-CAD result for this identification.','service.icad.token_missing'=>'Missing i-CAD element: :field.','service.icad.form_missing'=>'i-CAD animal search form not found.', + 'error.invalid_expense'=>'Invalid expense.', + 'service.backup.zip_missing'=>'The PHP ZIP extension is unavailable.','service.backup.directory_failed'=>'Unable to create the backup directory.','service.backup.create_failed'=>'Unable to create the ZIP archive.','service.backup.not_found'=>'Backup not found.','service.backup.delete_failed'=>'Unable to delete this backup.','service.backup.restore_missing'=>'Restore archive not found.','service.backup.prepare_failed'=>'Unable to prepare the restore operation.','service.backup.unreadable'=>'The ZIP file is unreadable.','service.backup.too_many_files'=>'This archive contains too many files.','service.backup.too_large'=>'This archive is too large.','service.backup.unsafe_path'=>'Unsafe path detected in the ZIP file.','service.backup.manifest_missing'=>'The Globinours manifest is missing.','service.backup.incompatible'=>'Incompatible backup format.','service.backup.extract_failed'=>'Unable to extract the backup.','service.backup.manifest_path'=>'The manifest contains an unauthorised path.','service.backup.file_altered'=>'Missing or altered file: :name.','service.backup.database_missing'=>'The SQLite database is missing from the backup.','service.backup.database_hash'=>'The SQLite database fingerprint is invalid.','service.backup.database_corrupt'=>'The backed-up database is corrupted.','service.backup.table_missing'=>'Required table missing: :table.','service.backup.secure_failed'=>'Unable to secure :path.','service.backup.restore_path_failed'=>'Unable to restore :path.','service.backup.secure_database_failed'=>'Unable to secure the current database.','service.backup.restore_database_failed'=>'Unable to restore the SQLite database.','service.backup.add_failed'=>'Unable to add :name to the ZIP file.', + 'service.document.invalid_type'=>'Invalid document type.','service.document.pdf_failed'=>'PDF conversion failed. :details','service.image.missing_large'=>'Image missing or too large (20 MB maximum).','service.image.dimensions'=>'The image dimensions are too large.','service.logo.dimensions'=>'The logo dimensions are invalid or too large.','service.icad.invalid_chip'=>'Empty or invalid identification.','service.icad.credentials_missing'=>'i-CAD credentials are missing from the configuration.','service.icad.network_error'=>'i-CAD network error: :error', + 'error.bad_request'=>'Invalid request.','error.missing_id'=>'Missing identifier.','error.method_not_allowed'=>'Method not allowed.','error.dose_required'=>'Dose is required.','error.medication_name_required'=>'Medication name is required.','error.invalid_batch_expiry'=>'Invalid batch expiry date.','error.upload_failed'=>'Upload failed.','error.no_files'=>'No files provided.','error.not_found'=>'Item not found.','error.photo_not_found'=>'Photo not found.','error.invalid_birth_date'=>'Invalid date of birth.','error.invalid_date'=>'Invalid date.','error.invalid_freecat_date'=>'Invalid community-cat journey date.','error.invalid_foster'=>'Invalid foster family.','error.missing_pdf_view'=>'The animal-record print template is missing.', + 'quality.filter.birth'=>'Missing date of birth','quality.filter.sex'=>'Sex not provided','quality.filter.color'=>'Colour not provided','quality.filter.breed'=>'Breed not provided','quality.filter.origin'=>'Origin not provided','quality.filter.chip'=>'Missing identification','quality.filter.registration'=>'i-CAD registration not provided','quality.filter.adoption'=>'Adoption availability not provided','quality.filter.unsterilized'=>'Animal not sterilised','quality.filter.sterilization'=>'Sterilisation not provided','animals.current_care'=>'Currently in care','animals.all_records'=>'All records','animals.title'=>'Animals','litter.group_details'=>'Litter of :date · :count kitten(s)','bonded.default_name'=>'Bonded animals','bonded.group_details'=>':count animal(s) · :state','bonded.adopt_together'=>'To be adopted together','bonded.incomplete'=>'Group currently incomplete', + 'grant.actual_expenses'=>'Actual expenses','grant.actual_income'=>'Actual income','grant.treasury'=>'Cash position','grant.forecast_expenses'=>'Forecast expenses','grant.forecast_income'=>'Forecast income','grant.voluntary_contributions'=>'Voluntary contributions','animal.compatible_dogs'=>'Compatible with dogs','animal.compatible_cats'=>'Compatible with cats','animal.compatible_children'=>'Compatible with children','animal.house_trained'=>'House-trained','common.city_example'=>'E.g. Lesneven','dashboard.overdue_days'=>'Overdue (D+:days)','dashboard.exit_today'=>'Exit today','dashboard.exit_tomorrow'=>'Exit tomorrow','dashboard.exit_days'=>'Exit in :days days','animal.vaccine_booster'=>'Booster','chart.weight'=>'Weight (kg)','chart.date'=>'Date', + 'project.evolutions_description'=>'History of major changes and Globinours roadmap.', + 'error.access_denied'=>'Access denied.','deworm.invalid_date'=>'Invalid deworming date.','deworm.name_required'=>'Dewormer name is required.','deworm.invalid_next_due'=>'Invalid next due date.', + 'statistics.cost_scope_animals'=>'Animals in scope','statistics.cost_with_expense'=>'With at least one expense','statistics.cost_avg_all'=>'Average per animal cared for','statistics.cost_avg_spending'=>'Average per animal with expenses','statistics.cost_median'=>'Median among animals with expenses','statistics.cost_metric_help'=>'The overall average includes animals with no recorded expense. The median limits the impact of exceptionally costly veterinary cases.','statistics.cost_by_city'=>'Veterinary cost by town of origin','statistics.cost_by_city_help'=>'Animals cared for or generating an expense during the period. The town comes from the rescue location.','statistics.municipality'=>'Town','statistics.expense_animals'=>'Animals with expenses','statistics.total_cost'=>'Total cost','statistics.average_all'=>'Overall average','statistics.average_with_expense'=>'Average with expenses','statistics.median'=>'Median','statistics.cost_city_open'=>'Open statistics for :city','statistics.cost_no_city'=>'No expense can yet be linked to a town.','statistics.animals_short'=>':count animal(s)','statistics.structure_cost_help'=>'Amounts and average among animals billed by this organisation. Select its name to open the period details.','statistics.open_structure_cost'=>'View financial details for :structure','statistics.category_cost_help'=>'Amounts and average among animals concerned by this category.', + 'medical_document.prescriptions'=>'Prescriptions','medical_document.private_documents'=>'Private medical documents, available only to authorised roles.','medical_document.no_prescription'=>'No prescription recorded.','medical_document.add_prescription'=>'Add a prescription','medical_document.prescription_added'=>'Prescription added','medical_document.prescribed_on'=>'Prescription date','medical_document.file'=>'Scan or photo','medical_document.private_hint'=>'PDF or photo, 20 MB maximum. The document will be kept in the private medical area.','medical_document.link_treatments'=>'Link to current treatments','medical_document.status_active'=>'Current','medical_document.status_finished'=>'Finished','medical_document.status_replaced'=>'Replaced', + 'medical_document.lab_results'=>'Lab results','medical_document.lab_help'=>'Structured values and changes over time.','medical_document.no_analysis'=>'No lab report recorded.','medical_document.add_analysis'=>'Add lab results','medical_document.analysis_added'=>'Lab results added','medical_document.analysis'=>'Lab report','medical_document.sampled_on'=>'Sample date','medical_document.analysis_type'=>'Analysis type','medical_document.analysis_example'=>'E.g. renal panel','medical_document.laboratory'=>'Laboratory','medical_document.report_optional'=>'Report (optional)','medical_document.open_report'=>'Open report','medical_document.measured_parameters'=>'Measured parameters','medical_document.parameters_help'=>'A separate chart will be created for each parameter and unit.','medical_document.parameter'=>'Parameter','medical_document.value'=>'Value','medical_document.unit'=>'Unit','medical_document.reference'=>'Reference range','medical_document.reference_min'=>'Lower reference','medical_document.reference_max'=>'Upper reference','medical_document.template'=>'Panel template…','medical_document.template_renal'=>'Renal panel','medical_document.template_hepatic'=>'Liver panel','medical_document.template_thyroid'=>'Thyroid panel','medical_document.evolution'=>'Lab result trends','medical_document.graph_help'=>'Blue: below range · green: in range · red: above. Dashed lines follow the entered reference values.','medical_document.invalid_data'=>'Invalid animal or date.','medical_document.invalid_result'=>'Each entered result must have a parameter and a numeric value.','medical_document.result_or_document_required'=>'Add at least one result or a report.','medical_document.file_required'=>'Add a scan or photo of the prescription.','medical_document.file_invalid'=>'The file is missing, incomplete, or larger than 20 MB.','medical_document.file_type'=>'Accepted formats: PDF, JPEG, PNG, and WebP.','medical_document.storage_failed'=>'The medical document could not be saved.', + 'settings.health'=>'Globinours health','install.requirements'=>'Technical requirements','install.requirements_help'=>'Every required item must pass before the first installation can be created. Warnings indicate optional or recommended features.','install.public_directory'=>'Public directory to configure in the web server','install.association'=>'Association identity','install.administrator'=>'First administrator','install.requirements_block'=>'Not all required components are available.','install.runtime_missing'=>'The server does not yet provide the extensions required by Globinours.','install.association_required'=>'The association name is required.','install.complete'=>'Installation complete. Globinours is ready; review the server status below.','health.page_title'=>'Settings — Globinours health','health.title'=>'Globinours health','health.intro'=>'Read-only diagnosis of the server, database, files, and backups.','health.refresh'=>'Run diagnosis again','health.ok'=>'Successful checks','health.warnings'=>'Warnings','health.errors'=>'Errors','health.group_environment'=>'PHP environment','health.group_database'=>'Database','health.group_storage'=>'Storage','health.group_continuity'=>'Continuity and backups','health.group_files'=>'File consistency','health.group_security'=>'Production configuration','health.sqlite_integrity'=>'SQLite integrity','health.sqlite_ok'=>'The database reports “ok”.','health.foreign_keys'=>'Data relationships','health.no_fk_error'=>'No invalid relationship.','health.fk_errors'=>':count invalid relationship(s).','health.migrations'=>'Database migrations','health.migrations_ok'=>':count migrations applied, none pending.','health.migrations_pending'=>':count pending migration(s).','health.database_file'=>'Database file','health.file_missing'=>'File missing.','health.disk_space'=>'Available disk space','health.unknown'=>'Information unavailable','health.last_backup'=>'Latest backup','health.no_backup'=>'No Globinours backup is available.','health.backup_age'=>'Latest backup on :date, :days day(s) ago.','health.missing_photos'=>'Referenced photos missing','health.orphan_photos'=>'Photos with no database reference','health.file_count'=>':count file(s).','health.display_errors'=>'PHP error display','health.production_value'=>'Production mode · display_errors = :value','health.development_mode'=>'Development mode enabled.','health.error_log'=>'PHP error logging','health.system_log'=>'System log configured by the hosting provider.','health.logging_disabled'=>'Error logging is disabled.','health.versions'=>'PHP :php · SQLite :sqlite','health.generated'=>'Diagnosis generated on :date','health.read_only'=>'This diagnosis does not modify any data.', + 'settings.notifications'=>'Notifications','notification.page_title'=>'Settings — Notifications','notification.title'=>'Notifications and summaries','notification.intro'=>'Alerts remain visible in Globinours and can be grouped into a daily or weekly email.','notification.saved'=>'Notification settings were saved.','notification.sent'=>'The summary was sent.','notification.mail_failed'=>'The server could not send the email. Check its mail configuration.','notification.invalid_recipient'=>'Enter a valid email address.','notification.email_digest'=>'Automatic email summary','notification.enable_email'=>'Enable automatic sending','notification.recipient'=>'Recipient','notification.recipient_help'=>'The association’s general address is used when left empty.','notification.frequency'=>'Frequency','notification.daily'=>'Daily','notification.weekly'=>'Weekly','notification.current_alerts'=>'Current alerts','notification.no_alert'=>'No current alert.','notification.send_test'=>'Send now','notification.cron_help'=>'To automate delivery, run regularly:','notification.dashboard_title'=>'Items to watch','notification.configure'=>'Configure','notification.vaccines_overdue'=>'Overdue vaccines','notification.treatments_overdue'=>'Treatments to close','notification.dewormings_due'=>'Deworming due or within 7 days','notification.backup_old'=>'Backup is too old','notification.email_subject'=>'Alert summary','notification.email_intro'=>'Here is the automatic summary for :association.','notification.email_footer'=>'Message generated automatically by Globinours.', + 'health.recent_errors'=>'Recent PHP errors','health.recent_error_count'=>':count error line(s) over the last seven days.','health.no_recent_error'=>'No error logged over the last seven days.', + 'directory.not_found'=>'Contact not found.','directory.adopted_animals'=>'Adopted animals','directory.back'=>'Back to directory','directory.no_contact_details'=>'No contact details provided.','directory.total_invoiced'=>'Total paid or invoiced','directory.expense_count'=>':count recorded expense(s)','directory.fostered_animals'=>'Animals fostered','directory.deposited_animals'=>'Animals brought in','directory.linked_people'=>'Linked people','directory.expense_history'=>'Expense history','directory.back_statistics'=>'Back to statistics','animal.animal'=>'Animal','common.label'=>'Label','common.amount'=>'Amount','common.total'=>'Total', + 'directory.open_contact'=>'Open :name’s profile','animal.archive_edit_help'=>'Archived record correction mode: identity and record details can be corrected. Status, location and archived state will be preserved.', + 'settings.inventory'=>'Pharmacy and stock','inventory.title'=>'Pharmacy and stock','inventory.intro'=>'Track medicines, dewormers and vaccines separately, including batches, expiry dates, costs and movements.','inventory.saved'=>'The stock movement was recorded.','inventory.alerts'=>'Items to watch','inventory.low_stock'=>'low stock item(s)','inventory.expiring'=>'expired batch(es) or due within 30 days','inventory.add_product'=>'Add a product','inventory.category'=>'Category','inventory.medications'=>'Medicines','inventory.dewormers'=>'Dewormers','inventory.vaccines'=>'Vaccines','inventory.medication'=>'Medicine','inventory.dewormer'=>'Dewormer','inventory.vaccine'=>'Vaccine','inventory.unit'=>'Stock unit','inventory.minimum'=>'Minimum threshold','inventory.receive'=>'Receive a batch','inventory.product'=>'Product','inventory.quantity'=>'Quantity','inventory.unit_cost'=>'Unit price (€)','inventory.expiry'=>'Expiry date','inventory.supplier'=>'Supplier','inventory.remove_stock'=>'Record stock out','inventory.batch'=>'Available batch','inventory.no_batch'=>'no batch number','inventory.movement'=>'Movement','inventory.administration'=>'Administration','inventory.loss'=>'Loss / damage','inventory.correction'=>'Correction','inventory.entry'=>'Entry','inventory.record'=>'Record','inventory.stock'=>'Available stock','inventory.next_expiry'=>'Next expiry','inventory.empty'=>'No stocked product.','inventory.history'=>'Movement history','inventory.invalid_product'=>'Product, category and unit are required.','inventory.invalid_quantity'=>'Quantity must be greater than zero.','inventory.invalid_date'=>'The expiry date is invalid.','inventory.invalid_movement'=>'The stock movement is invalid.','inventory.batch_not_found'=>'Batch not found.','inventory.insufficient_stock'=>'The batch does not contain enough stock.','inventory.receipt'=>'Batch receipt', + 'backups.automatic'=>'Automatic backups','backups.enable_schedule'=>'Enable scheduling','backups.schedule_help'=>'The server must regularly run the displayed command. An archive is accepted only after checking its hashes and SQLite database.','backups.last_verified'=>'Last successful verification','backups.verify'=>'Verify','backups.verified'=>'The archive, manifest and SQLite database are valid.','backups.schedule_saved'=>'Backup scheduling was saved.', + 'privacy.other_contacts'=>'Other contacts','privacy.adopters'=>'Adopters','privacy.fosters'=>'Foster families','privacy.volunteers'=>'Volunteers','privacy.years'=>'years','privacy.months'=>'months','privacy.access_retention'=>'Private access log','privacy.applied_policy'=>'Applied rule','privacy.active_link'=>'Record still active','privacy.reviewed'=>'Export and links reviewed','privacy.review_required'=>'Confirm that exports and links have been reviewed before anonymisation.', + 'notification.inventory_low'=>'Products below stock threshold','notification.inventory_expiry'=>'Batches expired or due within 30 days', + 'nav.agenda'=>'Calendar','permission.module.agenda'=>'Calendar','permission.module_help.agenda'=>'Shared appointments, transport, visits and tasks', + 'agenda.title'=>'Shared calendar','agenda.intro'=>'Appointments, transport, visits and tasks available to the whole team.','agenda.add'=>'Add an event','agenda.edit'=>'Edit event','agenda.saved'=>'The calendar was updated.','agenda.invalid'=>'Event information is incomplete.','agenda.invalid_time'=>'Enter a valid time or select all day.','agenda.invalid_end'=>'The end date or time is invalid.','agenda.end_before_start'=>'The end cannot be before the start.','agenda.invalid_repeat'=>'The repeat end date is invalid.','agenda.upcoming'=>'Next 30 days','agenda.none'=>'No planned event.','agenda.event'=>'Event','agenda.type'=>'Type','agenda.type_appointment'=>'Appointment','agenda.type_veterinary'=>'Veterinary appointment','agenda.type_transport'=>'Transport','agenda.type_pre_adoption'=>'Pre-adoption visit','agenda.type_task'=>'Task','agenda.type_other'=>'Other','agenda.time'=>'Time','agenda.end_date'=>'End date','agenda.end_time'=>'End time','agenda.all_day'=>'All day','agenda.priority'=>'Priority','agenda.priority_low'=>'Low','agenda.priority_normal'=>'Normal','agenda.priority_high'=>'High','agenda.priority_urgent'=>'Urgent','agenda.assigned'=>'Assignee','agenda.reminder'=>'Reminder','agenda.reminder_0'=>'At event time','agenda.reminder_30'=>'30 minutes before','agenda.reminder_60'=>'1 hour before','agenda.reminder_1440'=>'1 day before','agenda.reminder_2880'=>'2 days before','agenda.reminder_10080'=>'1 week before','agenda.contact'=>'Linked contact','agenda.location'=>'Location','agenda.repeat'=>'Repeat','agenda.repeat_none'=>'None','agenda.repeat_daily'=>'Daily','agenda.repeat_weekly'=>'Weekly','agenda.repeat_monthly'=>'Monthly','agenda.repeat_until'=>'Until','agenda.status'=>'Status','agenda.status_planned'=>'Planned','agenda.status_completed'=>'Completed','agenda.status_cancelled'=>'Cancelled','agenda.mon'=>'Mon','agenda.tue'=>'Tue','agenda.wed'=>'Wed','agenda.thu'=>'Thu','agenda.fri'=>'Fri','agenda.sat'=>'Sat','agenda.sun'=>'Sun', + 'dashboard.agenda_due'=>'calendar items due','notification.agenda_due'=>'Events or tasks due within 24 hours','agenda.legend'=>'Colour legend','agenda.legend_organization'=>'Organisation','agenda.legend_medical'=>'Medical follow-up','agenda.vaccine_reminder'=>'Vaccine reminder: :vaccine','agenda.type_vaccine'=>'Vaccine reminder','agenda.open_animal'=>'Open record','agenda.deworming'=>'Dewormer','agenda.deworming_reminder'=>'Deworming: :product','agenda.type_deworming'=>'Next deworming','agenda.treatment_start'=>'Treatment starts: :medication','agenda.type_treatment_start'=>'Treatment start','agenda.treatment_end'=>'Treatment ends: :medication','agenda.type_treatment_end'=>'Treatment end', + 'agenda.view'=>'Display mode','agenda.month_view'=>'Month','agenda.week_view'=>'Week','agenda.today'=>'Today','agenda.week_range'=>'From :start to :end','agenda.export_ics'=>'Export .ics','agenda.subscription'=>'Calendar subscription','agenda.subscription_help'=>'Add this private link to Google Calendar, Outlook, Apple Calendar or Thunderbird to synchronise events automatically. Anyone with this link can read the calendar: do not share it publicly.','agenda.feed_active'=>'Active','agenda.feed_inactive'=>'Inactive','agenda.copy_now'=>'Copy this link now: it will no longer be displayed after leaving this page.','agenda.feed_hidden'=>'The private link is active and hidden. Regenerate it only when you need a new address.','agenda.create_feed'=>'Create my private link','agenda.regenerate_feed'=>'Regenerate link','agenda.revoke_feed'=>'Revoke link','agenda.revoke_confirm'=>'Revoke this subscription immediately? Configured calendars will no longer synchronise.','agenda.feed_invalid'=>'Subscription not found or revoked.','common.copy'=>'Copy', + 'agenda.scope'=>'Displayed calendar','agenda.scope_all'=>'Whole team','agenda.scope_mine'=>'My calendar','agenda.volunteer'=>'Volunteer','agenda.volunteers'=>'Assigned volunteers','agenda.volunteers_help'=>'Hold Ctrl or Cmd to select several volunteers.','agenda.show_medical'=>'Show automatic medical deadlines','agenda.search_placeholder'=>'Task, animal, place or person…','agenda.conflict_saved'=>'Event saved, but this person already has another task at that time.','agenda.add_on_date'=>'Add an event on','common.all'=>'All', + 'agenda.type_'=>'Type','agenda.status_'=>'Status','agenda.priority_'=>'Priority','agenda.reminder_'=>'Reminder','agenda.repeat_'=>'Repeat','agenda.legend_'=>'Legend','common.description'=>'Description','common.active'=>'Active', + 'settings.rooms'=>'Rooms and boxes','rooms.title'=>'Room and box configuration','rooms.saved'=>'Room configuration saved.','rooms.help'=>'Existing rooms keep their internal code, so assignments and history remain unchanged. Disable an unused room rather than deleting its history.','rooms.invalid'=>'Invalid room configuration.','rooms.color'=>'Colour','rooms.type'=>'Organisation','rooms.collective'=>'Shared room','rooms.with_boxes'=>'Room with boxes','rooms.order'=>'Order','rooms.boxes'=>'Boxes','rooms.one_box_per_line'=>'One box name per line','rooms.boxes_help'=>'The number of lines defines the number of boxes. Removing a line disables the box without erasing history.','rooms.bulk_validation'=>'Allow “Everyone is well”','rooms.add'=>'Add a room', + 'surgery.invalid_data'=>'The surgery information is invalid.', + 'accounting.required_fields'=>'The reference, date and total amount including tax are required.', + 'accounting.document_invalid'=>'The document is invalid or exceeds 20 MB.', + 'accounting.document_format'=>'Allowed formats: PDF, JPEG, PNG and WebP.', + 'accounting.document_save_failed'=>'The document could not be saved.', + 'search.title'=>'Global search','search.placeholder'=>'Animal, contact, appointment or invoice…','search.action'=>'Search','search.hint'=>'Enter at least two characters.','search.none'=>'No accessible result.','search.open'=>'Open global search','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animals','search.group.directory'=>'Directory','search.group.agenda'=>'Calendar','search.group.accounting'=>'Accounting','search.loading'=>'Searching…','search.all_results'=>'Show all results','search.error'=>'Search is temporarily unavailable.', +]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php new file mode 100644 index 0000000..bcfec50 --- /dev/null +++ b/resources/lang/fr.php @@ -0,0 +1,122 @@ +'Tarif fixe','pricing.group.surgery_discount'=>'Actes chirurgicaux', + 'statistics.location_coverage'=>'Fiabilité des communes d’origine','statistics.location_documented'=>':count documentées','statistics.location_inferred'=>':count déduites par fratrie','statistics.location_born_in_care'=>':count nés au refuge','statistics.location_unknown'=>':count non renseignées','statistics.location_coverage_help'=>'Les naissances au refuge sont séparées des communes inconnues. Les communes déduites reposent sur une portée ou une fratrie documentée. Les mentions ambiguës (vétérinaire, adresse d’un tiers) restent volontairement exclues.', + 'error.unexpected_reference'=>'Une erreur interne est survenue. Référence : :reference.', + 'auth.remember_device'=>'Rester connecté sur cet appareil','auth.remember_device_help'=>'Une session révocable sera conservée pendant 90 jours. Le mot de passe n’est jamais enregistré.','devices.title'=>'Appareils connectés','devices.intro'=>'Sessions persistantes autorisées pour votre compte. Révoquez immédiatement un appareil perdu ou partagé.','devices.revoke_all'=>'Tout déconnecter','devices.revoke_all_confirm'=>'Déconnecter tous les appareils, y compris celui-ci ?','devices.revoked'=>'Appareil déconnecté.','devices.this_device'=>'Cet appareil','devices.active'=>'Actif','devices.inactive'=>'Révoqué ou expiré','devices.created'=>'Ajouté le :date','devices.last_used'=>'Dernière utilisation : :date','devices.expires'=>'Expiration : :date','devices.revoke'=>'Révoquer','devices.none'=>'Aucun appareil de confiance enregistré.', + 'public_site.to_complete'=>'À compléter','public_site.default_legal'=>"Éditeur du site\n\nLe présent site est édité par :association, association déclarée sous le numéro RNA :rna et identifiée sous le numéro SIRET :siret.\nSiège social : :address\nContact : :email\n\nDirection de la publication\n\nLa direction de la publication est assurée par la représentante ou le représentant légal de l’association. Nom : [À compléter].\n\nHébergement\n\nHébergeur : [raison sociale, adresse et coordonnées à compléter].\n\nPropriété intellectuelle\n\nSauf mention contraire, les textes, photographies et éléments graphiques de ce site appartiennent à l’association ou sont utilisés avec l’autorisation de leurs titulaires. Toute réutilisation doit respecter les droits applicables et l’image des animaux et des personnes présentées.\n\nResponsabilité\n\nL’association s’efforce de fournir des informations exactes et à jour. Les disponibilités et caractéristiques des animaux peuvent évoluer ; leur publication ne vaut pas acceptation automatique d’une adoption.",'public_site.default_privacy'=>"Responsable du traitement\n\nLe responsable du traitement est :association.\nAdresse : :address\nContact pour l’exercice de vos droits : :email\n\nDonnées concernées et finalités\n\nLe site public présente les animaux proposés à l’adoption et les moyens de contacter l’association. Lorsque vous contactez l’association, les informations que vous transmettez peuvent être utilisées pour répondre à votre demande, étudier une adoption, gérer un don ou assurer le suivi administratif correspondant.\n\nDestinataires et conservation\n\nLes données sont accessibles aux membres autorisés de l’association et, lorsque cela est nécessaire, à ses partenaires habilités. Elles ne sont pas vendues. Elles sont conservées pendant la durée nécessaire à la finalité poursuivie et aux obligations légales ou comptables applicables, puis supprimées ou anonymisées selon la politique de l’association.\n\nCookies et services externes\n\nLe site public n’utilise pas de dispositif publicitaire ou de mesure d’audience intégré par Globinours. La carte de contact utilise les tuiles OpenStreetMap ; son affichage entraîne une connexion aux serveurs cartographiques concernés et la transmission de données techniques telles que l’adresse IP. La partie de gestion utilise un cookie de session indispensable à l’authentification.\n\nVos droits\n\nVous pouvez demander l’accès, la rectification, l’effacement ou la limitation de vos données et, lorsque les conditions sont réunies, vous opposer au traitement ou demander leur portabilité. Adressez votre demande à :email. Vous pouvez également saisir l’autorité de contrôle compétente, notamment la CNIL en France.\n\nMise à jour\n\nCette politique doit être adaptée aux traitements réellement effectués par l’association et mise à jour lorsqu’ils évoluent.", + 'privacy.heading'=>'Confidentialité et conservation','privacy.no_auto_delete'=>'Aucune suppression automatique : la liste sert à une revue humaine.','privacy.reason'=>'Motif','privacy.none_review'=>'Aucun contact à réexaminer.','privacy.access_register'=>'Registre des accès privés','privacy.contact_not_found'=>'Contact introuvable.','privacy.export_failed'=>'Impossible de produire l’export.','privacy.reason_required'=>'Le motif est obligatoire.','privacy.anonymous_contact'=>'Contact anonymisé', + 'settings.privacy'=>'Confidentialité / RGPD','privacy.title'=>'Confidentialité et conservation','privacy.intro'=>'Pilotez la conservation des coordonnées, les exports et les accès aux fichiers privés.','privacy.saved'=>'Politique de conservation enregistrée.','privacy.retention'=>'Durée de conservation','privacy.retention_years'=>'Durée après la dernière activité (années)','privacy.retention_help'=>'Cette règle produit une liste de contrôle : aucune donnée n’est supprimée automatiquement.','privacy.review'=>'Contacts à réexaminer','privacy.no_review'=>'Aucun contact ne dépasse actuellement cette durée.','privacy.last_activity'=>'Dernière activité','privacy.export'=>'Exporter','privacy.anonymize'=>'Anonymiser','privacy.anonymize_reason'=>'Motif obligatoire','privacy.anonymize_confirm'=>'Anonymiser ce contact ? Une sauvegarde sera créée avant l’opération.','privacy.access_log'=>'Accès aux fichiers privés','privacy.no_access'=>'Aucun accès enregistré.','privacy.resource'=>'Ressource','privacy.actor'=>'Utilisateur','privacy.action'=>'Action','privacy.date'=>'Date','privacy.anonymized'=>'Contact anonymisé. Sauvegarde : :backup','public_site.legal'=>'Mentions légales','public_site.privacy'=>'Confidentialité','public_site.legal_privacy'=>'Mentions légales et confidentialité','public_site.legal_hint'=>'Ces textes sont publiés sur deux pages dédiées. Faites-les valider selon la situation de votre association.', + 'settings.group_general'=>'Configuration','settings.group_business'=>'Référentiels métier','settings.group_access'=>'Accès','settings.group_maintenance'=>'Maintenance','public_site.main_address'=>'Page affichée à l’adresse principale','public_site.routing_management'=>'Globinours à la racine','public_site.routing_management_help'=>'La gestion reste sur / et le mini-site sur /site.','public_site.routing_public'=>'Site public à la racine','public_site.routing_public_help'=>'Le site utilise / et l’accès à la gestion se fait par /gestion.','public_site.routing_no_server'=>'Ce choix est géré par Globinours : aucune modification Apache ou Nginx n’est nécessaire.', + 'public_site.published'=>'Publié sur le site','public_site.waiting_publication'=>'Publication en attente de disponibilité', + 'settings.public_site'=>'Site public','common.title'=>'Titre','common.text'=>'Texte','public_site.unavailable'=>'Site indisponible','public_site.unavailable_help'=>'Le site public n’est pas encore disponible.','public_site.not_found'=>'Page introuvable','public_site.animals'=>'Nos animaux','public_site.contact'=>'Contact','public_site.home'=>'Accueil','public_site.donation'=>'Dons','public_site.navigation'=>'Navigation principale','public_site.powered_by'=>'Propulsé par','public_site.rna'=>'RNA','public_site.siret'=>'SIRET','public_site.map_title'=>'Nous trouver','public_site.map_marker'=>'Adresse de l’association','public_site.discover'=>'Découvrir les animaux','public_site.latest_animals'=>'Ils cherchent une famille','public_site.no_animals'=>'Aucun animal n’est actuellement publié dans cette catégorie.','public_site.all_species'=>'Toutes les espèces','public_site.sterilized'=>'Stérilisé','public_site.age_unknown'=>'Âge non renseigné','public_site.months'=>':count mois','public_site.years'=>':count an(s)','public_site.years_months'=>':years an(s) et :months mois','public_site.back_animals'=>'Retour aux animaux','public_site.age'=>'Âge','public_site.breed'=>'Race','public_site.coat'=>'Robe','public_site.dogs'=>'OK chiens','public_site.cats'=>'OK chats','public_site.children'=>'OK enfants','public_site.clean'=>'Propre','public_site.ask_adoption'=>'Nous contacter pour son adoption','public_site.make_donation'=>'Faire un don', + 'public_site.settings_title'=>'Paramètres — Site public','public_site.settings_heading'=>'Mini-site public','public_site.settings_intro'=>'Un site léger alimenté uniquement par les informations volontairement publiées.','public_site.preview'=>'Voir le site','public_site.saved'=>'La configuration du site public a été enregistrée.','public_site.activation_style'=>'Activation et apparence','public_site.enable'=>'Activer le site public','public_site.enable_help'=>'Lorsqu’il est désactivé, aucune page publique ni fiche animale n’est consultable.','public_site.theme'=>'Thème visuel','public_site.theme_warm'=>'Chaleureux','public_site.theme_nature'=>'Naturel','public_site.theme_minimal'=>'Minimal','public_site.free_page'=>'Page libre','public_site.show_free_page'=>'Afficher cette page dans le menu','public_site.donation_url'=>'Lien vers la plateforme de don','public_site.contact_reuse'=>'L’adresse, le téléphone, l’e-mail et le logo viennent des paramètres généraux de l’association.','public_site.invalid_url'=>'Un des liens saisis est invalide.','public_site.publish_animal'=>'Publier cet animal sur le mini-site','public_site.publish_animal_help'=>'La fiche ne sera visible que si l’animal est également disponible à l’adoption. Seules les photos publiques et le texte ci-dessous seront utilisés.','public_site.public_description'=>'Présentation publique','public_site.public_description_example'=>'Son caractère, ses habitudes et la famille qu’il recherche…', + 'common.open'=>'Ouvrir','settings.lab_references'=>'Références biologiques','medical_document.menu_prescription'=>'Ordonnance','medical_document.menu_analysis'=>'Résultats d’analyses','lab_reference.title'=>'Paramètres — Références biologiques','lab_reference.heading'=>'Références biologiques','lab_reference.intro'=>'Intervalles par analyseur et par espèce utilisés pour préremplir les analyses et interpréter leurs courbes.','lab_reference.saved'=>'Les références biologiques ont été enregistrées.','lab_reference.priority_title'=>'Référence locale prioritaire.','lab_reference.priority_help'=>'Si votre analyseur ou votre clinique imprime des bornes différentes, reportez-les ici : elles priment sur les valeurs constructeur. Une borne vide ne déclenche aucune interprétation colorée.','lab_reference.manufacturer_ranges'=>'Valeurs initiales issues des intervalles constructeur ; elles restent modifiables.','lab_reference.code'=>'Code','lab_reference.source'=>'Source / remarque','lab_reference.invalid'=>'Référentiel invalide : vérifiez les libellés, unités et bornes.', + 'nav.dashboard'=>'Dashboard','nav.animals'=>'Animaux','nav.directory'=>'Annuaire','nav.care_round'=>'Tournée','nav.statistics'=>'Statistiques','nav.administrative'=>'Administratif','nav.grants'=>'Subventions', + 'account.settings'=>'Compte et paramètres','account.settings_heading'=>'Paramètres','account.logout'=>'Déconnexion', + 'settings.general'=>'Général','settings.users'=>'Utilisateurs','settings.permissions'=>'Rôles et permissions','settings.species'=>'Espèces','settings.pricing'=>'Tarifs','settings.backups'=>'Sauvegardes','settings.audit'=>'Journal d’audit','settings.asm3'=>'Migration ASM3','settings.data'=>'Données et démo', + 'settings.general_title'=>'Paramètres généraux','settings.general_intro'=>'Identité de l’association et règles communes de Globinours.','settings.language'=>'Langue de l’application','settings.language_help'=>'Langue générale de l’interface. Le français reste utilisé lorsqu’une traduction manque.','settings.save'=>'Enregistrer les paramètres', + 'meta.dashboard'=>'Vue d’ensemble, priorités médicales et activité récente du refuge.','meta.statistics'=>'Activité annuelle, évolutions et profils des animaux pris en charge.','meta.administrative'=>'Registre administratif du refuge Globinours.','meta.animals'=>'Gestion et suivi des animaux du refuge Globinours.','meta.default'=>'Globinours — Né de l’histoire de Twix, conçu pour protéger les suivants.', + 'auth.tagline'=>'Né de l’histoire de Twix, conçu pour protéger les suivants.','auth.setup'=>'Créer le compte administrateur','auth.login'=>'Connexion','auth.display_name'=>'Nom affiché','auth.username'=>'Identifiant','auth.password'=>'Mot de passe','auth.password_confirm'=>'Confirmer le mot de passe','auth.create'=>'Créer le compte et continuer','auth.sign_in'=>'Se connecter','auth.restore_success'=>'Sauvegarde restaurée. Reconnectez-vous avec les comptes présents dans cette sauvegarde.','auth.fix_fields'=>'Veuillez corriger les champs indiqués.','auth.required'=>'Ce champ est obligatoire.','auth.password_min'=>'Le mot de passe doit contenir au moins :count caractères.','auth.invalid_format'=>'Le format saisi n’est pas valide.','auth.check_field'=>'Veuillez vérifier ce champ.','auth.password_mismatch'=>'Les mots de passe ne correspondent pas.','auth.session_expired'=>'La session a expiré. Rechargez la page.','auth.invalid_username'=>'Identifiant invalide (3 à 40 caractères).','auth.display_required'=>'Le nom affiché est requis.','auth.invalid_credentials'=>'Identifiant ou mot de passe incorrect.','auth.first_setup'=>'Première installation', + 'dashboard.today_priorities'=>'Priorités du jour','dashboard.watch_points'=>':count point(s) à surveiller','dashboard.controlled'=>'Tout est sous contrôle','dashboard.open_round'=>'Ouvrir la tournée','dashboard.active_alerts'=>'alertes actives','dashboard.active_treatments'=>'traitements en cours','dashboard.morning_round'=>'tournée du matin','dashboard.evening_round'=>'tournée du soir','dashboard.vaccines_7'=>'vaccins sous 7 jours','dashboard.quick_search'=>'Recherche rapide (nom, code, puce)','common.search'=>'Rechercher','dashboard.overview'=>'Vue d’ensemble','dashboard.overview_help'=>'Les chiffres du refuge aujourd’hui','dashboard.total'=>'Total','dashboard.shelter'=>'Refuge','dashboard.foster'=>'Famille d’accueil','dashboard.medical_pole'=>'Pôle médical', + 'animals.search_placeholder'=>'Recherche : nom, code interne, puce','animals.all_species'=>'Toutes les espèces','animals.all_availability'=>'Toute disponibilité','animals.available'=>'Disponible à l’adoption','animals.unavailable'=>'Non disponible','animals.to_define'=>'À définir','animals.add'=>'Ajouter un animal','animals.add_litter'=>'Ajouter une portée','animals.table_view'=>'Vue tableau','animals.mosaic_view'=>'Vue mosaïque','animals.archived'=>'Animaux archivés','animals.trash'=>'Corbeille','animals.photo'=>'Photo','animals.name'=>'Nom','animals.sex'=>'Sexe','animals.species'=>'Espèce','animals.age'=>'Âge','animals.status'=>'Statut','animals.location'=>'Récupéré à','animals.preview'=>'Aperçu','animals.no_results'=>'Aucun résultat.', + 'status.refuge'=>'Refuge','status.fa'=>'Famille d’accueil','status.fa_permanente'=>'Accueil permanent','status.chat_libre'=>'Chat libre','status.quarantaine'=>'Quarantaine','status.soin'=>'Infirmerie','status.isolation'=>'Isolation','status.hospitalise'=>'Hospitalisé','status.reserve'=>'Réservé','status.adopte'=>'Adopté','status.decede'=>'Décédé','status.enfui'=>'Enfui','sex.male'=>'Mâle','sex.female'=>'Femelle','sex.unknown'=>'Sexe inconnu','common.yes'=>'Oui','common.no'=>'Non','common.unknown'=>'Inconnu', + 'animals.all_statuses'=>'Tous les statuts', + 'dashboard.round_anomalies'=>'Anomalies signalées en tournée :','dashboard.adults'=>'adultes','dashboard.kittens'=>'chatons','dashboard.unknown_age'=>'âge inconnu','dashboard.silver_room'=>'salle Silver','dashboard.twix_room'=>'salle Twix','dashboard.unassigned'=>'non attribués','dashboard.temporary'=>'temporaires','dashboard.permanent'=>'permanentes','dashboard.in_quarantine'=>'en quarantaine','dashboard.in_medical'=>'en infirmerie','dashboard.data_quality'=>'Qualité des fiches','dashboard.data_quality_help'=>'Des données fiables pour un meilleur suivi et de bonnes statistiques','dashboard.current_records'=>'Fiches actuellement suivies','dashboard.records_complete'=>'Toutes les fiches essentielles sont complètes','dashboard.records_to_complete'=>':count fiches à compléter','dashboard.see_statistics'=>'Voir les statistiques','dashboard.medical'=>'Soins & médical','dashboard.medical_help'=>'Les priorités et suivis à traiter','dashboard.vaccines_overdue'=>'Vaccins en retard','dashboard.vaccines_soon'=>'Vaccins ≤ 7 jours','dashboard.vaccines_month'=>'Vaccins de 8 à 30 jours','dashboard.nothing'=>'RAS.','dashboard.quarantine_followup'=>'Quarantaine — suivi','dashboard.entry'=>'Entrée','dashboard.expected_exit'=>'Sortie prévue','dashboard.exit_followup'=>'Suivi de sortie','dashboard.no_quarantine'=>'Aucun animal en quarantaine.','dashboard.weight_alerts'=>'Alertes poids (≥ 15% sur 30 jours)','dashboard.current'=>'Actuel','dashboard.variation'=>'Variation','dashboard.no_alert'=>'Aucune alerte.','dashboard.treatments_close'=>'Traitements à clôturer','dashboard.current_treatments'=>'Traitements en cours','dashboard.medication'=>'Médicament','dashboard.start'=>'Début','dashboard.end'=>'Fin','dashboard.no_treatment'=>'Aucun traitement en cours.','dashboard.recent_activity'=>'Activité récente','dashboard.recent_activity_help'=>'Ce qui a été réalisé dernièrement','dashboard.medical_notes_7'=>'Notes médicales (7j)','dashboard.vaccines_30'=>'Vaccins faits (30j)','dashboard.treatments_30'=>'Traitements démarrés (30j)','dashboard.latest_notes'=>'Dernières notes médicales','dashboard.date'=>'Date','dashboard.type'=>'Type','dashboard.reason'=>'Motif','dashboard.no_note'=>'Aucune note.','common.animal'=>'Animal', + 'animals.removed_management'=>'Gestion des animaux retirés','animals.quality_filter'=>'Filtre qualité','animals.records'=>':count fiches','animals.remove_filter'=>'Retirer le filtre','animals.open_record'=>'Ouvrir la fiche de :name','animals.photo_of'=>'Photo de :name','animals.foster'=>'Famille d’accueil','animals.free_cat_site'=>'Site du chat libre','animals.dogs'=>'Chiens','animals.cats'=>'Chats','animals.children'=>'Enfants','animals.cleanliness'=>'Propreté','animals.compatible_dogs'=>'Compatible chiens','animals.compatible_cats'=>'Compatible chats','animals.compatible_children'=>'Compatible enfants','animals.sort_by'=>'Trier par :label','animals.bonded'=>'Inséparables','animals.litter'=>'Portée','animals.enlarge_photo'=>'Agrandir la photo de :name','animals.coat_unknown'=>'Robe non renseignée','animals.adopt_together'=>'À adopter ensemble','animals.not_sterilized'=>'Non stérilisé','animals.sterilization_unknown'=>'Stérilisation inconnue','animals.not_chipped'=>'Non pucé','animals.adoptable'=>'Adoptable','animals.not_adoptable'=>'Non adoptable','animals.medical_preview'=>'Aperçu médical','animals.fiv_felv'=>'État FIV / FeLV','animals.last_weight'=>'Dernier poids','animals.next_vaccine'=>'Prochain vaccin','animals.current_treatment'=>'Traitement en cours','animals.dewormer'=>'Vermifuge','animals.latest_diagnosis'=>'Dernier diagnostic / point de vigilance','common.close'=>'Fermer','animals.open_full_record'=>'Ouvrir la fiche complète', + 'dashboard.watch_point'=>':count point à surveiller','dashboard.morning'=>'matin','dashboard.evening'=>'soir','dashboard.adult'=>'adulte','dashboard.kitten'=>'chaton','dashboard.record_to_complete'=>':count fiche à compléter','dashboard.quality_note'=>'Le statut « non stérilisé » est une information de suivi, pas une donnée manquante. Les autres compteurs signalent des champs non renseignés.','quality.birth'=>'Âge inconnu','quality.sex'=>'Sexe inconnu','quality.color'=>'Couleur absente','quality.breed'=>'Race absente','quality.origin'=>'Origine absente','quality.chip'=>'Non identifié','quality.registration'=>'I-CAD inconnu','quality.sterilization'=>'Stérilisation inconnue','quality.adoption'=>'Adoption à définir','quality.unsterilized'=>'Non stérilisé','animals.life_profile'=>'Compatibilité chiens, chats, enfants et propreté','animals.species_label'=>'Espèce : :name','animals.test_unknown'=>'Non testé','animals.test_negative'=>'Négatif','animals.test_positive'=>'Positif','animals.test_doubtful'=>'Douteux','animals.tests_not_applicable'=>'Tests FIV/FeLV non applicables','animals.age_unknown'=>'Âge non renseigné','animals.age_estimated'=>'estimé','animals.sex_not_provided'=>'Sexe non renseigné','animals.available_title'=>'Disponible à l’adoption','animals.unavailable_title'=>'Non disponible à l’adoption','animals.weight_unknown'=>'Non renseigné','animals.no_vaccine'=>'Aucun rappel programmé','animals.no_current_treatment'=>'Aucun traitement en cours','animals.no_recent_medical'=>'Aucun point médical récent', + 'dashboard.grants_check'=>'Suivi des subventions à vérifier','dashboard.open_grants'=>'Ouvrir le suivi','dashboard.quarantine_tip'=>'Astuce : renseigne les dates d’entrée et de sortie prévue pour activer le suivi.','animals.enlarged_preview'=>'Aperçu agrandi','animals.short_hair'=>'Poil court','animals.medium_hair'=>'Poil mi-long','animals.long_hair'=>'Poil long','animals.curly_hair'=>'Poil frisé','animals.hairless'=>'Sans poil','animals.hair_unknown'=>'Poil non renseigné','animals.breed_unknown'=>'Race non renseignée', + 'animals.latest'=>'Dernier','animals.next'=>'Prochain','animals.never_provided'=>'Jamais renseigné','animals.last_update'=>'Dernière mise à jour : :date', + 'animal.archived_record'=>'Fiche archivée','animal.archived_help'=>'Consultation de l’historique conservé:date. Restaurez la fiche avant de reprendre son suivi.','animal.archived_on'=>' · archivée le :date','animal.health_alert'=>'Alerte sanitaire : :status','animal.species'=>'Espèce','animal.sex'=>'Sexe','animal.breed'=>'Race','animal.coat'=>'Robe','animal.hair'=>'Poil','animal.hair_short'=>'Court','animal.hair_medium'=>'Mi-long','animal.hair_long'=>'Long','animal.hair_curly'=>'Frisé','animal.hairless'=>'Sans poil','animal.microchip'=>'Puce électronique','animal.tattoo'=>'Tatouage','animal.identification_unknown'=>'Identification non renseignée','animal.placed_on'=>'posée le :date','animal.status'=>'Statut','animal.room'=>'Salle','animal.assign'=>'À attribuer','animal.site'=>'Site','animal.to_provide'=>'À renseigner','animal.free_cat_followup'=>'Suivi du chat libre','animal.present_on_site'=>'Présent sur site','animal.missing'=>'Disparu','animal.moved'=>'Déplacé','animal.site_or_colony'=>'Site ou colonie','animal.caretaker'=>'Référent','animal.capture_release'=>'Capture / remise en liberté','animal.internal_location'=>'Localisation interne','animal.sterilized'=>'Stérilisé','animal.adoption_fee_hint'=>'Frais d’adoption indicatifs :','animal.availability_define'=>'Disponibilité à définir','animal.registration_unknown'=>'I-CAD inconnu','animal.registration_pending'=>'I-CAD en attente','animal.registration_done'=>'I-CAD enregistré','animal.available_from'=>'à partir du :date','common.edit'=>'Modifier','common.add'=>'Ajouter','common.actions'=>'Actions','common.back'=>'Retour','common.restore'=>'Restaurer la fiche','common.print_record'=>'Imprimer le dossier','animal.medical_status'=>'Statut médical','animal.medical_note'=>'Note médicale','animal.treatment'=>'Traitement','animal.vaccine'=>'Vaccin','animal.veterinary_expense'=>'Dépense vétérinaire','animal.bonded_group'=>'Groupe inséparable','animal.placement_return'=>'Placement / retour','animal.validate_adoption'=>'Valider une adoption','animal.record_death'=>'Enregistrer un décès','animal.archive'=>'Archiver','animal.move_to_trash'=>'Mettre à la corbeille','animal.tab.identity'=>'Identité','animal.tab.activity'=>'Activité','animal.tab.medical'=>'Médical','animal.tab.treatments'=>'Traitements','animal.tab.vaccines'=>'Vaccins','animal.tab.more'=>'Plus','animal.tab.locations'=>'Lieux de vie','animal.tab.placements'=>'Parcours d’accueil','animal.tab.litters'=>'Portées','animal.tab.documents'=>'Documents','animal.tab.history'=>'Historique','animal.tab.photos'=>'Photos', + 'common.name'=>'Nom','common.weight'=>'Poids (kg)','common.optional'=>'Optionnel','common.disabled'=>'désactivée','animal.form.species_hint'=>'La liste se gère dans Paramètres → Espèces.','animal.form.weight_hint'=>'Optionnel, pour initialiser la courbe de poids.','animal.form.status_hint'=>'Le statut pilote l’emplacement et les parcours disponibles.','animal.form.shelter_room'=>'Salle au refuge','animal.form.foster'=>'Famille d’accueil','animal.form.provide_later'=>'À renseigner plus tard','animal.form.foster_hint'=>'Contacts ayant le rôle « FA » dans l’annuaire.','animal.form.hair_type'=>'Type de poil','animal.form.sterilization'=>'Stérilisation','animal.form.identification'=>'Identification','animal.form.type'=>'Type','animal.form.number'=>'Numéro','animal.form.implant_date'=>'Date de pose','animal.form.registration'=>'Enregistrement I-CAD','animal.form.none_identification'=>'Inconnu / aucune','animal.form.pending'=>'En attente','animal.form.registered'=>'Enregistré','animal.form.adoption'=>'Adoption','animal.form.availability'=>'Disponibilité','animal.form.not_available'=>'Non disponible','animal.form.available_from'=>'Disponible à partir du','animal.form.unavailable_reason'=>'Motif d’indisponibilité', + 'common.not_provided'=>'Non renseigné','common.save'=>'Enregistrer','common.notes'=>'Notes','common.date'=>'Date','animal.form.sterilization_date'=>'Date de stérilisation','animal.form.veterinarian'=>'Vétérinaire','animal.form.clinic'=>'Cabinet','animal.form.chip_number'=>'Numéro de puce ou tatouage','animal.form.life_profile'=>'Profil de vie','animal.form.life_profile_hint'=>'Utilisez « Inconnu » tant que le comportement n’a pas été réellement observé.','animal.form.age'=>'Âge','animal.form.birth_date'=>'Date de naissance','animal.form.estimated_birth'=>'Naissance estimée ?','animal.form.estimated_years'=>'Âge estimé (années)','animal.form.estimated_months'=>'Âge estimé (mois)','animal.form.location'=>'Localisation','animal.form.origin_city'=>'Commune d’origine','animal.form.origin_hint'=>'La commune seule suffit pour les statistiques. L’adresse détaillée reste facultative dans l’identité.','animal.form.current_location'=>'Lieu actuel','animal.form.current_location_hint'=>'Pour le moment, c’est fixé au refuge.','animal.form.intake'=>'Entrée au refuge','animal.form.intake_hint'=>'Ces informations alimentent automatiquement le registre et les statistiques.','animal.form.intake_date'=>'Date d’entrée','animal.form.intake_type'=>'Mode d’entrée','animal.form.intake_reason'=>'Motif principal','animal.form.depositor'=>'Déposant','animal.form.no_depositor'=>'Aucun déposant connu','animal.form.new_depositor'=>'Créer un déposant à la volée','animal.form.new_depositor_name'=>'Nom du nouveau déposant','animal.form.depositor_hint'=>'Le contact sera ajouté à l’annuaire avec le rôle « Déposant ».','animal.form.circumstances'=>'Circonstances','animal.form.quarantine_exit'=>'Sortie quarantaine','animal.form.quarantine_hint'=>'Calculée automatiquement :count jour(s) après l’entrée lorsque le statut est « Quarantaine ».','animal.form.location_reason'=>'Motif du déplacement','animal.form.location_reason_hint'=>'Enregistré uniquement si le statut, la salle, le box ou l’adresse change.', + 'common.cancel'=>'Annuler','animal.treatment_start'=>'Démarrer un traitement','animal.medication'=>'Médicament','animal.route'=>'Voie','animal.new_medication'=>'Nom du nouveau médicament','animal.molecule'=>'Molécule','animal.form_form'=>'Forme','animal.dosage'=>'Posologie','animal.administer_during'=>'À administrer pendant','animal.morning_round'=>'Tournée du matin','animal.evening_round'=>'Tournée du soir','animal.as_needed'=>'À la demande','animal.start'=>'Début','animal.end_optional'=>'Fin (optionnel)','animal.vaccine_record'=>'Enregistrer un vaccin','animal.done_on'=>'Fait le','animal.reminder_due'=>'Rappel prévu','animal.batch'=>'Lot','animal.manufacturer'=>'Fabricant','animal.batch_expiry'=>'Expiration du lot','animal.administered_by'=>'Administré par','animal.person_name'=>'Nom de la personne', + 'animal.medical_status_global'=>'Statut médical global','animal.positive_test_help'=>'Un passage à « Positif » ajoute automatiquement une note médicale et une entrée dans l’historique.','animal.fiv_status'=>'Statut FIV','animal.felv_status'=>'Statut FeLV','animal.test_date'=>'Date du test','animal.test_note'=>'Note liée aux tests','animal.conditions'=>'Autres pathologies ou vigilances','animal.conditions_help'=>'Une vigilance active est reprise dans le résumé médical.','animal.label'=>'Intitulé','animal.state'=>'État','animal.diagnosis'=>'Diagnostic','animal.note'=>'Note','animal.active'=>'Active','animal.resolved'=>'Résolue','animal.add_condition'=>'Ajouter une pathologie ou une vigilance','animal.save_medical_status'=>'Enregistrer le statut médical','animal.add_medical_note'=>'Ajouter une note médicale','animal.reason'=>'Motif','animal.sterilization_act'=>'Cet acte correspond à la stérilisation du chat','animal.already_sterilized'=>'Cet animal est déjà enregistré comme stérilisé.','animal.sterilization_auto'=>'La fiche passera automatiquement à « Stérilisé » et les frais indicatifs à :price €.','animal.temperature'=>'Température (°C)','animal.symptoms'=>'Symptômes','animal.exam'=>'Examen','animal.medical_photos'=>'Photos médicales','animal.medical_photos_hint'=>'JPG, PNG, WebP — 20 Mo maximum par image, optimisation automatique','animal.plan'=>'Plan','animal.consultation'=>'Consultation','animal.checkup'=>'Contrôle','animal.symptom'=>'Symptôme','animal.procedure'=>'Acte','animal.hospitalization'=>'Hospitalisation','animal.other'=>'Autre', + 'animal.deworming_record'=>'Enregistrer un vermifuge','animal.product'=>'Produit','animal.dose'=>'Dose','animal.next_due'=>'Prochaine échéance','animal.next_due_hint'=>'Calcul automatique selon l’âge si ce champ reste vide.','animal.apply_litter'=>'Administrer aussi à la maman et à tous les chatons de la portée','animal.comment'=>'Commentaire','animal.add_placement'=>'Ajouter un événement de placement','animal.event'=>'Événement','animal.reservation'=>'Réservation','animal.foster_placement'=>'Placement en famille d’accueil','animal.return_shelter'=>'Retour au refuge','animal.cancellation'=>'Annulation','animal.placement_hint'=>'L’adoption définitive conserve son bouton dédié.','animal.related_contact'=>'Contact associé','animal.no_contact'=>'Aucun contact','animal.return_reason_placeholder'=>'Obligatoire pour un retour ou une annulation','common.choose'=>'Choisir', + 'animal.veterinary_clinic'=>'Cabinet vétérinaire','animal.create_clinic'=>'Créer un cabinet…','animal.new_clinic_name'=>'Nom du nouveau cabinet','animal.create_vet'=>'Créer un vétérinaire…','animal.new_vet_name'=>'Nom du nouveau vétérinaire','animal.medical_pair_hint'=>'Choisir un vétérinaire sélectionne automatiquement sa structure. Choisir d’abord une structure limite la liste aux vétérinaires qui lui sont rattachés.','animal.tariff_record'=>'Tarif à comptabiliser','animal.no_expense'=>'Ne pas ajouter de dépense','animal.net'=>'net','animal.expense_hint'=>'La dépense sera rattachée automatiquement à cet animal.','common.choose_from_list'=>'Choisir dans la liste', + 'animal.death_recorded'=>'Décès enregistré','animal.year'=>'Année :year','animal.date_unknown'=>'Date inconnue','animal.statistics'=>'Statistiques','animal.in_shelter_figures'=>'Inclus dans les chiffres du refuge','animal.outside_care'=>'Hors prise en charge','animal.place'=>'Lieu','animal.care'=>'Prise en charge','animal.context'=>'Contexte','animal.euthanasia'=>'Euthanasie','animal.natural_death'=>'Décès naturel ou accidentel','animal.cause'=>'Cause','animal.age_at_death'=>'Âge au décès','animal.bonded_cats'=>'Chats inséparables','animal.bonded_help'=>'Ces animaux doivent être placés ou adoptés ensemble.','animal.dissolve_group'=>'Dissoudre le groupe','animal.intake_origin_help'=>'Origine administrative et circonstances de la prise en charge.','animal.intake_mode'=>'Mode d’entrée','animal.origin_city'=>'Commune d’origine','animal.life_profile_help'=>'Compatibilités observées et propreté.','animal.location'=>'Localisation','animal.rescue'=>'Récupération','animal.current'=>'Actuel','animal.rescue_place'=>'Lieu de récupération','animal.coordinates_missing'=>'Coordonnées manquantes.','animal.adopted_by'=>'Adopté par','animal.distance_story'=>':name a parcouru :distance km pour arriver chez nous.','animal.distance_caption'=>'Un court trajet sur la carte… mais un grand pas vers la sécurité.','animal.last_sync'=>'Dernière synchro : :date','animal.open_icad'=>'Ouvrir i-CAD','common.refresh'=>'Rafraîchir','animal.icad_error'=>'Erreur i-CAD','animal.no_icad'=>'Aucune donnée i-CAD pour le moment.','animal.icad_name'=>'Nom i-CAD','animal.birth'=>'Naissance','animal.appearance'=>'Apparence','animal.holder'=>'Détenteur (i-CAD)','animal.do_not_share'=>'NE PAS COMMUNIQUER','common.phone'=>'Téléphone','common.email'=>'E-mail','common.address'=>'Adresse','animal.icad_history'=>'Historique i-CAD','animal.no_event'=>'Aucun évènement.','animal.recorded'=>'Enregistré','animal.placement_journey'=>'Parcours d’accueil et d’adoption','animal.placement_journey_help'=>'Réservations, familles d’accueil, adoptions, retours et annulations.','animal.add_event'=>'Ajouter un événement','animal.final_adoption'=>'Adoption définitive','animal.no_placement'=>'Aucun placement enregistré.','common.by'=>'Par :name','animal.location_history'=>'Historique des lieux de vie','animal.location_history_help'=>'Chaque changement de statut, de salle, de box, de famille ou d’adresse est conservé automatiquement.','animal.step'=>':count étape','animal.steps'=>':count étapes','animal.no_location'=>'Aucun emplacement enregistré.','animal.no_activity'=>'Aucune activité enregistrée.','animal.system'=>'Système','animal.care_round'=>'Tournée', + 'animal.current_health_help'=>'État actuel et vigilances durables','animal.conditions_short'=>'Pathologies et vigilances','animal.no_active_condition'=>'Aucune vigilance active.','animal.deworming_schedule'=>'Chatons : 3, 5 et 7 semaines, puis chaque mois jusqu’à 6 mois · adultes : tous les 3 mois.','animal.no_deworming'=>'Aucun vermifuge enregistré.','animal.latest_deworming'=>'Dernier vermifuge : :date','animal.next_date'=>'Prochain : :date','animal.recorded_by'=>'Enregistré par','animal.weight_evolution'=>'Évolution du poids','animal.no_weight'=>'Aucune mesure de poids enregistrée.','animal.no_medical_note'=>'Aucune note médicale.','animal.photos'=>'Photos','animal.no_treatment'=>'Aucun traitement en cours.','animal.finished'=>'Terminé','animal.ongoing'=>'En cours','animal.no_vaccine_recorded'=>'Aucun vaccin enregistré.','animal.batch_traceability'=>'Traçabilité du lot','animal.expires_on'=>'Expire le :date','animal.litters_parentage'=>'Portées et filiation','animal.litters_help'=>'Portées dont cet animal est parent ou chaton.','animal.add_litter'=>'Ajouter une portée','animal.no_litter'=>'Aucune portée enregistrée.','animal.litter_on'=>'Portée du :date','animal.mother'=>'Maman','animal.father'=>'Papa','animal.unknown_female'=>'inconnue','animal.unknown_male'=>'inconnu','animal.add_existing_cat'=>'Ajouter un chat existant','animal.archived'=>'Archivé','animal.sex_define'=>'Sexe à définir','animal.adoption_contract'=>'Contrat d’adoption','animal.generated_copy_help'=>'Une copie est générée à la demande ; le modèle original reste intact.','animal.prefilled_information'=>'Informations préremplies','animal.adoption_prefill'=>'Identité, sexe, naissance, robe, puce, stérilisation, FIV/FeLV, vaccination et rappel.','animal.linked_adopter'=>'Adoptant lié : :name','animal.no_linked_adopter'=>'Aucun adoptant lié : sa partie restera vierge.','animal.download_odt'=>'Télécharger l’ODT','animal.view_print_pdf'=>'Voir / imprimer le PDF','animal.abandon_found'=>'Abandon / trouvaille','animal.known_info_added'=>'Les informations connues de l’animal sont ajoutées automatiquement.','animal.identity_summary'=>'Nom, sexe, race, âge et identification.','animal.no_history_event'=>'Aucun événement enregistré.','animal.public_photos'=>'Photos publiques','animal.visible_online'=>'Visibles en ligne','animal.upload_public'=>'Uploader (public)','animal.no_public_photo'=>'Aucune photo publique.','animal.primary'=>'Principale','animal.public'=>'Publique','animal.set_primary'=>'Définir comme photo principale','animal.private'=>'Privée','common.delete_short'=>'Suppr.','animal.private_photos'=>'Photos privées','animal.not_visible_online'=>'Non visibles en ligne','animal.upload_private'=>'Uploader (privé)','animal.no_private_photo'=>'Aucune photo privée.','animal.round_photo'=>'Photo prise pendant une tournée', + 'animal.holder_internal'=>'Détenteur (usage interne)','animal.icad_fetching'=>'Récupération i-CAD…','animal.icad_fetch_error'=>'Erreur i-CAD lors de la récupération. Consultez les journaux techniques.', + 'animal.medication_saved_hint'=>'Le médicament sera ajouté au référentiel et restera disponible pour les prochains traitements.','animal.kitten_vaccine_hint'=>'Modifiez à +1 mois pour un chaton.','animal.other_person'=>'Autre personne', + 'directory.title'=>'Annuaire','directory.add'=>'Ajouter un contact','directory.edit'=>'Modifier le contact','directory.search'=>'Nom, téléphone, e-mail ou ville','directory.all_roles'=>'Tous les rôles','common.filter'=>'Filtrer','directory.identity'=>'Identité','directory.roles'=>'Rôles','directory.contact_details'=>'Coordonnées','directory.city'=>'Ville','directory.organization'=>'Structure','directory.person'=>'Personne','directory.documents'=>'Documents','directory.volunteer_contract'=>'Contrat bénévole','directory.foster_proposal'=>'Proposition FA','directory.no_contact'=>'Aucun contact.','directory.identity_details'=>'Identité et coordonnées','directory.kind'=>'Type','directory.organization_link'=>'Structure de rattachement','directory.organization_hint'=>'Pour rattacher une personne, par exemple un vétérinaire, à sa structure.','directory.postal_code'=>'Code postal','directory.country'=>'Pays','directory.internal_notes'=>'Notes internes','directory.name_required'=>'Nom requis','directory.invalid_organization'=>'Structure de rattachement invalide.','role.adoptant'=>'Adoptant','role.benevole'=>'Bénévole','role.cabinet'=>'Cabinet vétérinaire','role.crematorium'=>'Crématorium','role.deposant'=>'Déposant','role.dirigeant'=>'Dirigeant','role.fa'=>'Famille d’accueil','role.fourriere'=>'Fourrière','role.veterinaire'=>'Vétérinaire', + 'care.title'=>'Tournée de soins','care.description'=>'Mode tablette pour les tournées du matin et du soir.','care.setup_title'=>'Configuration des salles de soins','care.round_of'=>'Tournée du :period','care.morning'=>'matin','care.evening'=>'soir','care.fullscreen'=>'Plein écran','care.configure_boxes'=>'Configurer les box','care.silver_room'=>'Salle Silver','care.twix_room'=>'Salle Twix','care.medical_room'=>'Infirmerie','care.quarantine_room'=>'Salle de quarantaine','care.cat'=>':count chat','care.cats'=>':count chats','care.validate_room_confirm'=>'Valider tous les animaux non renseignés de cette salle ?','care.everyone_well'=>'Tout le monde va bien','care.finished'=>'Terminé','care.treatment_planned'=>'Traitement prévu pendant cette tournée','care.treatment_unconfigured'=>'Traitement sans horaire à configurer','care.validated'=>'Validé','care.todo'=>'À faire','care.all_normal'=>'Tout est normal','care.given'=>'Donné','care.not_given'=>'Non donné','care.anomaly_photo'=>'Photo d’une anomalie','care.photo_comment'=>'Commentaire sur la photo (ex. plaie patte avant)','care.photo_hint'=>'La photo sera optimisée puis enregistrée dans les médias privés de l’animal.','care.optional_comment'=>'Commentaire facultatif','care.validate_animal'=>'Valider :name','care.no_schedule'=>'sans horaire','care.both'=>'Les deux','care.no_animal_room'=>'Aucun animal dans cette salle.','care.food_normal'=>'Mangé','care.food_little'=>'Peu mangé','care.food_refused'=>'Refusé','care.water_normal'=>'Eau OK','care.water_renewed'=>'Renouvelée','care.abnormal'=>'Anormale','care.urine_normal'=>'Urines OK','care.absent_feminine'=>'Absentes','care.stool_normal'=>'Selles OK','care.state_good'=>'État bon','care.watch'=>'À surveiller','care.alert'=>'Alerte','care.box_top_left_double'=>'Box haut gauche double','care.box_top_right'=>'Box haut droite','care.box_top_left'=>'Box haut gauche','care.box_top_right_double'=>'Box haut droite double','care.box_top_full'=>'Box haut complet','care.box_top_center'=>'Box haut centre','care.box_bottom_full'=>'Box bas complet','care.box_bottom_left'=>'Box bas gauche','care.box_bottom_right'=>'Box bas droite','care.box_unassigned'=>'Sans box attribué','care.invalid_photo'=>'Photo invalide, trop volumineuse ou commentaire manquant.','care.invalid_photo_format'=>'Format de photo non accepté.','care.setup_help'=>'Configurez les cloisons, enregistrez, puis attribuez les animaux aux box disponibles.','care.back_round'=>'Retour à la tournée','care.three_top_boxes'=>'Trois box du haut','care.two_bottom_boxes'=>'Deux box du bas','care.three_separate'=>'3 box séparés','care.double_left'=>'Double gauche + simple droite','care.double_right'=>'Simple gauche + double droite','care.one_large'=>'1 grand box','care.two_separate'=>'2 box séparés','care.current_assignment'=>'Affectation actuelle','care.room'=>'Salle','care.box'=>'Box','care.assignment_reason'=>'Motif des nouvelles affectations','care.assignment_placeholder'=>'Facultatif : réorganisation des box, isolement…','care.save_setup'=>'Enregistrer la configuration', + 'admin.title'=>'Administratif','admin.exports_title'=>'Administratif — Exports','admin.register'=>'Registre des entrées et des sorties','admin.grants'=>'Subventions et bilans','admin.export_data'=>'Exporter les données','admin.documents'=>'Documents administratifs','admin.models'=>':count modèles','admin.models_help'=>'Modèles originaux vierges, prêts à consulter, imprimer ou compléter.','admin.blank_model'=>'modèle vierge','admin.adoption_document'=>'Contrat d’adoption','admin.abandon_document'=>'Abandon / trouvaille','admin.volunteer_document'=>'Contrat bénévole','admin.foster_document'=>'Proposition famille d’accueil','admin.pre_adoption_document'=>'Certificat de visite pré-adoption','admin.year'=>'Année','admin.search'=>'Recherche','admin.search_placeholder'=>'Nom / code / puce…','admin.all_species'=>'Toutes','admin.cat'=>'Chat','admin.dog'=>'Chien','common.reset'=>'Réinitialiser','admin.print_register'=>'Imprimer le registre','admin.birth'=>'Naissance','admin.identification'=>'Identification','admin.reason_contact'=>'Raison / Coordonnées','admin.entry'=>'Entrée','admin.exit'=>'Sortie','admin.adoption'=>'Adoption','admin.owner_death'=>'Décès du propriétaire','admin.animal_death'=>'Décès de l’animal','admin.rescue'=>'Sauvetage','admin.crematorium'=>'Crématorium','admin.no_data'=>'Aucune donnée.','admin.exports_help'=>'Des fichiers ouverts, lisibles et réutilisables hors de Globinours.','admin.back'=>'Retour à l’administratif','admin.sensitive'=>'Données sensibles.','admin.sensitive_help'=>'Ces exports peuvent contenir des coordonnées personnelles et des informations médicales. Conservez-les dans un emplacement sécurisé.','admin.portability'=>'Export complet de portabilité','admin.portability_help'=>'Une archive ZIP contenant tous les tableaux CSV, sans mots de passe, médias ni secrets de configuration.','admin.download_all'=>'Télécharger tous les CSV','common.download'=>'Télécharger','admin.compatibility'=>'Compatibilité','admin.compatibility_help'=>'Les fichiers utilisent UTF-8, une ligne d’en-tête et le séparateur point-virgule attendu par les tableurs français. Les cellules susceptibles d’être interprétées comme des formules sont neutralisées.','admin.unknown_export'=>'Export inconnu.','admin.export_failed'=>'L’export n’a pas pu être généré. Consultez les journaux du serveur.','admin.forbidden'=>'Votre rôle ne permet pas d’accéder à l’administration.','export.animals'=>'Animaux','export.contacts'=>'Annuaire','export.adoptions'=>'Adoptions','export.placements'=>'Parcours d’accueil et retours','export.medical'=>'Notes médicales','export.treatments'=>'Traitements','export.vaccinations'=>'Vaccinations','export.deaths'=>'Décès','export.measurements'=>'Mesures','export.litters'=>'Portées','export.bonded'=>'Groupes inséparables','export.care_rounds'=>'Tournées','export.administrations'=>'Administrations de traitements','export.media_index'=>'Index des médias','export.movements'=>'Mouvements','export.locations'=>'Historique des lieux de vie','export.history'=>'Historique', + 'grant.title'=>'Subventions et bilan annuel','grant.forbidden'=>'Votre rôle ne permet pas cette action.','grant.document_not_found'=>'Document introuvable.','grant.saved'=>'Les informations ont bien été enregistrées.','grant.save_error'=>'L’enregistrement n’a pas abouti. Vérifiez les champs et le format du document.','grant.file_year'=>'Année du dossier','common.show'=>'Afficher','grant.register'=>'Registre :year','grant.print_report'=>'Imprimer le bilan','grant.entries'=>'Entrées','grant.exits'=>'Sorties','grant.adoptions'=>'Adoptions','grant.deaths'=>'Décès','grant.births'=>'Chatons nés','grant.vaccinations'=>'Vaccinations','grant.treatments'=>'Traitements commencés','grant.current'=>'Animaux suivis actuellement','grant.indicators_help'=>'Les indicateurs ci-dessus viennent directement des données Globinours pour :year. Les « animaux suivis actuellement » représentent la situation du jour, pas une photographie historique au 31 décembre.','grant.avg_adoption'=>'Délai moyen d’adoption','common.days'=>':count jours','grant.comparison'=>'Évolution par rapport à :year','grant.comparison_help'=>'Les pourcentages ne sont affichés que lorsqu’une base N−1 existe.','grant.indicator'=>'Indicateur','grant.evolution'=>'Évolution','grant.sex_entries'=>'Répartition par sexe des entrées','grant.age_entry'=>'Âge lors de l’entrée','grant.origins'=>'Origines déclarées','grant.death_causes'=>'Causes de décès','grant.adoption_stay'=>'Durée de séjour avant adoption','grant.no_data_year'=>'Aucune donnée exploitable pour :year.','grant.annual_report'=>'Bilan annuel :year','grant.members'=>'Adhérents','grant.volunteers'=>'Bénévoles actifs','grant.employees'=>'Salariés','grant.employee_fte'=>'ETP salariés','grant.last_ag'=>'Dernière assemblée générale','grant.beneficiaries'=>'Bénéficiaires / public concerné','grant.beneficiaries_placeholder'=>'Animaux pris en charge, habitants, communes couvertes…','grant.activity_summary'=>'Résumé du rapport d’activité','grant.completed_actions'=>'Actions réalisées','grant.planned_actions'=>'Projets et actions prévus','grant.officers'=>'Dirigeants et organisation','grant.in_kind'=>'Aides en nature et contributions bénévoles','grant.financial_summary'=>'Synthèse financière','grant.actual_expenses'=>'Dépenses réalisées','grant.actual_income'=>'Recettes réalisées','grant.forecast_expenses'=>'Dépenses prévisionnelles','grant.forecast_income'=>'Recettes prévisionnelles','grant.treasury'=>'Trésorerie disponible','grant.voluntary'=>'Contributions volontaires valorisées','grant.save_report'=>'Enregistrer le bilan','grant.applications'=>'Demandes par commune ou organisme','grant.organization'=>'Organisme','grant.deadline'=>'Échéance','grant.requested'=>'Demandé','grant.previous_aid'=>'Aide précédente','grant.decision'=>'Suivi de la décision','grant.awarded'=>'Accordé','grant.no_application'=>'Aucune demande pour :year.','grant.organization_required'=>'Commune / organisme *','grant.requested_amount'=>'Montant demandé','grant.state'=>'État','grant.notes'=>'Particularités / notes','common.add'=>'Ajouter','grant.checklist'=>'Checklist des dossiers','grant.checklist_help'=>'Pièces communes, validité et formulaire propre à chaque organisme.','grant.add_first'=>'Ajoutez d’abord une demande pour suivre sa complétude.','grant.valid_parts'=>':valid/:total pièces valides','grant.complete'=>'Dossier complet','grant.incomplete'=>'À compléter','grant.valid'=>'Valide','grant.expiring'=>'Expire bientôt','grant.expired'=>'Expiré','grant.missing'=>'Manquant','grant.vault'=>'Coffre des justificatifs','grant.vault_help'=>'PDF et ODT privés, inclus dans les sauvegardes.','grant.other'=>'Autre','grant.permanent'=>'Permanent','grant.folder'=>'Dossier : :name','grant.expired_on'=>'Expiré le','grant.valid_until'=>'Valide jusqu’au','grant.no_document'=>'Aucun justificatif enregistré.','grant.category'=>'Catégorie','grant.related_folder'=>'Dossier concerné','grant.common_part'=>'Pièce commune','grant.document_title'=>'Titre','grant.pdf_odt'=>'Fichier PDF ou ODT','grant.permanent_document'=>'Document permanent, disponible pour toutes les années','grant.add_document'=>'Ajouter le document', + 'grant.status.draft'=>'Brouillon','grant.status.ready'=>'Prête','grant.status.submitted'=>'Déposée','grant.status.awarded'=>'Accordée','grant.status.refused'=>'Refusée','grant.status.withdrawn'=>'Abandonnée','grant.category.official_form'=>'Formulaire officiel de l’organisme','grant.category.statutes'=>'Statuts','grant.category.prefecture'=>'Récépissé de préfecture','grant.category.jo'=>'Publication au Journal officiel','grant.category.rib'=>'RIB','grant.category.insurance'=>'Attestation d’assurance','grant.category.ag'=>'Procès-verbal d’assemblée générale','grant.category.activity'=>'Rapport d’activité','grant.category.accounts'=>'Comptes approuvés','grant.category.budget'=>'Budget prévisionnel','grant.category.other'=>'Autre', + 'grant.generated'=>'Généré par :association avec Globinours, le :date à :time','grant.not_calculable'=>'non calculable','statistics.details'=>'Répartitions détaillées','common.no_data'=>'Aucune donnée.','grant.association_life'=>'Vie associative','grant.report_activity'=>'Rapport d’activité','grant.actions_planned'=>'Actions prévues','grant.beneficiaries_short'=>'Bénéficiaires','grant.in_kind_short'=>'Aides en nature et bénévolat','grant.expenses_planned'=>'Dépenses prévues','grant.income_planned'=>'Recettes prévues','grant.voluntary_short'=>'Contributions volontaires', + 'statistics.title'=>'Statistiques du refuge','statistics.description'=>'Activité annuelle, évolutions et profils des animaux pris en charge.','statistics.handled'=>'Animaux pris en charge','statistics.entries'=>'Entrées','statistics.exits'=>'Sorties','statistics.adoptions'=>'Adoptions','statistics.deaths'=>'Décès','statistics.births'=>'Chatons nés','statistics.vaccinations'=>'Vaccinations','statistics.treatments'=>'Traitements','statistics.treatments_started'=>'Traitements commencés','statistics.medical_notes'=>'Notes médicales','statistics.avg_adoption_days'=>'Délai moyen d’adoption (jours)','statistics.memory'=>'Mémoire du refuge','statistics.city_report'=>'Bilan de :city','statistics.figures'=>'L’activité en chiffres','statistics.city_help'=>'Animaux originaires de cette commune lors de leur prise en charge.','statistics.help'=>'Ici, on analyse les tendances. Le dashboard reste consacré aux actions et urgences du jour.','statistics.period'=>'Période analysée','statistics.all_years'=>'Toutes les années','statistics.origin_city'=>'Commune d’origine','statistics.all_cities'=>'Toutes les communes','common.clear'=>'Effacer','common.print'=>'Imprimer','statistics.handled_count'=>':count animaux pris en charge','statistics.filter_print'=>'Filtre appliqué à toute la page et à l’impression.','statistics.since_start'=>'depuis le début','statistics.in_year'=>'en :year','statistics.vet_costs'=>'Coûts vétérinaires estimés','statistics.vet_costs_help'=>'Tarifs réellement appliqués après remises, figés au moment de chaque dépense.','statistics.animals_concerned'=>':count animaux concernés','statistics.by_structure'=>'Par structure','statistics.by_category'=>'Par catégorie','statistics.no_expense'=>'Aucune dépense enregistrée.','statistics.annual_activity'=>'Évolution annuelle de l’activité','statistics.annual_activity_help'=>'Animaux distincts pris en charge, adoptions et décès enregistrés depuis le début.','statistics.year'=>'Année','statistics.unknown_date'=>'Date inconnue','statistics.no_previous_percent'=>'Une absence de pourcentage signifie que la valeur N−1 était nulle.','statistics.current_snapshot'=>'Photographie actuelle','statistics.current_snapshot_help'=>'Animaux vivants encore pris en charge aujourd’hui.','statistics.animals'=>'animaux','statistics.free_cats'=>'Chats libres suivis','statistics.sterilized'=>'Stérilisés','statistics.chipped'=>'Identifiés par puce','statistics.fiv_positive'=>'FIV positifs','statistics.felv_positive'=>'FeLV positifs','statistics.free_cat_help'=>'Les chats libres restent suivis par l’association sans être comptés comme présents physiquement au refuge.','statistics.profiles'=>'Profils et répartitions','statistics.profiles_help_all'=>'Les profils concernent les animaux entrés depuis le début, sauf mention contraire.','statistics.profiles_help_year'=>'Les profils concernent les animaux entrés en :year, sauf mention contraire.','statistics.observations'=>':count observations','statistics.click_city'=>'cliquez sur une commune','statistics.no_period_data'=>'Aucune donnée exploitable pour cette période.','statistics.show_city'=>'Afficher le bilan de :city','statistics.intake_modes'=>'Modes d’entrée','statistics.intake_reasons'=>'Motifs d’entrée','statistics.colors'=>'Couleurs des animaux entrés','statistics.breeds'=>'Races des animaux entrés','statistics.species'=>'Espèces des animaux entrés','statistics.sex'=>'Sexe des animaux entrés','statistics.age_at_entry'=>'Âge lors de l’entrée','statistics.origins'=>'Origines déclarées','statistics.adoption_delay'=>'Durée avant adoption','statistics.death_causes'=>'Causes de décès','statistics.current_distribution'=>'Répartition actuelle','statistics.free_cat_sites'=>'Chats libres par site ou commune','statistics.releases_year'=>'Remises en liberté par année','statistics.releases_site'=>'Remises en liberté par site','statistics.intake.unknown'=>'Non renseigné','statistics.intake.found'=>'Trouvé','statistics.intake.owner_surrender'=>'Cession par le propriétaire','statistics.intake.transfer'=>'Transfert d’une autre structure','statistics.intake.born_in_care'=>'Né sous la responsabilité du refuge','statistics.intake.return'=>'Retour après placement','statistics.intake.seizure'=>'Saisie ou réquisition','statistics.intake.other'=>'Autre mode d’entrée','statistics.reason.unknown'=>'Non renseigné','statistics.reason.stray_found'=>'Animal errant ou trouvé','statistics.reason.unwanted_litter'=>'Portée non désirée','statistics.reason.owner_health'=>'Santé ou hospitalisation du propriétaire','statistics.reason.owner_death'=>'Décès du propriétaire','statistics.reason.owner_care_home'=>'Entrée en EHPAD ou établissement spécialisé du propriétaire','statistics.reason.allergy'=>'Allergie','statistics.reason.housing'=>'Logement ou déménagement','statistics.reason.financial'=>'Difficultés financières','statistics.reason.behavior'=>'Difficultés comportementales','statistics.reason.abandonment'=>'Abandon sur place','statistics.reason.danger'=>'Mise en danger ou maltraitance','statistics.reason.transfer'=>'Transfert entre structures','statistics.reason.birth'=>'Naissance','statistics.reason.other'=>'Autre motif', + 'statistics.city_filter_summary'=>':count animaux pris en charge','statistics.filter_applied'=>'Filtre appliqué à toute la page et à l’impression.','statistics.undated_deaths'=>':total décès au total. :count décès historiques sans date certaine sont inclus dans le total général, mais pas dans la courbe ni dans les lignes annuelles.','statistics.global_memory'=>'Mémoire globale :','statistics.undated_summary'=>':entries entrées, :exits sorties et :births naissances sans année certaine sont incluses dans les totaux, mais pas dans la chronologie. Les :vaccinations vaccinations concernent :vaccinated animaux ; les :notes notes médicales concernent :medical animaux.','statistics.undated_handled'=>':count animaux pris en charge sans année d’entrée connue sont inclus dans le total général, mais pas placés artificiellement sur la courbe.','statistics.chart_label'=>'Évolution annuelle de l’activité', + 'statistics.value.females'=>'Femelles','statistics.value.males'=>'Mâles','statistics.value.unknown'=>'Inconnu','statistics.value.not_provided'=>'Non renseigné','statistics.value.not_provided_f'=>'Non renseignée','statistics.value.under_year'=>'Moins d’un an','statistics.value.one_two_years'=>'1 à 2 ans','statistics.value.three_seven_years'=>'3 à 7 ans','statistics.value.eight_plus'=>'8 ans et plus','statistics.value.under_month'=>'Moins d’un mois','statistics.value.one_three_months'=>'1 à 3 mois','statistics.value.three_six_months'=>'3 à 6 mois','statistics.value.six_twelve_months'=>'6 à 12 mois','statistics.value.over_year'=>'Plus d’un an','statistics.value.cats'=>'Chats','statistics.value.dogs'=>'Chiens','statistics.value.unknown_f'=>'Inconnue','statistics.value.permanent_foster'=>'Accueil permanent','statistics.value.unprovided_site'=>'Site non renseigné', + 'pricing.category.consultation'=>'Consultation','pricing.category.surgery'=>'Chirurgie','pricing.category.identification'=>'Identification','pricing.category.test'=>'Test / analyse','pricing.category.vaccine'=>'Vaccin','pricing.category.euthanasia'=>'Euthanasie','pricing.category.cremation'=>'Crémation','pricing.category.medication'=>'Médicament','pricing.category.dewormer'=>'Vermifuge','pricing.category.other'=>'Autre','death.cause.disease'=>'Maladie','death.cause.accident'=>'Accident','death.cause.old_age'=>'Vieillesse','death.cause.congenital'=>'Malformation ou affection congénitale','death.cause.surgery_complication'=>'Complication médicale ou chirurgicale','death.cause.poisoning'=>'Empoisonnement','death.cause.unknown'=>'Cause inconnue','death.cause.other'=>'Autre cause', + 'settings.saved'=>'Les paramètres ont été enregistrés.','settings.association'=>'Association','settings.association_name'=>'Nom de l’association','settings.rna'=>'Numéro RNA','settings.siret'=>'SIRET','settings.address'=>'Adresse','settings.postcode'=>'Code postal','settings.city'=>'Ville','settings.phone'=>'Téléphone','settings.email'=>'E-mail','settings.association_logo'=>'Logo de l’association','settings.logo_help'=>'PNG, JPEG ou WebP, 5 Mo maximum. Il pourra être intégré aux documents officiels.','settings.delete_logo'=>'Supprimer le logo actuel','settings.logo_alt'=>'Logo de :name','settings.current_preview'=>'Aperçu actuel','settings.no_logo'=>'Aucun logo enregistré','settings.operation'=>'Fonctionnement','settings.quarantine_duration'=>'Durée de quarantaine','settings.days'=>'jours','settings.backups_kept'=>'Sauvegardes conservées', + 'settings.users_title'=>'Paramètres — Utilisateurs','settings.users_intro'=>'Comptes, rôles et accès à l’application.','settings.configure_permissions'=>'Configurer les permissions des rôles','settings.add_user'=>'Ajouter un utilisateur','settings.display_name'=>'Nom affiché','settings.username'=>'Identifiant','settings.role'=>'Rôle','settings.initial_password'=>'Mot de passe initial','settings.new_password'=>'Nouveau mot de passe','settings.unchanged'=>'Inchangé','settings.active'=>'Actif','settings.create'=>'Créer','settings.never_logged'=>'Jamais connecté','settings.seen'=>'Vu :date','settings.invalid_data'=>'Données invalides','settings.keep_admin'=>'Vous ne pouvez pas retirer vos propres droits administrateur.','settings.password_short'=>'Mot de passe trop court','settings.password_required'=>'Mot de passe de 10 caractères minimum requis', + 'settings.permissions_title'=>'Paramètres — Rôles et permissions','settings.permissions_saved'=>'Les permissions des rôles ont été enregistrées.','settings.permissions_intro'=>'Les droits s’appliquent à tous les utilisateurs appartenant au rôle concerné.','settings.safeguard'=>'Garde-fou :','settings.safeguard_help'=>'Les administrateurs conservent toujours tous les droits. Eux seuls peuvent gérer les utilisateurs, cette matrice, les paramètres, les sauvegardes et le journal d’audit. L’accès médical nécessite également de pouvoir consulter les fiches animales.','settings.module'=>'Module','settings.view'=>'Voir','settings.edit'=>'Modifier','settings.permission_view'=>'Voir :module — :role','settings.permission_edit'=>'Modifier :module — :role','settings.edit_implies_view'=>'Autoriser la modification active automatiquement la consultation du module.','settings.save_permissions'=>'Enregistrer les permissions', + 'permission.role.admin'=>'Administrateur','permission.role.responsable'=>'Responsable','permission.role.benevole'=>'Bénévole','permission.role.lecture'=>'Lecture seule','permission.module.dashboard'=>'Dashboard','permission.module.animals'=>'Animaux','permission.module.medical'=>'Médical','permission.module.care'=>'Tournées','permission.module.directory'=>'Annuaire','permission.module.statistics'=>'Statistiques','permission.module.administrative'=>'Administratif','permission.module.grants'=>'Subventions','permission.module_help.dashboard'=>'Priorités et situation quotidienne','permission.module_help.animals'=>'Fiches, portées, liens, médias et adoptions','permission.module_help.medical'=>'Notes, traitements, vaccinations et décès','permission.module_help.care'=>'Passages tablette et configuration des box','permission.module_help.directory'=>'Personnes, structures et coordonnées','permission.module_help.statistics'=>'Analyses annuelles et tendances','permission.module_help.administrative'=>'Registre, documents et exports','permission.module_help.grants'=>'Bilans, demandes et justificatifs', + 'settings.species_title'=>'Paramètres — Espèces','settings.species_heading'=>'Espèces animales','settings.species_intro'=>'Référentiel commun aux fiches, imports, registres et statistiques. Une espèce utilisée peut être désactivée sans perdre ses animaux.','settings.species_saved'=>'Le référentiel des espèces a été enregistré.','settings.add_species'=>'Ajouter une espèce','settings.name'=>'Nom','settings.species_example'=>'Ex. Tortue d’Hermann','settings.code'=>'Code','settings.category'=>'Catégorie','settings.icon'=>'Icône','settings.color'=>'Couleur','settings.order'=>'Ordre','settings.active_f'=>'Active','settings.stable_code'=>'Code stable','settings.animal_count'=>':count animal(aux)','common.save_short'=>'Sauver','species.category.animaux_compagnie'=>'Animaux de compagnie','species.category.nac'=>'NAC','species.category.animal_ferme'=>'Animaux de ferme','species.category.faune_sauvage'=>'Faune sauvage','species.category.autre'=>'Autre', + 'settings.association_required'=>'Le nom de l’association est obligatoire.','settings.invalid_email'=>'Adresse e-mail invalide.','settings.species_invalid'=>'Nom, code, catégorie ou couleur invalide.','settings.species_not_found'=>'Espèce introuvable.','settings.admin_only'=>'Accès réservé aux administrateurs.', + 'pricing.title'=>'Paramètres — Tarifs','pricing.heading'=>'Tarifs et coûts','pricing.intro'=>'Référentiel par structure. Les dépenses enregistrées conservent le tarif et la remise du jour.','pricing.saved'=>'Tarifs enregistrés.','pricing.adoption_fees'=>'Tarifs d’adoption','pricing.sterilized'=>'Stérilisé','pricing.unsterilized'=>'Non stérilisé','pricing.discounts'=>'Remises par structure','pricing.discounts_help'=>'Vue synthétique des réductions appliquées.','pricing.no_discount'=>'Aucune remise','pricing.configure'=>'Configurer','pricing.no_structure'=>'Ajoutez d’abord une structure éligible dans l’annuaire.','pricing.discount_for'=>'Remises · :name','pricing.future_help'=>'Les valeurs seront appliquées aux dépenses futures.','pricing.antibiotic_help'=>'Les antibiotiques restent à 0 % tant qu’aucune remise spécifique n’est accordée.','pricing.save_discounts'=>'Enregistrer les remises','pricing.add_label'=>'Ajouter un intitulé tarifaire','pricing.add_label_help'=>'Les médicaments peuvent être liés au référentiel existant. Choisissez « Antibiotique » pour neutraliser la remise.','pricing.structure'=>'Structure','pricing.label'=>'Intitulé','pricing.label_example'=>'Ex. Radiographie','pricing.catalog_price'=>'Prix catalogue','pricing.discount_group'=>'Groupe de remise','pricing.unit'=>'Unité','pricing.linked_medication'=>'Médicament lié','pricing.active_tariff'=>'Tarif actif','pricing.add_tariff'=>'Ajouter le tarif','pricing.grid'=>'Grille tarifaire','pricing.discount'=>'Remise','pricing.catalog'=>'Catalogue','pricing.estimated_cost'=>'Coût estimé','pricing.state'=>'État','pricing.per_unit'=>'par :unit','pricing.active'=>'Actif','pricing.inactive'=>'Inactif','pricing.invalid_structure'=>'Choisissez une structure éligible et complétez le tarif.','pricing.invalid_discount_structure'=>'Structure tarifaire invalide.', + 'pricing.group.act'=>'Acte','pricing.group.medication'=>'Médicament hors antibiotique','pricing.group.antibiotic'=>'Antibiotique','pricing.group.ape_api'=>'APE / API','pricing.group.analysis'=>'Analyse','pricing.group.none'=>'Sans remise','expense.add'=>'Ajouter une dépense','expense.history_help'=>'Le tarif catalogue et la remise sont figés dans l’historique.','expense.act_product'=>'Acte ou produit','expense.choose'=>'Choisir','expense.price_after_discount'=>'Prix après remise.','expense.quantity'=>'Quantité','expense.comment'=>'Commentaire','expense.comment_placeholder'=>'Facture, précision…','expense.save'=>'Enregistrer la dépense','expense.unit_estimate'=>'Coût unitaire estimé : :price € après :discount % de remise.','expense.estimated_vet'=>'Coût vétérinaire estimé','expense.recorded'=>':count dépenses tarifées enregistrées','expense.details'=>'Voir le détail','expense.expense'=>'Dépense','expense.total'=>'Total', + 'backups.title'=>'Paramètres — Sauvegardes','backups.heading'=>'Sauvegardes','backups.intro'=>'Base, photos, documents et identité visuelle dans une archive ZIP.','backups.created'=>'La sauvegarde complète a été créée.','backups.deleted'=>'La sauvegarde a été supprimée.','backups.failed'=>'Sauvegarde impossible :','backups.create'=>'Créer une sauvegarde','backups.create_help'=>'Le ZIP contient une copie cohérente de la base SQLite, les médias des animaux et le logo personnalisé de l’association.','backups.manifest_help'=>'Chaque fichier est répertorié dans un manifeste avec son empreinte SHA-256. Les :count sauvegardes les plus récentes sont conservées sur le serveur.','backups.create_now'=>'Créer une sauvegarde maintenant','backups.restore'=>'Restaurer une sauvegarde','backups.restore_confirm'=>'Restaurer cette sauvegarde ?\n\nLes données, photos et documents actuels seront remplacés. Une sauvegarde d’urgence sera créée automatiquement avant la restauration.','backups.archive'=>'Archive Globinours (.zip)','backups.understand'=>'Je comprends que les données actuelles seront remplacées.','backups.restore_archive'=>'Restaurer l’archive','backups.available'=>'Sauvegardes disponibles','backups.archive_column'=>'Archive','backups.size'=>'Taille','backups.emergency'=>'Urgence','backups.download'=>'Télécharger','backups.delete_confirm'=>'Supprimer définitivement cette sauvegarde du serveur ?','common.delete'=>'Supprimer','backups.none'=>'Aucune sauvegarde ZIP pour le moment.','backups.not_found'=>'Sauvegarde introuvable.','backups.invalid_file'=>'Fichier ZIP manquant ou invalide.','backups.too_large'=>'La sauvegarde dépasse la limite de 1 Go.','unit.mb'=>'Mo','unit.kb'=>'Ko', + 'data.title'=>'Paramètres — Données et démonstration','data.heading'=>'Données et démonstration','data.intro'=>'Réinitialiser les données métier sans supprimer les utilisateurs ni les paramètres de l’association.','data.reset_success'=>'La base métier est vide. Sauvegarde créée : :backup.','data.demo_success'=>'La base de démonstration a été chargée. Sauvegarde créée : :backup.','data.animals'=>'Animaux actuellement présents','data.contacts'=>'Contacts actuellement présents','data.destructive'=>'Opération destructive.','data.destructive_help'=>'Une sauvegarde ZIP complète est toujours créée avant l’action. Les comptes utilisateurs, permissions, paramètres, logo et modèles de documents sont conservés.','data.reset'=>'Remettre le projet à zéro','data.reset_help'=>'Supprime animaux, annuaire, historique, médical, portées, placements, tournées, imports et dossiers de subvention.','data.confirmation'=>'Confirmation','data.type_reset'=>'Tapez VIDER LA BASE','data.exact_phrase'=>'Phrase exacte :','data.backup_reset'=>'Sauvegarder puis vider la base','data.reset_confirm'=>'Vider toutes les données métier ? Une sauvegarde sera créée avant l’opération.','data.demo'=>'Charger la démonstration','data.demo_help'=>'Vide les données métier puis crée des chats, une portée, un duo inséparable, des contacts, une FA, du médical, des vaccins et un décès fictifs.','data.type_demo'=>'Tapez CHARGER LA DEMO','data.backup_demo'=>'Sauvegarder puis charger la démo','data.demo_confirm'=>'Remplacer les données actuelles par un jeu entièrement fictif ?','data.confirm_required'=>'La confirmation exacte « :phrase » est requise.', + 'audit.title'=>'Paramètres — Journal d’audit','audit.heading'=>'Journal d’audit','audit.intro'=>'Les 500 actions les plus récentes.','audit.user'=>'Utilisateur','audit.action'=>'Action','audit.item'=>'Élément','audit.path'=>'Chemin','audit.none'=>'Aucune action enregistrée.','settings.navigation'=>'Navigation des paramètres', + 'audit.deleted_account'=>'Compte supprimé', + 'asm3.title'=>'Paramètres — Migration ASM3','asm3.analyzed_success'=>'Dump analysé. Vérifiez le rapport avant l’import.','asm3.imported_success'=>'Migration ASM3 terminée. La sauvegarde préalable reste disponible.','asm3.new'=>'Nouvelle migration ASM3','asm3.new_help'=>'Le dépôt déclenche uniquement une analyse. Les exports MySQL et PostgreSQL sont acceptés.','asm3.dump'=>'Dump ASM3 (.sql ou .pgsql)','asm3.upload_help'=>'250 Mo maximum · stockage privé · limite PHP active : :limit.','asm3.php_limit'=>'La limite PHP active semble trop basse. Pour le serveur intégré, relancez Globinours avec :','asm3.analyze'=>'Analyser le dump','asm3.analyzed_at'=>'Analysé le :date','asm3.by'=>'par :name','asm3.status.analyzed'=>'Prêt à importer','asm3.status.completed'=>'Importé','asm3.status.failed'=>'Échec','asm3.animals'=>'Animaux','asm3.contacts'=>'Contacts','asm3.movements'=>'Mouvements','asm3.vaccinations'=>'Vaccinations','asm3.medical_notes'=>'Notes médicales','asm3.weights'=>'Poids','asm3.litters'=>'Portées','asm3.to_review'=>':count à vérifier','asm3.media'=>'Médias référencés : :count. Les fichiers binaires absents du dump ne peuvent pas être recréés.','asm3.mode'=>'Mode d’import','asm3.merge'=>'Fusionner','asm3.merge_help'=>'Conserver les données Globinours actuelles.','asm3.replace'=>'Remplacer les données métier','asm3.replace_help'=>'Effacer animaux, annuaire et historique opérationnel. Utilisateurs, paramètres et subventions sont conservés.','asm3.confirmation'=>'Confirmation','asm3.type_import'=>'Tapez IMPORTER ASM3','asm3.type_replace'=>'Tapez REMPLACER PAR ASM3','asm3.expected'=>'Confirmation attendue :','asm3.transaction_help'=>'Une sauvegarde ZIP est créée avant l’opération. Le nettoyage et l’import forment une seule transaction : en cas d’erreur, aucune modification n’est conservée.','asm3.backup_import'=>'Sauvegarder puis importer','asm3.technical_report'=>'Rapport technique','asm3.none'=>'Aucune analyse ASM3 pour le moment.','asm3.post_too_large'=>'Le dump dépasse la taille de requête autorisée par PHP (post_max_size : :limit). Redémarrez le serveur avec la configuration indiquée sur cette page.','asm3.select_dump'=>'Sélectionnez un dump SQL ASM3.','asm3.upload_unverified'=>'Le fichier téléversé n’a pas pu être vérifié par PHP.','asm3.over_250'=>'Le dump dépasse 250 Mo.','asm3.extension'=>'Le fichier doit porter l’extension .sql ou .pgsql.','asm3.storage_unavailable'=>'Stockage temporaire indisponible.','asm3.storage_failed'=>'Impossible de stocker le dump.','asm3.no_animal'=>'Ce fichier ne contient aucune fiche animale ASM3.','asm3.confirm_required'=>'La confirmation exacte « :phrase » est requise.','asm3.analysis_unavailable'=>'Analyse introuvable ou déjà utilisée.','asm3.dump_altered'=>'Le dump stocké est absent ou altéré.','asm3.import_failed'=>'Import interrompu, aucune donnée de la transaction n’a été conservée : :error','asm3.upload_ini'=>'Le dump dépasse la limite PHP active (upload_max_filesize : :limit). Redémarrez le serveur avec la configuration indiquée sur cette page.','asm3.upload_form'=>'Le dump dépasse la taille autorisée par le formulaire.','asm3.upload_partial'=>'Le téléversement du dump a été interrompu. Réessayez.','asm3.tmp_unavailable'=>'Le dossier temporaire PHP est indisponible.','asm3.write_failed'=>'PHP n’a pas pu écrire le dump sur le disque.','asm3.extension_stopped'=>'Une extension PHP a interrompu le téléversement.','asm3.upload_failed'=>'Le téléversement du dump a échoué (code :code).', + 'asm3.hash_unavailable'=>'Empreinte du dump indisponible.','asm3.hash_failed'=>'Impossible de calculer l’empreinte du dump.','asm3.unreadable'=>'Dump ASM3 introuvable ou illisible.','asm3.open_failed'=>'Impossible d’ouvrir le dump ASM3.','asm3.animal_table_missing'=>'La table animal est absente : aucune migration animale ne sera possible.','asm3.animals_without_name'=>':count animal(aux) sans nom.','asm3.animals_without_birth'=>':count animal(aux) sans date de naissance.', + 'death.title'=>'Décès de :name','death.record'=>'Enregistrer le décès de :name','death.intro'=>'Ces informations alimenteront l’historique et les statistiques du refuge.','animal.back_record'=>'Retour à la fiche','death.important'=>'Important :','death.important_help'=>'Un décès survenu après une adoption ou hors de la responsabilité de l’association reste enregistré, mais n’est pas inclus dans les chiffres de mortalité du refuge.','death.date'=>'Date du décès','death.main_cause'=>'Cause principale','death.euthanized'=>'Décès consécutif à une euthanasie','death.cause_details'=>'Précisions sur la cause','death.cause_placeholder'=>'Diagnostic, circonstances, pathologie…','death.context_place'=>'Contexte et lieu','death.place'=>'Lieu','death.place_details'=>'Précision sur le lieu','death.place_placeholder'=>'Salle, adresse ou établissement','death.in_care'=>'Décès sous la responsabilité du refuge','death.in_care_help'=>'À cocher si l’animal était encore pris en charge par l’association, y compris en FA ou chez le vétérinaire.','death.veterinarian'=>'Vétérinaire','common.not_provided_option'=>'Non renseigné','death.care_body'=>'Prise en charge','death.body_disposition'=>'Devenir du corps','death.crematorium'=>'Crématorium','death.crematorium_help'=>'Les crématoriums sont gérés dans l’annuaire.','death.cremation_date'=>'Date de crémation','death.internal_notes'=>'Notes internes','death.notes_placeholder'=>'Consignes, suivi des cendres, référence du dossier…','death.confirm'=>'Confirmer l’enregistrement de ce décès ?','death.save'=>'Enregistrer le décès','death.invalid'=>'Informations de décès invalides.','death.cause_required'=>'Précisez la cause du décès.','death.cremation_after'=>'La date de crémation doit être postérieure ou égale au décès.','death.place.refuge'=>'Au refuge','death.place.fa'=>'En famille d’accueil','death.place.veterinaire'=>'Chez le vétérinaire','death.place.exterieur'=>'À l’extérieur','death.place.inconnu'=>'Lieu inconnu','death.place.autre'=>'Autre lieu','death.disposition.pending'=>'À organiser','death.disposition.collective_cremation'=>'Crémation collective','death.disposition.individual_cremation'=>'Crémation individuelle','death.disposition.returned_family'=>'Corps ou cendres remis à la famille','death.disposition.burial'=>'Inhumation','death.disposition.other'=>'Autre prise en charge', + 'inactive.trash'=>'Corbeille','inactive.archives'=>'Archives','inactive.deleted_on'=>'Supprimé le','inactive.archived_on'=>'Archivé le','inactive.trash_help'=>'Ces fiches sont exclues du listing, des portées actives et de toutes les statistiques.','inactive.archive_help'=>'Dossiers retirés du suivi courant : animaux adoptés, décédés ou archivés manuellement.','inactive.back'=>'Retour aux animaux actifs','inactive.search'=>'Recherche : nom, code interne, puce ou commune','inactive.reason'=>'Motif','inactive.restore_confirm'=>'Restaurer cette fiche dans le listing actif ?','inactive.restore'=>'Restaurer','inactive.no_trash'=>'Aucune fiche supprimée ne correspond à la recherche.','inactive.no_archive'=>'Aucune fiche archivée ne correspond à la recherche.', + 'litter.create'=>'Ajouter une portée','litter.mother'=>'Maman','litter.father'=>'Papa','common.optional_parentheses'=>'(facultatif)','litter.unknown_parent'=>'Parent inconnu','litter.birth_date'=>'Date de naissance','litter.kitten_type'=>'Type de chatons','litter.new_kittens'=>'Nouveaux chatons','litter.existing_cats'=>'Chats existants','litter.kitten_count'=>'Nombre de chatons','litter.create_records'=>'Globinours créera une nouvelle fiche pour chacun.','litter.new_records'=>'Nouvelles fiches','litter.auto_name'=>'Le nom automatique sera « Parent-Bébé-N ».','litter.select_siblings'=>'Sélectionner les frères et sœurs','litter.no_new_record'=>'Aucune nouvelle fiche ne sera créée.','litter.search_name_code'=>'Rechercher par nom ou code…','animal.no_code'=>'Sans code','animal.archived_badge'=>'Archivé','litter.create_litter'=>'Créer la portée','litter.name_optional'=>'Nom (facultatif)','litter.auto_name_placeholder'=>'Nom automatique','animal.coat'=>'Robe','common.to_define'=>'À définir','animal.sex_label'=>'Sexe','common.remove'=>'Retirer','litter.reduce_confirm'=>'Réduire ce nombre supprimera les informations déjà saisies pour les derniers chatons. Continuer ?','litter.add_existing'=>'Ajouter des chats à la portée','litter.summary'=>'Portée du :date · Maman : :mother · Papa : :father','common.unknown_f'=>'inconnue','common.unknown_m'=>'inconnu','common.unknown_date'=>'date inconnue','litter.search_cat'=>'Rechercher un chat','litter.cat_name_code'=>'Nom ou code du chat…','litter.attached_hidden'=>'Les chats déjà rattachés à une portée ne sont pas proposés.','animal.sex_to_define'=>'Sexe à définir','litter.none_available'=>'Aucun chat disponible à rattacher.','litter.select_multiple'=>'Vous pouvez sélectionner un ou plusieurs chats existants.','litter.add_to'=>'Ajouter à la portée', + 'bonded.create'=>'Créer un groupe inséparable','bonded.intro'=>'Les membres devront être placés ou adoptés ensemble.','bonded.group_name'=>'Nom du groupe','bonded.group_example'=>'Ex. Duo Caramel & Nougat','bonded.note_example'=>'Ex. Très fusionnels, ne pas séparer','bonded.members'=>'Membres du groupe','bonded.select_two'=>'Sélectionnez au moins deux chats.','bonded.save'=>'Créer le groupe inséparable', + 'litter.add_title'=>'Ajouter des chatons à une portée','litter.need_kitten'=>'Créez ou sélectionnez au moins un chaton.','litter.different_parents'=>'Les deux parents doivent être différents.','litter.invalid_parent'=>'Parent invalide ou non éligible.','litter.invalid_selected'=>'Un chat sélectionné est indisponible, est parent de la portée ou appartient déjà à une autre portée.','litter.not_found'=>'Portée introuvable.','litter.select_one'=>'Sélectionnez au moins un chat.','litter.invalid_existing'=>'Un chat est indisponible, est parent de cette portée ou appartient déjà à une autre portée.','bonded.unavailable'=>'Animal indisponible ou déjà lié.','bonded.need_two'=>'Sélectionnez au moins deux chats.','bonded.invalid_member'=>'Un des chats est indisponible ou appartient déjà à un groupe.', + 'coat.black_white'=>'Bicolore Noir / Blanc','coat.grey_white'=>'Bicolore Gris / Blanc','coat.ginger_white'=>'Bicolore Roux / Blanc','coat.tabby_white'=>'Bicolore Tigré et Blanc','coat.grey'=>'Gris','coat.ginger'=>'Roux','coat.black'=>'Noir','coat.chocolate'=>'Chocolat','coat.white'=>'Blanc','coat.calico'=>'Tricolore / Calico','coat.tortoiseshell'=>'Écaille de tortue','coat.colourpoint'=>'Colourpoint / Siamois','coat.tabby'=>'Tigré / Tabby', + 'access.denied'=>'Accès non autorisé','access.edit_denied'=>'Modification non permise','access.page_denied'=>'Page non accessible','access.explanation_edit'=>'Votre rôle ne permet pas de modifier le module :module. Si cet accès vous semble nécessaire, un administrateur peut ajuster les permissions de votre groupe.','access.explanation_view'=>'Votre rôle ne permet pas de consulter le module :module. Si cet accès vous semble nécessaire, un administrateur peut ajuster les permissions de votre groupe.','access.requested'=>'demandé','access.back_space'=>'Retour à mon espace','access.previous'=>'Page précédente', + 'deworm.catalog_record'=>'Enregistrer un vermifuge','deworm.product'=>'Vermifuge','deworm.add_catalog'=>'Ajouter un nouveau vermifuge au référentiel','deworm.name'=>'Nom du vermifuge','deworm.ingredient'=>'Molécule (optionnel)','deworm.form'=>'Forme','deworm.dose_example'=>'Ex. 1/2 comprimé','deworm.next_due'=>'Prochaine échéance','deworm.auto_age'=>'Calcul automatique selon l’âge si ce champ reste vide.','deworm.apply_litter'=>'Administrer aussi à la maman et à tous les chatons de la portée', + 'adoption.modal_title'=>'Adoption de l’animal','adoption.directory_adopter'=>'Adoptant dans l’annuaire','adoption.new_adopter'=>'Créer un nouvel adoptant','adoption.contact_help'=>'Choisissez une personne existante ou créez sa fiche directement ici.','adoption.adopter_name'=>'Nom de l’adoptant','adoption.date'=>'Date d’adoption','adoption.validate'=>'Valider l’adoption','history.title'=>'Historique','history.none'=>'Aucun historique.','history.event'=>'Événement','history.detail'=>'Détail','history.user'=>'Utilisateur', + 'animal.archive_confirm'=>'Archiver cet animal ?\n\nIl sera retiré du suivi courant et pourra être restauré depuis les archives.','animal.trash_reason'=>'Pourquoi placer cet animal dans la corbeille ? (facultatif)','animal.trash_default_reason'=>'Création par erreur','animal.trash_confirm'=>'Confirmer la mise à la corbeille ?\n\nLa fiche ne sera plus comptée ni affichée, mais restera restaurable.','animal.natural_death_short'=>'Décès naturel ou accidentel','animal.death_age'=>'Âge au décès :','animal.age_days'=>'jour(s)','animal.age_weeks'=>'semaine(s)','animal.age_months'=>'mois','animal.age_years'=>'an(s)','animal.bonded_dissolve_confirm'=>'Dissoudre ce groupe inséparable ? Les fiches des chats seront conservées.','animal.delete_photo_confirm'=>'Supprimer cette photo ?','animal.medication_form_placeholder'=>'Comprimé, sirop…','animal.person_placeholder'=>'Bénévole, intervenant extérieur…','animal.test_notes_placeholder'=>'Laboratoire, confirmation, conduite à tenir…','animal.condition_placeholder'=>'Ex. insuffisance rénale','animal.reason_placeholder'=>'Ex. contrôle, boiterie, vomissements…', + 'freecat.journey'=>'Parcours chat libre','freecat.journey_help'=>'Site de vie et suivi après remise en liberté.','freecat.tracking_state'=>'État du suivi','freecat.present'=>'Présent','freecat.missing'=>'Disparu','freecat.moved'=>'Déplacé','freecat.death_help'=>'Un décès se déclare depuis l’action dédiée.','freecat.precise_location'=>'Localisation précise','common.internal'=>'interne','freecat.address_placeholder'=>'Adresse ou indication d’accès','freecat.caretaker'=>'Référent ou nourrisseur','freecat.directory_person'=>'Personne enregistrée dans l’annuaire.','freecat.release_date'=>'Date de remise en liberté','animal.rescue_location'=>'Lieu de récupération','animal.geocode_help'=>'Géocodage automatique via OpenStreetMap.','animal.intake_circumstances_placeholder'=>'Contexte de la trouvaille, précisions données lors du dépôt…', + 'dashboard.grants_check'=>'Suivi des subventions à vérifier','dashboard.grants_summary'=>':overdue échéances dépassées · :deadlines sous 30 jours · :incomplete dossiers incomplets · :documents pièces expirées ou proches de l’être','dashboard.open_grants'=>'Ouvrir le suivi','footer.description'=>'La gestion quotidienne d’un refuge, pensée pour rester humaine, rapide et lisible.','footer.brewed'=>'Concocté avec 🧡','footer.brewed_label'=>'Concocté avec amour','footer.by_brittany'=>'en 2026 par :developer en Bretagne','footer.breton_flag'=>'Drapeau breton','footer.evolutions'=>'Évolutions','footer.evolutions_title'=>'Voir les évolutions et la feuille de route','footer.source'=>'Code source','footer.source_title'=>'Consulter le dépôt Git de Globinours','map.rescue'=>'Récupération','map.missing_coordinates'=>'Coordonnées non renseignées', + 'animal.icad_not_checked'=>'i-CAD : non consulté','animal.sterilization_label'=>'Stérilisation','freecat.site'=>'Site ou colonie','freecat.site_example'=>'Ex. Colonie du port','common.city'=>'Commune','freecat.capture_date'=>'Date de capture','animal.rescue_location_placeholder'=>'Ex. Rue, ville… (ou simplement la commune)','footer.tagline'=>'Né de l’histoire de Twix, conçu pour protéger les suivants.','map.current'=>'Lieu actuel','map.flight_path'=>'Trajet à vol d’oiseau','validation.required'=>'Ce champ est obligatoire.','validation.password_min'=>'Le mot de passe doit contenir au moins :count caractères.','validation.invalid_format'=>'Le format saisi n’est pas valide.','validation.check_field'=>'Veuillez vérifier ce champ.', + 'error.animal_not_found'=>'Animal introuvable.','error.vet_clinic_required'=>'Le vétérinaire et sa structure sont obligatoires.','error.use_death_action'=>'Utilisez l’action « Enregistrer un décès » depuis la fiche de l’animal.','error.valid_species'=>'Sélectionnez une espèce valide dans le référentiel.','error.freecat_cats_only'=>'Le parcours « Chat libre » est réservé aux chats.','error.invalid_sterilization_date'=>'Date de stérilisation invalide.','error.name_required'=>'Nom requis.','error.invalid_placement'=>'Événement de placement invalide.','error.placement_reason_required'=>'Le motif est requis pour un retour ou une annulation.','error.adoption_failed'=>'L’adoption n’a pas pu être enregistrée. Aucune donnée technique n’a été affichée ; consultez les journaux du serveur.','error.document_storage'=>'Échec du stockage du document.','error.tariff_not_found'=>'Tarif introuvable.', + 'pdf.medical_record'=>'Dossier médical — :name','pdf.generated_on'=>'Généré le :date','pdf.identity'=>'Identité','pdf.internal_code'=>'Code interne','pdf.main_photo'=>'Photo principale','pdf.medical_notes'=>'Notes médicales','pdf.no_medical_notes'=>'Aucune note médicale.','pdf.content'=>'Contenu','pdf.clinic_vet'=>'Clinique / Vétérinaire','pdf.reason'=>'Motif','pdf.temperature'=>'Température','pdf.symptoms'=>'Symptômes','pdf.exam'=>'Examen','pdf.diagnosis'=>'Diagnostic','pdf.plan'=>'Plan','pdf.treatments'=>'Traitements','pdf.no_treatment'=>'Aucun traitement.','pdf.state'=>'État','pdf.medication'=>'Médicament','pdf.start'=>'Début','pdf.end'=>'Fin','pdf.ongoing'=>'En cours','pdf.finished'=>'Terminé','pdf.route'=>'Voie','pdf.dosage'=>'Posologie','pdf.vaccines'=>'Vaccins','pdf.no_vaccine'=>'Aucun vaccin.','pdf.vaccine'=>'Vaccin','pdf.done_on'=>'Fait le','pdf.booster'=>'Rappel','pdf.register'=>'Registre des entrées et sorties — :year','pdf.generated_by'=>'Généré par :association, le :date à :time','pdf.movements'=>':count mouvement(s)','pdf.reason_contact'=>'Raison / Coordonnées','pdf.entry'=>'Entrée','pdf.exit'=>'Sortie','pdf.back_register'=>'Retour au registre','pdf.print'=>'Imprimer / enregistrer en PDF','pdf.no_movement'=>'Aucun mouvement pour ces filtres.', + 'species.cat'=>'Chat','species.dog'=>'Chien','species.nac'=>'NAC','pdf.weight'=>'Poids','pdf.lot'=>'Lot', + 'pdf.back_record'=>'Retour à la fiche','pdf.health_alert'=>'ALERTE SANITAIRE','pdf.no_main_photo'=>'Aucune photo principale.','pdf.summary'=>'Synthèse','pdf.last_medical'=>'Dernier médical','pdf.next_vaccine'=>'Prochain vaccin','pdf.internal_document'=>'Document interne — :association / Globinours','pdf.death_recorded'=>'Décès enregistré — :date','pdf.unknown_date'=>'date inconnue','pdf.death_in_care'=>'Décès sous la responsabilité du refuge','pdf.death_outside_care'=>'Décès hors prise en charge du refuge','pdf.crematorium'=>'Crématorium','pdf.photos'=>'Photos', + 'data.confirm_reset_phrase'=>'VIDER LA BASE','data.confirm_demo_phrase'=>'CHARGER LA DEMO','asm3.confirm_import_phrase'=>'IMPORTER ASM3','asm3.confirm_replace_phrase'=>'REMPLACER PAR ASM3', + 'service.document.adoption_missing'=>'Modèle du contrat d’adoption introuvable.','service.document.template_missing'=>'Modèle de document introuvable.','service.document.copy_failed'=>'Impossible de copier le modèle.','service.document.open_failed'=>'Impossible d’ouvrir le modèle ODT.','service.document.xml_invalid'=>'XML du modèle invalide.','service.document.content_invalid'=>'Contenu ODT invalide.','service.document.update_contract_failed'=>'Impossible de mettre à jour le contrat.','service.document.update_failed'=>'Impossible de mettre à jour le document.','service.document.libreoffice_failed'=>'Impossible de lancer LibreOffice.','service.document.temp_failed'=>'Impossible de créer le dossier temporaire.', + 'service.image.format'=>'Format non accepté. Utilisez une image JPEG, PNG, WebP ou GIF.','service.image.invalid_upload'=>'Le fichier téléversé est invalide.','service.image.directory_failed'=>'Impossible de créer le dossier média.','service.image.optimized_failed'=>'Impossible de finaliser l’image optimisée.','service.image.save_failed'=>'Impossible d’enregistrer l’image.', + 'service.logo.upload_failed'=>'Le logo n’a pas pu être téléversé.','service.logo.too_large'=>'Le logo ne doit pas dépasser 5 Mo.','service.logo.format'=>'Le logo doit être une image PNG, JPEG ou WebP.','service.logo.finalize_failed'=>'Impossible de finaliser le logo.', + 'service.private_media.invalid_name'=>'Nom de média invalide.','service.private_media.not_found'=>'Fichier média introuvable.','service.private_media.move_failed'=>'Impossible de déplacer le média.', + 'service.export.zip_missing'=>'L’extension ZIP est indisponible.','service.export.prepare_failed'=>'Impossible de préparer l’export.','service.export.unknown_dataset'=>'Jeu de données inconnu.', + 'service.icad.login_failed'=>'Connexion i-CAD échouée : la page professionnelle n’est pas authentifiée.','service.icad.no_result'=>'Aucun résultat i-CAD pour cette identification.','service.icad.token_missing'=>'Élément i-CAD introuvable : :field.','service.icad.form_missing'=>'Formulaire de recherche d’animal i-CAD introuvable.', + 'error.invalid_expense'=>'Dépense invalide.', + 'service.backup.zip_missing'=>'L’extension PHP ZIP est indisponible.','service.backup.directory_failed'=>'Impossible de créer le dossier des sauvegardes.','service.backup.create_failed'=>'Impossible de créer l’archive ZIP.','service.backup.not_found'=>'Sauvegarde introuvable.','service.backup.delete_failed'=>'Impossible de supprimer cette sauvegarde.','service.backup.restore_missing'=>'Archive de restauration introuvable.','service.backup.prepare_failed'=>'Impossible de préparer la restauration.','service.backup.unreadable'=>'Le fichier ZIP est illisible.','service.backup.too_many_files'=>'Cette archive contient trop de fichiers.','service.backup.too_large'=>'Cette archive est trop volumineuse.','service.backup.unsafe_path'=>'Chemin dangereux détecté dans le ZIP.','service.backup.manifest_missing'=>'Le manifeste Globinours est absent.','service.backup.incompatible'=>'Format de sauvegarde incompatible.','service.backup.extract_failed'=>'Impossible d’extraire la sauvegarde.','service.backup.manifest_path'=>'Le manifeste contient un chemin non autorisé.','service.backup.file_altered'=>'Fichier manquant ou altéré : :name.','service.backup.database_missing'=>'La base SQLite est absente de la sauvegarde.','service.backup.database_hash'=>'L’empreinte de la base SQLite est invalide.','service.backup.database_corrupt'=>'La base sauvegardée est corrompue.','service.backup.table_missing'=>'Table obligatoire absente : :table.','service.backup.secure_failed'=>'Impossible de mettre :path en sécurité.','service.backup.restore_path_failed'=>'Impossible de restaurer :path.','service.backup.secure_database_failed'=>'Impossible de mettre la base actuelle en sécurité.','service.backup.restore_database_failed'=>'Impossible de restaurer la base SQLite.','service.backup.add_failed'=>'Impossible d’ajouter :name au ZIP.', + 'service.document.invalid_type'=>'Type de document incorrect.','service.document.pdf_failed'=>'Conversion PDF impossible. :details','service.image.missing_large'=>'Image absente ou trop volumineuse (20 Mo maximum).','service.image.dimensions'=>'Les dimensions de cette image sont trop importantes.','service.logo.dimensions'=>'Les dimensions du logo sont invalides ou trop importantes.','service.icad.invalid_chip'=>'Identification vide ou invalide.','service.icad.credentials_missing'=>'Identifiants i-CAD manquants dans la configuration.','service.icad.network_error'=>'Erreur réseau i-CAD : :error', + 'error.bad_request'=>'Requête invalide.','error.missing_id'=>'Identifiant manquant.','error.method_not_allowed'=>'Méthode non autorisée.','error.dose_required'=>'La dose est obligatoire.','error.medication_name_required'=>'Le nom du médicament est obligatoire.','error.invalid_batch_expiry'=>'Date d’expiration du lot invalide.','error.upload_failed'=>'Le téléversement a échoué.','error.no_files'=>'Aucun fichier fourni.','error.not_found'=>'Élément introuvable.','error.photo_not_found'=>'Photo introuvable.','error.invalid_birth_date'=>'Date de naissance invalide.','error.invalid_date'=>'Date invalide.','error.invalid_freecat_date'=>'Date du parcours chat libre invalide.','error.invalid_foster'=>'Famille d’accueil invalide.','error.missing_pdf_view'=>'Le modèle d’impression de la fiche animale est introuvable.', + 'quality.filter.birth'=>'Date de naissance manquante','quality.filter.sex'=>'Sexe non renseigné','quality.filter.color'=>'Couleur non renseignée','quality.filter.breed'=>'Race non renseignée','quality.filter.origin'=>'Origine non renseignée','quality.filter.chip'=>'Identification manquante','quality.filter.registration'=>'Enregistrement I-CAD non renseigné','quality.filter.adoption'=>'Disponibilité à l’adoption non renseignée','quality.filter.unsterilized'=>'Animal non stérilisé','quality.filter.sterilization'=>'Stérilisation non renseignée','animals.current_care'=>'Actuellement pris en charge','animals.all_records'=>'Tous les dossiers','animals.title'=>'Animaux','litter.group_details'=>'Portée du :date · :count chaton(s)','bonded.default_name'=>'Chats inséparables','bonded.group_details'=>':count chat(s) · :state','bonded.adopt_together'=>'À adopter ensemble','bonded.incomplete'=>'Groupe actuellement incomplet', + 'grant.actual_expenses'=>'Dépenses réalisées','grant.actual_income'=>'Recettes réalisées','grant.treasury'=>'Trésorerie','grant.forecast_expenses'=>'Dépenses prévues','grant.forecast_income'=>'Recettes prévues','grant.voluntary_contributions'=>'Contributions volontaires','animal.compatible_dogs'=>'Compatible avec les chiens','animal.compatible_cats'=>'Compatible avec les chats','animal.compatible_children'=>'Compatible avec les enfants','animal.house_trained'=>'Propre','common.city_example'=>'Ex. Lesneven','dashboard.overdue_days'=>'En retard (J+:days)','dashboard.exit_today'=>'Sortie aujourd’hui','dashboard.exit_tomorrow'=>'Sortie demain','dashboard.exit_days'=>'Sortie dans :days jours','animal.vaccine_booster'=>'Rappel','chart.weight'=>'Poids (kg)','chart.date'=>'Date', + 'project.evolutions_description'=>'Historique des grandes évolutions et feuille de route de Globinours.', + 'error.access_denied'=>'Accès refusé.','deworm.invalid_date'=>'Date de vermifuge invalide.','deworm.name_required'=>'Le nom du vermifuge est obligatoire.','deworm.invalid_next_due'=>'Prochaine échéance invalide.', + 'statistics.cost_scope_animals'=>'Animaux dans le périmètre','statistics.cost_with_expense'=>'Avec au moins une dépense','statistics.cost_avg_all'=>'Moyenne par animal pris en charge','statistics.cost_avg_spending'=>'Moyenne par animal ayant des frais','statistics.cost_median'=>'Médiane des animaux ayant des frais','statistics.cost_metric_help'=>'La moyenne générale inclut les animaux sans dépense enregistrée. La médiane limite l’effet des cas vétérinaires exceptionnellement coûteux.','statistics.cost_by_city'=>'Coût vétérinaire par commune d’origine','statistics.cost_by_city_help'=>'Animaux pris en charge ou ayant généré une dépense pendant la période. La commune provient du lieu de récupération.','statistics.municipality'=>'Commune','statistics.expense_animals'=>'Animaux avec frais','statistics.total_cost'=>'Coût total','statistics.average_all'=>'Moyenne globale','statistics.average_with_expense'=>'Moyenne avec frais','statistics.median'=>'Médiane','statistics.cost_city_open'=>'Ouvrir les statistiques de :city','statistics.cost_no_city'=>'Aucune dépense ne peut encore être rapprochée d’une commune.','statistics.animals_short'=>':count animal(aux)','statistics.structure_cost_help'=>'Montants et moyenne parmi les animaux facturés par cette structure. Cliquez sur son nom pour ouvrir le détail de la période.','statistics.open_structure_cost'=>'Voir le détail financier de :structure','statistics.category_cost_help'=>'Montants et moyenne parmi les animaux concernés par cette catégorie.', + 'medical_document.prescriptions'=>'Ordonnances','medical_document.private_documents'=>'Documents médicaux privés, accessibles uniquement aux rôles autorisés.','medical_document.no_prescription'=>'Aucune ordonnance enregistrée.','medical_document.add_prescription'=>'Ajouter une ordonnance','medical_document.prescription_added'=>'Ordonnance ajoutée','medical_document.prescribed_on'=>'Date de prescription','medical_document.file'=>'Scan ou photo','medical_document.private_hint'=>'PDF ou photo, 20 Mo maximum. Le document sera conservé dans l’espace médical privé.','medical_document.link_treatments'=>'Relier aux traitements en cours','medical_document.status_active'=>'En cours','medical_document.status_finished'=>'Terminée','medical_document.status_replaced'=>'Remplacée', + 'medical_document.lab_results'=>'Résultats d’analyses','medical_document.lab_help'=>'Valeurs structurées et évolution dans le temps.','medical_document.no_analysis'=>'Aucune analyse enregistrée.','medical_document.add_analysis'=>'Ajouter des résultats','medical_document.analysis_added'=>'Résultats d’analyse ajoutés','medical_document.analysis'=>'Analyse','medical_document.sampled_on'=>'Date du prélèvement','medical_document.analysis_type'=>'Type d’analyse','medical_document.analysis_example'=>'Ex. bilan rénal','medical_document.laboratory'=>'Laboratoire','medical_document.report_optional'=>'Compte rendu (facultatif)','medical_document.open_report'=>'Voir le compte rendu','medical_document.measured_parameters'=>'Paramètres mesurés','medical_document.parameters_help'=>'Une courbe distincte sera créée pour chaque paramètre et unité.','medical_document.parameter'=>'Paramètre','medical_document.value'=>'Valeur','medical_document.unit'=>'Unité','medical_document.reference'=>'Valeurs usuelles','medical_document.reference_min'=>'Référence basse','medical_document.reference_max'=>'Référence haute','medical_document.template'=>'Modèle de bilan…','medical_document.template_renal'=>'Bilan rénal','medical_document.template_hepatic'=>'Bilan hépatique','medical_document.template_thyroid'=>'Bilan thyroïdien','medical_document.evolution'=>'Évolution des analyses','medical_document.graph_help'=>'Bleu : sous la plage · vert : dans la plage · rouge : au-dessus. Les pointillés suivent les valeurs de référence saisies.','medical_document.invalid_data'=>'Animal ou date invalide.','medical_document.invalid_result'=>'Chaque résultat renseigné doit avoir un paramètre et une valeur numérique.','medical_document.result_or_document_required'=>'Ajoutez au moins un résultat ou un compte rendu.','medical_document.file_required'=>'Ajoutez le scan ou la photo de l’ordonnance.','medical_document.file_invalid'=>'Le fichier est absent, incomplet ou dépasse 20 Mo.','medical_document.file_type'=>'Formats acceptés : PDF, JPEG, PNG et WebP.','medical_document.storage_failed'=>'Impossible d’enregistrer le document médical.', + 'settings.health'=>'Santé de Globinours','install.requirements'=>'Prérequis techniques','install.requirements_help'=>'Les éléments obligatoires doivent être validés avant de créer la première installation. Les avertissements correspondent à des fonctions facultatives ou recommandées.','install.public_directory'=>'Dossier public à déclarer dans le serveur web','install.association'=>'Identité de l’association','install.administrator'=>'Premier administrateur','install.requirements_block'=>'Les prérequis obligatoires ne sont pas tous disponibles.','install.runtime_missing'=>'Le serveur ne possède pas encore les extensions nécessaires à Globinours.','install.association_required'=>'Le nom de l’association est obligatoire.','install.complete'=>'Installation terminée. Globinours est prêt ; vérifiez ci-dessous l’état du serveur.','health.page_title'=>'Paramètres — Santé de Globinours','health.title'=>'Santé de Globinours','health.intro'=>'Diagnostic en lecture seule du serveur, de la base, des fichiers et des sauvegardes.','health.refresh'=>'Relancer le diagnostic','health.ok'=>'Contrôles réussis','health.warnings'=>'Avertissements','health.errors'=>'Erreurs','health.group_environment'=>'Environnement PHP','health.group_database'=>'Base de données','health.group_storage'=>'Stockage','health.group_continuity'=>'Continuité et sauvegardes','health.group_files'=>'Cohérence des fichiers','health.group_security'=>'Configuration de production','health.sqlite_integrity'=>'Intégrité SQLite','health.sqlite_ok'=>'La base répond « ok ».','health.foreign_keys'=>'Relations entre les données','health.no_fk_error'=>'Aucune relation invalide.','health.fk_errors'=>':count relation(s) invalide(s).','health.migrations'=>'Migrations de la base','health.migrations_ok'=>':count migrations appliquées, aucune en attente.','health.migrations_pending'=>':count migration(s) en attente.','health.database_file'=>'Fichier de base de données','health.file_missing'=>'Fichier absent.','health.disk_space'=>'Espace disque disponible','health.unknown'=>'Information indisponible','health.last_backup'=>'Dernière sauvegarde','health.no_backup'=>'Aucune sauvegarde Globinours disponible.','health.backup_age'=>'Dernière sauvegarde le :date, il y a :days jour(s).','health.missing_photos'=>'Photos référencées mais absentes','health.orphan_photos'=>'Photos sans référence en base','health.file_count'=>':count fichier(s).','health.display_errors'=>'Affichage des erreurs PHP','health.production_value'=>'Mode production · display_errors = :value','health.development_mode'=>'Mode développement actif.','health.error_log'=>'Journalisation PHP','health.system_log'=>'Journal système configuré par l’hébergeur.','health.logging_disabled'=>'La journalisation des erreurs est désactivée.','health.versions'=>'PHP :php · SQLite :sqlite','health.generated'=>'Diagnostic généré le :date','health.read_only'=>'Ce diagnostic ne modifie aucune donnée.', + 'settings.notifications'=>'Notifications','notification.page_title'=>'Paramètres — Notifications','notification.title'=>'Notifications et résumés','notification.intro'=>'Les alertes restent visibles dans Globinours et peuvent être regroupées dans un e-mail quotidien ou hebdomadaire.','notification.saved'=>'Les notifications ont été configurées.','notification.sent'=>'Le résumé a été envoyé.','notification.mail_failed'=>'Le serveur n’a pas pu remettre l’e-mail. Vérifiez sa configuration de messagerie.','notification.invalid_recipient'=>'Indiquez une adresse e-mail valide.','notification.email_digest'=>'Résumé automatique par e-mail','notification.enable_email'=>'Activer l’envoi automatique','notification.recipient'=>'Destinataire','notification.recipient_help'=>'À défaut, l’adresse générale de l’association sera utilisée.','notification.frequency'=>'Fréquence','notification.daily'=>'Chaque jour','notification.weekly'=>'Chaque semaine','notification.current_alerts'=>'Alertes actuelles','notification.no_alert'=>'Aucune alerte actuellement.','notification.send_test'=>'Envoyer maintenant','notification.cron_help'=>'Pour automatiser l’envoi, exécutez régulièrement :','notification.dashboard_title'=>'Points à surveiller','notification.configure'=>'Configurer','notification.vaccines_overdue'=>'Vaccins en retard','notification.treatments_overdue'=>'Traitements à clôturer','notification.dewormings_due'=>'Vermifuges échus ou sous 7 jours','notification.backup_old'=>'Sauvegarde trop ancienne','notification.email_subject'=>'Résumé des alertes','notification.email_intro'=>'Voici le résumé automatique de :association.','notification.email_footer'=>'Message généré automatiquement par Globinours.', + 'health.recent_errors'=>'Erreurs PHP récentes','health.recent_error_count'=>':count ligne(s) d’erreur sur les sept derniers jours.','health.no_recent_error'=>'Aucune erreur journalisée sur les sept derniers jours.', + 'directory.not_found'=>'Contact introuvable.','directory.adopted_animals'=>'Animaux adoptés','directory.back'=>'Retour à l’annuaire','directory.no_contact_details'=>'Aucune coordonnée renseignée.','directory.total_invoiced'=>'Total payé ou facturé','directory.expense_count'=>':count dépense(s) enregistrée(s)','directory.fostered_animals'=>'Animaux accueillis','directory.deposited_animals'=>'Animaux déposés','directory.linked_people'=>'Personnes rattachées','directory.expense_history'=>'Historique des dépenses','directory.back_statistics'=>'Retour aux statistiques','animal.animal'=>'Animal','common.label'=>'Intitulé','common.amount'=>'Montant','common.total'=>'Total', + 'directory.open_contact'=>'Ouvrir la fiche de :name','animal.archive_edit_help'=>'Mode correction d’archive : vous pouvez corriger l’identité et les informations du dossier. Le statut, l’emplacement et l’archivage seront conservés.', + 'settings.inventory'=>'Pharmacie et stocks','inventory.title'=>'Pharmacie et stocks','inventory.intro'=>'Suivez séparément médicaments, vermifuges et vaccins, leurs lots, péremptions, coûts et mouvements.','inventory.saved'=>'Le mouvement de stock a été enregistré.','inventory.alerts'=>'Points à surveiller','inventory.low_stock'=>'stock(s) faible(s)','inventory.expiring'=>'lot(s) expiré(s) ou sous 30 jours','inventory.add_product'=>'Ajouter un produit','inventory.category'=>'Catégorie','inventory.medications'=>'Médicaments','inventory.dewormers'=>'Vermifuges','inventory.vaccines'=>'Vaccins','inventory.medication'=>'Médicament','inventory.dewormer'=>'Vermifuge','inventory.vaccine'=>'Vaccin','inventory.unit'=>'Unité de stock','inventory.minimum'=>'Seuil minimum','inventory.receive'=>'Réceptionner un lot','inventory.product'=>'Produit','inventory.quantity'=>'Quantité','inventory.unit_cost'=>'Prix unitaire (€)','inventory.expiry'=>'Péremption','inventory.supplier'=>'Fournisseur','inventory.remove_stock'=>'Enregistrer une sortie','inventory.batch'=>'Lot disponible','inventory.no_batch'=>'sans numéro','inventory.movement'=>'Mouvement','inventory.administration'=>'Administration','inventory.loss'=>'Perte / casse','inventory.correction'=>'Correction','inventory.entry'=>'Entrée','inventory.record'=>'Enregistrer','inventory.stock'=>'Stock disponible','inventory.next_expiry'=>'Prochaine péremption','inventory.empty'=>'Aucun produit en stock.','inventory.history'=>'Historique des mouvements','inventory.invalid_product'=>'Le produit, sa catégorie et son unité sont obligatoires.','inventory.invalid_quantity'=>'La quantité doit être supérieure à zéro.','inventory.invalid_date'=>'La date de péremption est invalide.','inventory.invalid_movement'=>'Le mouvement de stock est invalide.','inventory.batch_not_found'=>'Lot introuvable.','inventory.insufficient_stock'=>'Le stock du lot est insuffisant.','inventory.receipt'=>'Réception du lot', + 'backups.automatic'=>'Sauvegardes automatiques','backups.enable_schedule'=>'Activer la planification','backups.schedule_help'=>'Le serveur doit exécuter régulièrement la commande indiquée. Une archive n’est validée qu’après contrôle de ses empreintes et de la base SQLite.','backups.last_verified'=>'Dernière vérification réussie','backups.verify'=>'Vérifier','backups.verified'=>'L’archive, son manifeste et sa base SQLite sont valides.','backups.schedule_saved'=>'La planification des sauvegardes a été enregistrée.', + 'privacy.other_contacts'=>'Autres contacts','privacy.adopters'=>'Adoptants','privacy.fosters'=>'Familles d’accueil','privacy.volunteers'=>'Bénévoles','privacy.years'=>'ans','privacy.months'=>'mois','privacy.access_retention'=>'Journal des accès privés','privacy.applied_policy'=>'Règle appliquée','privacy.active_link'=>'Dossier encore actif','privacy.reviewed'=>'Export et liens vérifiés','privacy.review_required'=>'Confirmez avoir vérifié l’export et les liens avant anonymisation.', + 'notification.inventory_low'=>'Produits sous le seuil de stock','notification.inventory_expiry'=>'Lots expirés ou sous 30 jours', + 'nav.agenda'=>'Agenda','permission.module.agenda'=>'Agenda','permission.module_help.agenda'=>'Rendez-vous, transports, visites et tâches partagées', + 'agenda.title'=>'Agenda partagé','agenda.intro'=>'Rendez-vous, transports, visites et tâches à retrouver par toute l’équipe.','agenda.add'=>'Ajouter un événement','agenda.edit'=>'Modifier l’événement','agenda.saved'=>'L’agenda a été mis à jour.','agenda.invalid'=>'Les informations de l’événement sont incomplètes.','agenda.invalid_time'=>'Indiquez une heure valide ou choisissez toute la journée.','agenda.invalid_end'=>'La date ou l’heure de fin est invalide.','agenda.end_before_start'=>'La fin ne peut pas précéder le début.','agenda.invalid_repeat'=>'La date de fin de répétition est invalide.','agenda.upcoming'=>'Les 30 prochains jours','agenda.none'=>'Aucun événement prévu.','agenda.event'=>'Événement','agenda.type'=>'Type','agenda.type_appointment'=>'Rendez-vous','agenda.type_veterinary'=>'Rendez-vous vétérinaire','agenda.type_transport'=>'Transport','agenda.type_pre_adoption'=>'Visite pré-adoption','agenda.type_task'=>'Tâche','agenda.type_other'=>'Autre','agenda.time'=>'Heure','agenda.end_date'=>'Date de fin','agenda.end_time'=>'Heure de fin','agenda.all_day'=>'Toute la journée','agenda.priority'=>'Priorité','agenda.priority_low'=>'Basse','agenda.priority_normal'=>'Normale','agenda.priority_high'=>'Haute','agenda.priority_urgent'=>'Urgente','agenda.assigned'=>'Responsable','agenda.reminder'=>'Rappel','agenda.reminder_0'=>'À l’heure prévue','agenda.reminder_30'=>'30 minutes avant','agenda.reminder_60'=>'1 heure avant','agenda.reminder_1440'=>'1 jour avant','agenda.reminder_2880'=>'2 jours avant','agenda.reminder_10080'=>'1 semaine avant','agenda.contact'=>'Contact lié','agenda.location'=>'Lieu','agenda.repeat'=>'Répétition','agenda.repeat_none'=>'Aucune','agenda.repeat_daily'=>'Chaque jour','agenda.repeat_weekly'=>'Chaque semaine','agenda.repeat_monthly'=>'Chaque mois','agenda.repeat_until'=>'Jusqu’au','agenda.status'=>'État','agenda.status_planned'=>'Prévu','agenda.status_completed'=>'Terminé','agenda.status_cancelled'=>'Annulé','agenda.mon'=>'Lun','agenda.tue'=>'Mar','agenda.wed'=>'Mer','agenda.thu'=>'Jeu','agenda.fri'=>'Ven','agenda.sat'=>'Sam','agenda.sun'=>'Dim', + 'dashboard.agenda_due'=>'échéances agenda','notification.agenda_due'=>'Événements ou tâches à traiter sous 24 heures','agenda.legend'=>'Légende des couleurs','agenda.legend_organization'=>'Organisation','agenda.legend_medical'=>'Suivi médical','agenda.vaccine_reminder'=>'Rappel vaccin : :vaccine','agenda.type_vaccine'=>'Rappel vaccinal','agenda.open_animal'=>'Ouvrir la fiche','agenda.deworming'=>'Vermifuge','agenda.deworming_reminder'=>'Vermifuge : :product','agenda.type_deworming'=>'Prochain vermifuge','agenda.treatment_start'=>'Début du traitement : :medication','agenda.type_treatment_start'=>'Début de traitement','agenda.treatment_end'=>'Fin du traitement : :medication','agenda.type_treatment_end'=>'Fin de traitement', + 'agenda.view'=>'Mode d’affichage','agenda.month_view'=>'Mois','agenda.week_view'=>'Semaine','agenda.today'=>'Aujourd’hui','agenda.week_range'=>'Du :start au :end','agenda.export_ics'=>'Exporter en .ics','agenda.subscription'=>'Abonnement à l’agenda','agenda.subscription_help'=>'Ajoutez ce lien privé à Google Agenda, Outlook, Apple Calendrier ou Thunderbird pour synchroniser automatiquement les événements. Toute personne possédant ce lien peut lire l’agenda : ne le partagez pas publiquement.','agenda.feed_active'=>'Actif','agenda.feed_inactive'=>'Inactif','agenda.copy_now'=>'Copiez ce lien maintenant : il ne sera plus affiché après avoir quitté la page.','agenda.feed_hidden'=>'Le lien privé est actif et masqué. Régénérez-le uniquement si vous devez obtenir une nouvelle adresse.','agenda.create_feed'=>'Créer mon lien privé','agenda.regenerate_feed'=>'Régénérer le lien','agenda.revoke_feed'=>'Révoquer le lien','agenda.revoke_confirm'=>'Révoquer immédiatement cet abonnement ? Les agendas déjà configurés ne pourront plus se synchroniser.','agenda.feed_invalid'=>'Abonnement introuvable ou révoqué.','common.copy'=>'Copier', + 'agenda.scope'=>'Agenda affiché','agenda.scope_all'=>'Toute l’équipe','agenda.scope_mine'=>'Mon agenda','agenda.volunteer'=>'Bénévole','agenda.volunteers'=>'Bénévoles mobilisés','agenda.volunteers_help'=>'Maintenez Ctrl ou Cmd pour sélectionner plusieurs bénévoles.','agenda.show_medical'=>'Afficher les échéances médicales automatiques','agenda.search_placeholder'=>'Mission, animal, lieu ou personne…','agenda.conflict_saved'=>'Événement enregistré, mais cette personne a déjà une autre mission sur ce créneau.','agenda.add_on_date'=>'Ajouter un événement le','common.all'=>'Tous', + 'agenda.type_'=>'Type','agenda.status_'=>'État','agenda.priority_'=>'Priorité','agenda.reminder_'=>'Rappel','agenda.repeat_'=>'Répétition','agenda.legend_'=>'Légende','common.description'=>'Description','common.active'=>'Actif', + 'settings.rooms'=>'Salles et box','rooms.title'=>'Configuration des salles et box','rooms.saved'=>'Configuration des locaux enregistrée.','rooms.help'=>'Les salles existantes conservent leur code interne : vos affectations et votre historique ne sont pas modifiés. Désactivez une salle inutilisée plutôt que de supprimer son histoire.','rooms.invalid'=>'Configuration de salle invalide.','rooms.color'=>'Couleur','rooms.type'=>'Organisation','rooms.collective'=>'Salle collective','rooms.with_boxes'=>'Salle avec box','rooms.order'=>'Ordre','rooms.boxes'=>'Box','rooms.one_box_per_line'=>'Un nom de box par ligne','rooms.boxes_help'=>'Le nombre de lignes détermine le nombre de box. Retirer une ligne désactive le box sans effacer l’historique.','rooms.bulk_validation'=>'Autoriser « Tout le monde va bien »','rooms.add'=>'Ajouter une salle', + 'surgery.invalid_data'=>'Les données de chirurgie sont invalides.', + 'accounting.required_fields'=>'La référence, la date et le montant TTC sont obligatoires.', + 'accounting.document_invalid'=>'Le document est invalide ou dépasse 20 Mo.', + 'accounting.document_format'=>'Formats autorisés : PDF, JPEG, PNG et WebP.', + 'accounting.document_save_failed'=>'Impossible d’enregistrer le document.', + 'search.title'=>'Recherche globale','search.placeholder'=>'Animal, contact, rendez-vous ou facture…','search.action'=>'Rechercher','search.hint'=>'Saisissez au moins deux caractères.','search.none'=>'Aucun résultat accessible.','search.open'=>'Ouvrir la recherche globale','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animaux','search.group.directory'=>'Annuaire','search.group.agenda'=>'Agenda','search.group.accounting'=>'Comptabilité','search.loading'=>'Recherche…','search.all_results'=>'Afficher tous les résultats','search.error'=>'La recherche est momentanément indisponible.', +]; diff --git a/scripts/asm3-analyze.php b/scripts/asm3-analyze.php new file mode 100644 index 0000000..d0b6b0b --- /dev/null +++ b/scripts/asm3-analyze.php @@ -0,0 +1,90 @@ +#!/usr/bin/env php + [--json] [--output rapport.md]\n"); + exit(2); +} +try { + $report = Asm3Analyzer::analyze($source); +} catch (Throwable $e) { + fwrite(STDERR, "Erreur : {$e->getMessage()}\n"); + exit(1); +} +if ($json) { + $content = json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; +} else { + $a = $report['animals']; + $content = "# Rapport de migration ASM3 → Globinours\n\n"; + $content .= + "Analyse en lecture seule de `{$report['source']}` (" . + number_format($report['size_bytes'] / 1048576, 2, ',', ' ') . + " Mio). Aucune donnée Globinours n’a été modifiée.\n\n"; + $content .= "## Synthèse animale\n\n| Indicateur | Nombre |\n|---|---:|\n"; + $content .= '| Total des animaux | ' . ($a['active'] + $a['archived']) . " |\n"; + foreach ( + [ + 'Dossiers actifs dans ASM3' => 'active', + 'Dossiers archivés dans ASM3' => 'archived', + 'Déclarés adoptables' => 'adoptable', + 'Non adoptables' => 'not_adoptable', + 'Décédés' => 'deceased', + 'Pucés' => 'microchip', + 'Tatoués' => 'tattoo', + 'Stérilisés' => 'neutered', + 'Sans nom' => 'missing_name', + 'Sans naissance' => 'missing_birth_date', + ] + as $label => $key + ) { + $content .= "| {$label} | {$a[$key]} |\n"; + } + $content .= + "\n## Périmètre de migration\n\n| Domaine ASM3 | Table | Lignes | Cible Globinours | État |\n|---|---|---:|---|---|\n"; + foreach ($report['domains'] as $d) { + $state = + ['ready' => 'Prêt à mapper', 'planned' => 'À implémenter', 'review' => 'À étudier'][$d['status']] ?? + $d['status']; + $content .= "| {$d['label']} | `{$d['table']}` | {$d['rows']} | `{$d['target']}` | {$state} |\n"; + } + $content .= + "\n## Alertes\n\n" . + ($report['warnings'] + ? implode("\n", array_map(static fn($w) => '- ' . $w, $report['warnings'])) + : '- Aucune anomalie bloquante détectée.') . + "\n"; + $content .= "\n## Tables non encore mappées\n\n"; + foreach (array_slice($report['unmapped_tables'], 0, 30, true) as $table => $count) { + $content .= "- `{$table}` : {$count} ligne(s)\n"; + } +} +if ($output) { + if (file_put_contents($output, $content) === false) { + fwrite(STDERR, "Impossible d’écrire {$output}\n"); + exit(1); + } + fwrite(STDOUT, "Rapport écrit dans {$output}\n"); +} else { + fwrite(STDOUT, $content); +} diff --git a/scripts/asm3-clinical.php b/scripts/asm3-clinical.php new file mode 100644 index 0000000..aab0389 --- /dev/null +++ b/scripts/asm3-clinical.php @@ -0,0 +1,21 @@ +#!/usr/bin/env php + [--apply]\n"); + exit(2); +} +if ($apply) { + fwrite(STDERR, 'IMPORT CONTACTS demandé. Tapez IMPORTER CONTACTS : '); + if (trim((string) fgets(STDIN)) !== 'IMPORTER CONTACTS') { + fwrite(STDERR, "Import annulé.\n"); + exit(3); + } +} +try { + Migrations::run($root . '/migrations'); + echo json_encode(Asm3ContactImporter::run($source, DB::pdo(), $apply), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), + "\n"; +} catch (Throwable $e) { + fwrite(STDERR, "Échec : {$e->getMessage()}\n"); + exit(1); +} diff --git a/scripts/asm3-import.php b/scripts/asm3-import.php new file mode 100644 index 0000000..d9f6a1f --- /dev/null +++ b/scripts/asm3-import.php @@ -0,0 +1,46 @@ +#!/usr/bin/env php + [--apply]\nSans --apply, aucune donnée métier n’est écrite.\n", + ); + exit(2); +} +if ($apply) { + fwrite(STDERR, 'IMPORT RÉEL demandé. Tapez exactement IMPORTER ASM3 pour continuer : '); + if (trim((string) fgets(STDIN)) !== 'IMPORTER ASM3') { + fwrite(STDERR, "Import annulé.\n"); + exit(3); + } +} +try { + Migrations::run($root . '/migrations'); + $result = Asm3AnimalImporter::run($source, DB::pdo(), $apply); + echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n"; +} catch (Throwable $e) { + fwrite(STDERR, "Échec : {$e->getMessage()}\n"); + exit(1); +} diff --git a/scripts/asm3-litters.php b/scripts/asm3-litters.php new file mode 100644 index 0000000..0c3bebd --- /dev/null +++ b/scripts/asm3-litters.php @@ -0,0 +1,20 @@ +#!/usr/bin/env php + [--apply]\n"); + exit(2); +} +if ($apply) { + fwrite(STDERR, 'IMPORT MOUVEMENTS demandé. Tapez IMPORTER MOUVEMENTS : '); + if (trim((string) fgets(STDIN)) !== 'IMPORTER MOUVEMENTS') { + fwrite(STDERR, "Import annulé.\n"); + exit(3); + } +} +try { + Migrations::run($root . '/migrations'); + echo json_encode(Asm3MovementImporter::run($source, DB::pdo(), $apply), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), + "\n"; +} catch (Throwable $e) { + fwrite(STDERR, "Échec : {$e->getMessage()}\n"); + exit(1); +} diff --git a/scripts/asm3-plan.php b/scripts/asm3-plan.php new file mode 100644 index 0000000..89a0749 --- /dev/null +++ b/scripts/asm3-plan.php @@ -0,0 +1,28 @@ +#!/usr/bin/env php + [rapport.json]\n"); + exit(2); +} +try { + $plan = Asm3MigrationPlanner::plan($source, DB::pdo()); + $json = json_encode($plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; +} catch (Throwable $e) { + fwrite(STDERR, "Erreur : {$e->getMessage()}\n"); + exit(1); +} +if ($output) { + if (file_put_contents($output, $json) === false) { + fwrite(STDERR, "Impossible d’écrire le rapport.\n"); + exit(1); + } + echo "Plan écrit dans {$output}\n"; +} else { + echo $json; +} diff --git a/scripts/backup.php b/scripts/backup.php new file mode 100644 index 0000000..59a987f --- /dev/null +++ b/scripts/backup.php @@ -0,0 +1,18 @@ +getExtension() !== 'php') { + continue; + } + $source = file_get_contents($file->getPathname()); + if ($source === false) { + continue; + } + preg_match_all("/\\bt(?:n)?\\(\\s*['\"]([^'\"]+)['\"]/", $source, $matches); + foreach ($matches[1] as $key) { + if (!str_ends_with($key, '.')) { + $used[$key] = true; + } + } +} +$missingFr = array_diff_key($used, $fr); +$missingEn = array_diff_key($used, $en); +$orphanFr = array_diff_key($fr, $en); +$orphanEn = array_diff_key($en, $fr); +foreach ( + [ + 'fr manquantes' => $missingFr, + 'en manquantes' => $missingEn, + 'uniquement fr' => $orphanFr, + 'uniquement en' => $orphanEn, + ] + as $label => $keys +) { + if ($keys) { + fwrite(STDERR, $label . ' : ' . implode(', ', array_keys($keys)) . PHP_EOL); + } +} +$placeholderErrors = []; +foreach (array_intersect_key($fr, $en) as $key => $frText) { + preg_match_all('/:([a-zA-Z][a-zA-Z0-9_]*)/', (string) $frText, $frMatches); + preg_match_all('/:([a-zA-Z][a-zA-Z0-9_]*)/', (string) $en[$key], $enMatches); + $frVars = array_values(array_unique($frMatches[1])); + sort($frVars); + $enVars = array_values(array_unique($enMatches[1])); + sort($enVars); + if ($frVars !== $enVars) { + $placeholderErrors[] = $key . ' (FR: ' . implode(',', $frVars) . ' · EN: ' . implode(',', $enVars) . ')'; + } +} +if ($placeholderErrors) { + fwrite(STDERR, 'variables incohérentes : ' . implode('; ', $placeholderErrors) . PHP_EOL); +} +$confirmationKeys = [ + 'data.confirm_reset_phrase', + 'data.confirm_demo_phrase', + 'asm3.confirm_import_phrase', + 'asm3.confirm_replace_phrase', +]; +$confirmationErrors = []; +foreach ($confirmationKeys as $key) { + if (trim((string) ($fr[$key] ?? '')) === '' || trim((string) ($en[$key] ?? '')) === '') { + $confirmationErrors[] = $key . ' vide'; + } elseif ($fr[$key] === $en[$key]) { + $confirmationErrors[] = $key . ' identique en FR et EN'; + } +} +if ($confirmationErrors) { + fwrite(STDERR, 'confirmations incohérentes : ' . implode('; ', $confirmationErrors) . PHP_EOL); +} +$rawOutput = []; +foreach (['/app/Controllers', '/app/Services'] as $relative) { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root . $relative, FilesystemIterator::SKIP_DOTS), + ); + foreach ($files as $file) { + if ( + $file->getExtension() !== 'php' || + in_array($file->getFilename(), ['DevController.php', 'Migrations.php'], true) + ) { + continue; + } + $source = file_get_contents($file->getPathname()) ?: ''; + if ( + preg_match( + '/(?:\becho\s*|throw\s+new\s+(?:RuntimeException|InvalidArgumentException)\s*\()\s*[\'\"][A-Za-zÀ-ÿ]/u', + $source, + ) + ) { + $rawOutput[] = str_replace($root . '/', '', $file->getPathname()); + } + } +} +if ($rawOutput) { + fwrite(STDERR, 'sorties utilisateur codées en dur : ' . implode(', ', array_unique($rawOutput)) . PHP_EOL); +} +if ($missingFr || $missingEn || $orphanFr || $orphanEn || $placeholderErrors || $confirmationErrors || $rawOutput) { + exit(1); +} +echo count($used) . + ' clés utilisées · ' . + count($fr) . + ' traductions FR/EN cohérentes · variables et confirmations vérifiées' . + PHP_EOL; diff --git a/scripts/import-asm3-photos.php b/scripts/import-asm3-photos.php new file mode 100644 index 0000000..f16ddac --- /dev/null +++ b/scripts/import-asm3-photos.php @@ -0,0 +1,165 @@ +#!/usr/bin/env php + !str_starts_with((string) $arg, '--')), +); +$dump = $positional[0] ?? ''; +$sourceDir = $positional[1] ?? ''; +if ($dump === '' || $sourceDir === '') { + fwrite(STDERR, "Usage : php scripts/import-asm3-photos.php [--apply]\n"); + exit(2); +} +if (!is_file($dump) || !is_dir($sourceDir)) { + throw new RuntimeException('Dump ASM3 ou dossier des photos introuvable.'); +} +$hash = hash_file('sha256', $dump); +$source = Asm3Analyzer::rows($dump, ['media', 'dbfs']); +$db = DB::pdo(); +$links = []; +$q = $db->prepare("SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=? AND entity_type='animal'"); +$q->execute([$hash]); +foreach ($q as $row) { + $links[(int) $row['source_id']] = (int) $row['target_id']; +} +$dbfs = []; +foreach ($source['dbfs'] ?? [] as $row) { + $dbfs[(int) $row['ID']] = $row; +} +$items = []; +$errors = []; +foreach ($source['media'] ?? [] as $media) { + if ((int) $media['LINKTYPEID'] !== 0 || !str_starts_with((string) $media['MEDIAMIMETYPE'], 'image/')) { + continue; + } + $sourceAnimal = (int) $media['LINKID']; + $targetAnimal = $links[$sourceAnimal] ?? 0; + $physical = $dbfs[(int) $media['DBFSID']] ?? null; + $filename = $physical ? basename(str_replace('file:', '', (string) $physical['URL'])) : ''; + $path = $sourceDir . '/' . $filename; + if (!$targetAnimal) { + $errors[] = 'Animal ASM3 non relié : ' . $sourceAnimal; + } elseif ($filename === '' || !is_file($path)) { + $errors[] = 'Fichier absent pour le média ASM3 ' . $media['ID']; + } else { + $inspection = ImageService::inspect($path); + $items[] = [ + 'media' => $media, + 'animal' => $targetAnimal, + 'path' => $path, + 'physical' => $filename, + 'inspection' => $inspection, + ]; + } +} +if ($errors) { + throw new RuntimeException(implode("\n", $errors)); +} +$targets = array_values(array_unique(array_column($items, 'animal'))); +$primaryCount = count(array_filter($items, static fn(array $i): bool => (int) $i['media']['WEBSITEPHOTO'] === 1)); +$summary = [ + 'mode' => $apply ? 'APPLIQUE' : 'SIMULATION', + 'photos_found' => count($items), + 'animals' => count($targets), + 'primary_photos' => $primaryCount, + 'public_photos' => count($items), + 'already_imported' => 0, + 'created' => 0, + 'optimized' => 0, + 'backup' => null, +]; +foreach ($items as $item) { + $original = 'ASM3-' . (int) $item['media']['ID'] . '-' . (string) $item['media']['MEDIANAME']; + $s = $db->prepare('SELECT 1 FROM animal_photos WHERE animal_id=? AND original_name=?'); + $s->execute([$item['animal'], $original]); + if ($s->fetchColumn()) { + $summary['already_imported']++; + } +} +if (!$apply) { + echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n"; + exit(); +} +$backup = BackupService::create('avant-import-photos-asm3'); +$summary['backup'] = $backup['name'] ?? null; +$written = []; +$db->beginTransaction(); +try { + $db->prepare( + "INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(?,?,'completed','{}',?)", + )->execute([basename($dump) . ' · photos', $hash, $summary['backup']]); + $run = (int) $db->lastInsertId(); + $reset = $db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=?'); + foreach ($targets as $animalId) { + $reset->execute([$animalId]); + } + $insert = $db->prepare( + 'INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption,created_at) VALUES(?,?,?,?,?,?,1,?,COALESCE(?,datetime(\'now\')))', + ); + $link = $db->prepare( + "INSERT OR IGNORE INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(?,'media',?,?,?)", + ); + foreach ($items as $item) { + $media = $item['media']; + $original = 'ASM3-' . (int) $media['ID'] . '-' . (string) $media['MEDIANAME']; + $existing = $db->prepare('SELECT id FROM animal_photos WHERE animal_id=? AND original_name=?'); + $existing->execute([$item['animal'], $original]); + $photoId = (int) $existing->fetchColumn(); + if (!$photoId) { + $stored = ImageService::storeLocal( + $item['path'], + $root . '/public/media/animals/' . $item['animal'], + 'asm3_' . (int) $media['ID'], + 'photo', + ); + $written[] = $stored['path']; + $caption = trim((string) ($media['MEDIANOTES'] ?? '')); + $caption = $caption !== '' ? 'Archive ASM3 · ' . $caption : 'Archive ASM3'; + $insert->execute([ + $item['animal'], + $stored['filename'], + $original, + $stored['mime'], + $stored['size'], + (int) $media['WEBSITEPHOTO'] === 1 ? 1 : 0, + $caption, + substr((string) $media['CREATEDDATE'], 0, 19) ?: null, + ]); + $photoId = (int) $db->lastInsertId(); + $summary['created']++; + if ($stored['optimized']) { + $summary['optimized']++; + } + } elseif ((int) $media['WEBSITEPHOTO'] === 1) { + $db->prepare('UPDATE animal_photos SET is_primary=1,is_public=1 WHERE id=?')->execute([$photoId]); + } else { + $db->prepare('UPDATE animal_photos SET is_public=1 WHERE id=?')->execute([$photoId]); + } + $link->execute([$hash, (int) $media['ID'], $photoId, $run]); + } + $db->prepare('UPDATE asm3_import_runs SET summary_json=? WHERE id=?')->execute([ + json_encode($summary, JSON_UNESCAPED_UNICODE), + $run, + ]); + if ($db->query('PRAGMA foreign_key_check')->fetchAll()) { + throw new RuntimeException('Violation de clé étrangère après import.'); + } + $db->commit(); +} catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + foreach ($written as $path) { + @unlink($path); + } + throw $e; +} +echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n"; diff --git a/scripts/maintenance.php b/scripts/maintenance.php new file mode 100644 index 0000000..84db3c9 --- /dev/null +++ b/scripts/maintenance.php @@ -0,0 +1,47 @@ + true, 'at' => date(DATE_ATOM), 'results' => $results], + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE, +), + PHP_EOL; +} catch (Throwable $e) { + fwrite(STDERR, json_encode(['ok' => false, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE) . PHP_EOL); + exit(1); +} diff --git a/scripts/merge-duplicate-contacts.php b/scripts/merge-duplicate-contacts.php new file mode 100644 index 0000000..2a15396 --- /dev/null +++ b/scripts/merge-duplicate-contacts.php @@ -0,0 +1,173 @@ +#!/usr/bin/env php +query("SELECT * FROM directory_contacts WHERE deleted_at IS NULL AND kind='person'") + ->fetchAll(PDO::FETCH_ASSOC); +$byKey = []; +foreach ($allContacts as $contact) { + $byKey[contactKey($contact['name'])][] = $contact; +} +$groups = array_filter($byKey, static fn(array $rows): bool => count($rows) > 1); +$singletons = array_values(array_filter($byKey, static fn(array $rows): bool => count($rows) === 1)); +$used = []; +for ($i = 0; $i < count($singletons); $i++) { + for ($j = $i + 1; $j < count($singletons); $j++) { + $a = $singletons[$i][0]; + $b = $singletons[$j][0]; + if (isset($used[$a['id']]) || isset($used[$b['id']])) { + continue; + } + $ka = contactKey($a['name']); + $kb = contactKey($b['name']); + if (min(strlen($ka), strlen($kb)) >= 8 && levenshtein($ka, $kb) <= 1) { + $groups['fuzzy:' . $a['id'] . ':' . $b['id']] = [$a, $b]; + $used[$a['id']] = $used[$b['id']] = true; + } + } +} +$foreignKeys = []; +foreach ( + $db + ->query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + ->fetchAll(PDO::FETCH_COLUMN) + as $table +) { + foreach ( + $db->query('PRAGMA foreign_key_list("' . str_replace('"', '""', $table) . '")')->fetchAll(PDO::FETCH_ASSOC) + as $fk + ) { + if ($fk['table'] === 'directory_contacts' && $table !== 'directory_contact_roles') { + $foreignKeys[] = [$table, $fk['from']]; + } + } +} +$summary = ['groups' => count($groups), 'contacts_merged' => 0, 'relations_moved' => 0]; +$details = []; +$backup = null; +if ($apply) { + $backup = BackupService::create('avant-fusion-annuaire'); +} +$db->beginTransaction(); +try { + foreach ($groups as $key => $contacts) { + usort($contacts, static function (array $a, array $b): int { + $byDetails = filledScore($b) <=> filledScore($a); + return $byDetails !== 0 ? $byDetails : (int) $a['id'] <=> (int) $b['id']; + }); + $canonical = array_shift($contacts); + $canonicalId = (int) $canonical['id']; + $mergedNames = [$canonical['name']]; + foreach ($contacts as $duplicate) { + $duplicateId = (int) $duplicate['id']; + $mergedNames[] = $duplicate['name']; + foreach ($foreignKeys as [$table, $column]) { + $countStmt = $db->prepare("SELECT COUNT(*) FROM \"$table\" WHERE \"$column\"=?"); + $countStmt->execute([$duplicateId]); + $count = (int) $countStmt->fetchColumn(); + if ($count) { + $db->prepare("UPDATE \"$table\" SET \"$column\"=? WHERE \"$column\"=?")->execute([ + $canonicalId, + $duplicateId, + ]); + $summary['relations_moved'] += $count; + } + } + $roles = $db->prepare('SELECT role FROM directory_contact_roles WHERE contact_id=?'); + $roles->execute([$duplicateId]); + foreach ($roles->fetchAll(PDO::FETCH_COLUMN) as $role) { + $db->prepare('INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(?,?)')->execute([ + $canonicalId, + $role, + ]); + } + $db->prepare('DELETE FROM directory_contact_roles WHERE contact_id=?')->execute([$duplicateId]); + foreach ( + ['phone', 'email', 'address', 'postal_code', 'city', 'country', 'organization_id', 'notes'] + as $field + ) { + if (empty($canonical[$field]) && !empty($duplicate[$field])) { + $canonical[$field] = $duplicate[$field]; + } + } + $db->prepare('DELETE FROM directory_contacts WHERE id=?')->execute([$duplicateId]); + $summary['contacts_merged']++; + } + $db->prepare( + 'UPDATE directory_contacts SET phone=?,email=?,address=?,postal_code=?,city=?,country=?,organization_id=?,notes=?,updated_at=datetime(\'now\') WHERE id=?', + )->execute([ + $canonical['phone'] ?: null, + $canonical['email'] ?: null, + $canonical['address'] ?: null, + $canonical['postal_code'] ?: null, + $canonical['city'] ?: null, + $canonical['country'] ?: 'France', + $canonical['organization_id'] ?: null, + $canonical['notes'] ?: null, + $canonicalId, + ]); + foreach (array_unique($mergedNames) as $oldName) { + $db->prepare( + 'UPDATE adoptions SET adopter_name=? WHERE adopter_contact_id=? AND lower(trim(adopter_name))=lower(trim(?))', + )->execute([$canonical['name'], $canonicalId, $oldName]); + $db->prepare( + 'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))', + )->execute([$canonical['name'], $oldName]); + } + $details[] = ['kept_id' => $canonicalId, 'kept_name' => $canonical['name'], 'merged_names' => $mergedNames]; + } + $fk = $db->query('PRAGMA foreign_key_check')->fetchAll(); + if ($fk) { + throw new RuntimeException('Violation de clé étrangère après fusion.'); + } + if ($apply) { + $db->commit(); + } else { + $db->rollBack(); + } + echo json_encode( + [ + 'mode' => $apply ? 'APPLIQUE' : 'SIMULATION', + 'backup' => $backup['name'] ?? null, + 'summary' => $summary, + 'groups' => $details, + ], + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, +), + "\n"; +} catch (Throwable $error) { + if ($db->inTransaction()) { + $db->rollBack(); + } + fwrite(STDERR, 'Fusion interrompue : ' . $error->getMessage() . "\n"); + exit(1); +} diff --git a/scripts/migrate.php b/scripts/migrate.php new file mode 100644 index 0000000..18d6535 --- /dev/null +++ b/scripts/migrate.php @@ -0,0 +1,18 @@ +query('PRAGMA integrity_check')->fetchColumn(); +if ($integrity !== 'ok') { + fwrite(STDERR, "SQLite integrity check failed: $integrity\n"); + exit(2); +} +fwrite(STDOUT, "Migrations applied; SQLite integrity: ok\n"); diff --git a/scripts/reconcile-animal-current-state.php b/scripts/reconcile-animal-current-state.php new file mode 100644 index 0000000..a74f8f9 --- /dev/null +++ b/scripts/reconcile-animal-current-state.php @@ -0,0 +1,106 @@ +#!/usr/bin/env php +query("SELECT id FROM users WHERE role='admin' AND active=1 ORDER BY id LIMIT 1")->fetchColumn() ?: + 0) ?: + null; +$latest = $db + ->query( + "WITH ranked AS (SELECT ap.*,ROW_NUMBER() OVER(PARTITION BY animal_id ORDER BY date(event_date) DESC,id DESC) rn FROM animal_placements ap) SELECT r.*,a.status current_status,a.archived_at current_archived,dc.name contact_name,dc.address contact_address,dc.postal_code contact_postal,dc.city contact_city,ad.adopter_address,ad.adopter_postal_code,ad.adopter_city FROM ranked r JOIN animals a ON a.id=r.animal_id LEFT JOIN animal_deaths d ON d.animal_id=a.id LEFT JOIN directory_contacts dc ON dc.id=r.contact_id LEFT JOIN adoptions ad ON ad.id=r.adoption_id WHERE r.rn=1 AND r.event_type='adoption' AND d.animal_id IS NULL AND a.deleted_at IS NULL ORDER BY r.event_date,r.animal_id", + ) + ->fetchAll(PDO::FETCH_ASSOC); +$summary = [ + 'adopted_states_checked' => count($latest), + 'statuses_fixed' => 0, + 'locations_fixed' => 0, + 'movements_created' => 0, + 'movements_updated' => 0, + 'history_created' => 0, +]; +if ($apply) { + $backup = BackupService::create('avant-reconciliation-etat-animaux'); +} +$db->beginTransaction(); +try { + foreach ($latest as $row) { + $animal = (int) $row['animal_id']; + $date = (string) $row['event_date']; + $contact = trim((string) ($row['contact_name'] ?? '')) ?: 'Adoptant non renseigné'; + $address = implode( + ' ', + array_filter([ + trim((string) ($row['contact_address'] ?? '' ?: $row['adopter_address'] ?? '')), + trim((string) ($row['contact_postal'] ?? '' ?: $row['adopter_postal_code'] ?? '')), + trim((string) ($row['contact_city'] ?? '' ?: $row['adopter_city'] ?? '')), + ]), + ); + if ($address === '') { + $address = 'Adopté par ' . $contact; + } + if ($row['current_status'] !== 'adopte' || empty($row['current_archived'])) { + $summary['statuses_fixed']++; + } + $current = $db->prepare('SELECT current_address FROM animals WHERE id=?'); + $current->execute([$animal]); + if (trim((string) $current->fetchColumn()) !== $address) { + $summary['locations_fixed']++; + } + $db->prepare( + "UPDATE animals SET status='adopte',is_archived=1,archived_at=?,refuge_room=NULL,care_box_key=NULL,quarantine_until=NULL,current_address=?,current_lat=NULL,current_lng=NULL,updated_at=datetime('now') WHERE id=?", + )->execute([$date . ' 12:00:00', $address, $animal]); + $movement = $db->prepare( + "SELECT id FROM animal_movements WHERE animal_id=? AND kind='exit' AND lower(COALESCE(lieu,'')) LIKE 'adopt%' ORDER BY created_at DESC,id DESC LIMIT 1", + ); + $movement->execute([$animal]); + $movementId = (int) $movement->fetchColumn(); + if ($movementId) { + $db->prepare( + "UPDATE animal_movements SET place=?,lieu='Adoption',contact_name=?,note=CASE WHEN note IS NULL OR note='' THEN 'Sortie synchronisée avec le parcours d’adoption.' ELSE note END,created_at=? WHERE id=?", + )->execute([$address, $contact, $date . ' 12:00:00', $movementId]); + $summary['movements_updated']++; + } else { + $db->prepare( + "INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,note,created_at) VALUES(?,'exit',?,'Adoption',?,'Sortie synchronisée avec le parcours d’adoption.',?)", + )->execute([$animal, $address, $contact, $date . ' 12:00:00']); + $summary['movements_created']++; + } + $history = $db->prepare( + "SELECT 1 FROM animal_history WHERE animal_id=? AND date(created_at)=date(?) AND (lower(label) LIKE '%adopt%' OR lower(type)='adoption') LIMIT 1", + ); + $history->execute([$animal, $date]); + if (!$history->fetchColumn()) { + $db->prepare( + "INSERT INTO animal_history(animal_id,type,label,details,user_id,created_at) VALUES(?,'status','Adoption de l’animal',?,?,?)", + )->execute([$animal, 'Adopté par ' . $contact . '.', $user, $date . ' 12:00:00']); + $summary['history_created']++; + } + } + $fk = $db->query('PRAGMA foreign_key_check')->fetchAll(); + if ($fk) { + throw new RuntimeException('Violation de clé étrangère après synchronisation.'); + } + if ($apply) { + $db->commit(); + } else { + $db->rollBack(); + } + echo json_encode( + ['mode' => $apply ? 'APPLIQUE' : 'SIMULATION', 'backup' => $backup['name'] ?? null, 'summary' => $summary], + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, +), + "\n"; +} catch (Throwable $error) { + if ($db->inTransaction()) { + $db->rollBack(); + } + fwrite(STDERR, 'Synchronisation interrompue : ' . $error->getMessage() . "\n"); + exit(1); +} diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 0000000..c834ed2 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +root_dir="$(cd "$(dirname "$0")/.." && pwd)" +snapshot="${1:-}" +if [[ -z "$snapshot" || ! -f "$snapshot" || "$(basename "$snapshot")" != code-*.tar.gz ]]; then + echo "Usage: $0 storage/releases/code-VERSION-DATE.tar.gz" >&2 + exit 2 +fi +case "$(realpath "$snapshot")" in "$root_dir"/storage/releases/*) ;; *) echo "Snapshot hors de storage/releases refusé." >&2; exit 2;; esac +touch "$root_dir/storage/maintenance.flag" +trap 'rm -f "$root_dir/storage/maintenance.flag"' EXIT +tar -xzf "$snapshot" -C "$root_dir" +php "$root_dir/scripts/migrate.php" +rm -f "$root_dir/storage/maintenance.flag" +echo "Code restauré. La base n’a pas été rétrogradée automatiquement." diff --git a/scripts/send-notifications.php b/scripts/send-notifications.php new file mode 100644 index 0000000..61e8db4 --- /dev/null +++ b/scripts/send-notifications.php @@ -0,0 +1,20 @@ +&2 + exit 2 +fi +if [[ ! -f "$root_dir/public/index.php" || ! -f "$root_dir/VERSION" ]]; then + echo "Installation Globinours invalide: $root_dir" >&2 + exit 2 +fi + +stage_dir="$(mktemp -d /tmp/globinours-update-XXXXXX)" +trap 'rm -rf "$stage_dir"; rm -f "$root_dir/storage/maintenance.flag"' EXIT +if tar -tzf "$release_archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then + echo "Archive refusée: chemin dangereux." >&2 + exit 3 +fi +tar -xzf "$release_archive" -C "$stage_dir" +source_dir="$stage_dir" +if [[ ! -f "$source_dir/public/index.php" ]]; then + first_dir="$(find "$stage_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + source_dir="$first_dir" +fi +if [[ ! -f "$source_dir/public/index.php" || ! -f "$source_dir/VERSION" ]]; then + echo "Archive Globinours invalide." >&2 + exit 3 +fi + +mkdir -p "$root_dir/storage/releases" +current_version="$(tr -d '[:space:]' < "$root_dir/VERSION")" +new_version="$(tr -d '[:space:]' < "$source_dir/VERSION")" +rollback_archive="$root_dir/storage/releases/code-${current_version}-$(date +%Y%m%d-%H%M%S).tar.gz" +php "$root_dir/scripts/backup.php" pre-upgrade +tar --exclude='./data' --exclude='./storage' --exclude='./public/media' --exclude='./.env' -czf "$rollback_archive" -C "$root_dir" . +touch "$root_dir/storage/maintenance.flag" +rsync -a --delete --exclude=data --exclude=storage --exclude=public/media --exclude=.env "$source_dir/" "$root_dir/" +php "$root_dir/scripts/migrate.php" +rm -f "$root_dir/storage/maintenance.flag" +echo "Globinours $current_version -> $new_version" +echo "Retour arrière du code: $rollback_archive" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9a4a1ba --- /dev/null +++ b/tests/README.md @@ -0,0 +1,9 @@ +# Tests Globinours + +La suite légère fonctionne sans Composer et utilise exclusivement une base SQLite temporaire : + +```bash +php tests/run.php +``` + +Elle contrôle les migrations, l’intégrité SQLite, les paramètres, l’authentification, les permissions, un parcours médical et d’adoption, les agrégats de l’annuaire, la génération d’alertes et la structure des sauvegardes ZIP. La base réelle n’est jamais ouverte par le processus de test. diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..e4e96cb --- /dev/null +++ b/tests/run.php @@ -0,0 +1,466 @@ +getMessage()}\n"; + $failed++; + } +}; +$assert = static function (bool $condition, string $message = 'Assertion échouée'): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; +try { + Migrations::run($root . '/migrations'); + I18n::setLocale('fr'); + $db = DB::pdo(); + Auth::start(); + $test('Toutes les migrations sont appliquées', function () use ($db, $root, $assert): void { + $expected = count(glob($root . '/migrations/*.sql') ?: []); + $actual = (int) $db->query('SELECT COUNT(*) FROM _migrations')->fetchColumn(); + $assert($actual === $expected, "$actual/$expected migrations"); + }); + $test('Une installation vierge initialise les médias privés', function () use ($db, $assert): void { + $columns = array_column($db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC), 'name'); + $assert(in_array('is_public', $columns, true), 'Colonne is_public absente'); + $assert( + (int) $db + ->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='medical_photos'") + ->fetchColumn() === 1, + 'Table medical_photos absente', + ); + PrivateMediaService::migrateLegacy(); + }); + $test('La base SQLite temporaire est intègre', function () use ($db, $assert): void { + $assert($db->query('PRAGMA integrity_check')->fetchColumn() === 'ok'); + }); + $test('La configuration initiale des salles est complète', function () use ($assert): void { + $rooms = ShelterRoomService::byCode(false); + foreach (['silver', 'twix', 'care', 'quarantine'] as $code) { + $assert(isset($rooms[$code]), 'Salle absente : ' . $code); + } + $assert($rooms['silver']['name'] === 'Salle collective 1'); + $assert($rooms['twix']['name'] === 'Salle collective 2'); + $assert( + count($rooms['care']['boxes']) === 5 && count($rooms['quarantine']['boxes']) === 5, + 'Les cinq box initiaux ne sont pas disponibles', + ); + }); + $test('Les paramètres sont enregistrés et relus', function () use ($assert): void { + AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null); + $assert(AppSettings::get('association_name') === 'Refuge Test'); + $assert(AppSettings::get('app_language') === 'en'); + I18n::setLocale('fr'); + }); + $test('Les quatre bilans biologiques existent pour chats et chiens', function () use ($db, $assert): void { + $rows = $db + ->query( + 'SELECT analyzer_code,species_code,COUNT(*) total FROM lab_reference_ranges GROUP BY analyzer_code,species_code', + ) + ->fetchAll(PDO::FETCH_ASSOC); + $sets = []; + foreach ($rows as $row) { + $sets[$row['analyzer_code']][$row['species_code']] = (int) $row['total']; + } + foreach (['vetscan-hm5', 'renal', 'hepatic', 'thyroid'] as $code) { + foreach (['chat', 'chien'] as $species) { + $assert(($sets[$code][$species] ?? 0) > 0, "Référentiel $code/$species absent"); + } + } + $assert( + (string) $db + ->query("SELECT analyzer_name FROM lab_reference_ranges WHERE analyzer_code='vetscan-hm5' LIMIT 1") + ->fetchColumn() === 'Bilan complet', + ); + }); + $test('Authentification et mot de passe haché', function () use ($db, $assert): void { + $assert(ini_get('session.use_strict_mode') === '1', 'Le mode strict des sessions est désactivé'); + $hash = password_hash('MotDePasseSolide!', PASSWORD_DEFAULT); + $db->prepare( + "INSERT INTO users(username,password_hash,display_name,role,active) VALUES('admin-test',?,'Administrateur test','admin',1)", + )->execute([$hash]); + $assert(!Auth::login('admin-test', 'incorrect')); + $assert( + (int) $db->query('SELECT COUNT(*) FROM login_attempts WHERE succeeded=0')->fetchColumn() === 1, + 'Échec de connexion non enregistré', + ); + $assert(Auth::login('admin-test', 'MotDePasseSolide!')); + $assert(Auth::is('admin')); + }); + $test('Les permissions administrateur restent complètes', function () use ($assert): void { + $assert(PermissionService::can('animals', 'edit')); + $assert(PermissionService::can('medical', 'view')); + }); + $test('Les permissions et répétitions de l’agenda sont opérationnelles', function () use ($db, $assert): void { + $assert(PermissionService::can('agenda', 'edit')); + $id = AgendaService::save([ + 'item_type' => 'task', + 'title' => 'Tâche répétée de test', + 'date' => date('Y-m-01'), + 'time' => '09:00', + 'priority' => 'high', + 'repeat_rule' => 'weekly', + 'repeat_until' => date('Y-m-t'), + ]); + $month = AgendaService::month(new DateTimeImmutable('first day of this month')); + $occurrences = array_values(array_filter($month, static fn($row) => (int) $row['id'] === $id)); + $assert(count($occurrences) >= 4, 'La répétition hebdomadaire n’est pas développée'); + AgendaService::setStatus($id, 'completed'); + $assert((string) $db->query('SELECT status FROM agenda_items WHERE id=' . $id)->fetchColumn() === 'completed'); + }); + $test('La vue hebdomadaire respecte le lundi, le dimanche et la préférence utilisateur', function () use ( + $db, + $assert, + ): void { + $inside = AgendaService::save([ + 'item_type' => 'task', + 'title' => 'Dans la semaine', + 'date' => date('Y-m-d'), + 'time' => '10:00', + ]); + $outside = AgendaService::save([ + 'item_type' => 'task', + 'title' => 'Semaine suivante', + 'date' => new DateTimeImmutable('monday next week')->format('Y-m-d'), + 'time' => '10:00', + ]); + $ids = array_map('intval', array_column(AgendaService::week(new DateTimeImmutable('today')), 'id')); + $assert(in_array($inside, $ids, true), 'Le jour courant est absent de sa semaine'); + $assert(!in_array($outside, $ids, true), 'La semaine suivante déborde dans la vue'); + $columns = array_column($db->query('PRAGMA table_info(users)')->fetchAll(PDO::FETCH_ASSOC), 'name'); + $assert(in_array('agenda_view', $columns, true), 'La préférence de vue agenda est absente'); + }); + $test('Les bénévoles de l’annuaire peuvent être mobilisés sur une mission', function () use ($db, $assert): void { + $db->exec("INSERT INTO directory_contacts(kind,name) VALUES('person','Bénévole Agenda')"); + $volunteer = (int) $db->lastInsertId(); + $db->prepare("INSERT INTO directory_contact_roles(contact_id,role) VALUES(?,'benevole')")->execute([ + $volunteer, + ]); + $event = AgendaService::save([ + 'item_type' => 'transport', + 'title' => 'Mission bénévole', + 'date' => date('Y-m-d'), + 'time' => '14:00', + 'location' => 'Clinique', + 'volunteer_contact_ids' => [$volunteer], + ]); + $rows = array_values( + array_filter( + AgendaService::week(new DateTimeImmutable('today')), + static fn($row) => (int) $row['id'] === $event, + ), + ); + $assert( + count($rows) === 1 && str_contains((string) $rows[0]['volunteer_names'], 'Bénévole Agenda'), + 'Bénévole absent de la mission', + ); + $assert(str_contains((string) $rows[0]['title'], 'Bénévole Agenda'), 'Affectation non visible dans l’agenda'); + $overlap = AgendaService::save([ + 'item_type' => 'task', + 'title' => 'Mission simultanée', + 'date' => date('Y-m-d'), + 'time' => '14:30', + 'volunteer_contact_ids' => [$volunteer], + ]); + $assert(count(AgendaService::conflicts($overlap)) === 1, 'Le chevauchement du bénévole n’est pas détecté'); + }); + $test('L’export iCalendar et son abonnement privé sont révocables', function () use ($db, $assert): void { + $token = CalendarFeedService::generateToken((int) Auth::id()); + $assert( + strlen($token) === 64 && CalendarFeedService::userForToken($token) !== null, + 'Jeton iCalendar invalide', + ); + $stored = (string) $db + ->query('SELECT agenda_feed_token_hash FROM users WHERE id=' . (int) Auth::id()) + ->fetchColumn(); + $assert($stored === hash('sha256', $token) && $stored !== $token, 'Le jeton privé est stocké en clair'); + $ics = CalendarFeedService::render(AgendaService::week(new DateTimeImmutable('today')), 'Agenda Test'); + $assert( + str_starts_with($ics, "BEGIN:VCALENDAR\r\n") && + str_contains($ics, 'BEGIN:VEVENT') && + str_contains($ics, 'END:VCALENDAR'), + 'Export iCalendar incomplet', + ); + CalendarFeedService::revoke((int) Auth::id()); + $assert(CalendarFeedService::userForToken($token) === null, 'Le lien révoqué reste utilisable'); + }); + $test('Une échéance vaccinale produit une notification', function () use ($db, $assert): void { + $db->exec("INSERT INTO ref_vaccines(name) VALUES('Test vaccin')"); + $v = (int) $db->lastInsertId(); + $db->exec( + "INSERT INTO animals(internal_code,name,species,status) VALUES('TEST-001','Animal Test','chat','refuge')", + ); + $a = (int) $db->lastInsertId(); + $db->prepare( + "INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date) VALUES(?,?,date('now','-1 year'),date('now','-1 day'))", + )->execute([$a, $v]); + $alerts = NotificationService::alerts(); + $assert((bool) array_filter($alerts, static fn($x) => $x['key'] === 'vaccines')); + }); + $test('Les rappels vaccinaux apparaissent automatiquement dans l’agenda', function () use ($assert): void { + $month = AgendaService::month(new DateTimeImmutable('first day of this month')); + $reminders = array_values( + array_filter( + $month, + static fn($row) => ($row['source_type'] ?? '') === 'vaccine' && ($row['item_type'] ?? '') === 'vaccine', + ), + ); + $assert(count($reminders) >= 1, 'Aucun rappel vaccinal virtuel dans le calendrier'); + $assert((int) $reminders[0]['id'] < 0, 'Le rappel vaccinal ne doit pas devenir un événement éditable'); + }); + $test('Vermifuges et traitements alimentent l’agenda sans double saisie', function () use ($db, $assert): void { + $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn(); + $dewormer = (int) $db->query('SELECT id FROM ref_dewormers ORDER BY id LIMIT 1')->fetchColumn(); + $db->prepare( + "INSERT INTO dewormings(animal_id,dewormer_id,administered_on,next_due_date) VALUES(?,?,date('now','-3 months'),date('now'))", + )->execute([$animal, $dewormer]); + $db->exec("INSERT INTO ref_medications(name) VALUES('Traitement agenda')"); + $medication = (int) $db->lastInsertId(); + $db->prepare( + "INSERT INTO treatments(animal_id,medication_id,dose_text,start_date,end_date,ongoing) VALUES(?,?,'Dose test',date('now'),date('now','+1 day'),1)", + )->execute([$animal, $medication]); + $sources = array_column(AgendaService::upcoming(2), 'source_type'); + foreach (['deworming', 'treatment_start', 'treatment_end'] as $source) { + $assert(in_array($source, $sources, true), 'Échéance absente : ' . $source); + } + }); + $test('Le parcours médical et adoption conserve ses relations', function () use ($db, $assert): void { + $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn(); + $db->prepare( + "INSERT INTO medical_notes(animal_id,kind,reason,created_by) VALUES(?,'controle','Contrôle de test',?)", + )->execute([$animal, Auth::id()]); + $db->prepare( + "INSERT INTO adoptions(animal_id,adopter_name,adoption_date) VALUES(?,'Adoptant test',date('now'))", + )->execute([$animal]); + $assert((int) $db->query('SELECT COUNT(*) FROM medical_notes')->fetchColumn() === 1); + $assert((int) $db->query('SELECT COUNT(*) FROM adoptions')->fetchColumn() === 1); + $assert(!$db->query('PRAGMA foreign_key_check')->fetchAll()); + }); + $test('La fiche annuaire agrège adoptions et dépenses', function () use ($db, $assert): void { + $db->exec("INSERT INTO directory_contacts(kind,name) VALUES('organization','Structure test')"); + $contact = (int) $db->lastInsertId(); + $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn(); + $db->prepare('UPDATE adoptions SET adopter_contact_id=? WHERE animal_id=?')->execute([$contact, $animal]); + $db->prepare( + "INSERT INTO animal_expenses(animal_id,clinic_contact_id,label,occurred_on,quantity,catalog_unit_cents,total_cents) VALUES(?,?,'Acte test',date('now'),1,1234,1234)", + )->execute([$animal, $contact]); + $q = $db->prepare( + "SELECT (SELECT COUNT(*) FROM (SELECT animal_id FROM adoptions WHERE adopter_contact_id=dc.id UNION SELECT animal_id FROM animal_placements WHERE contact_id=dc.id AND event_type='adoption')) adopted,(SELECT COALESCE(SUM(total_cents),0) FROM animal_expenses WHERE clinic_contact_id=dc.id) total FROM directory_contacts dc WHERE dc.id=?", + ); + $q->execute([$contact]); + $row = $q->fetch(); + $assert((int) $row['adopted'] === 1); + $assert((int) $row['total'] === 1234); + $costs = StatisticsService::costs((int) date('Y')); + $structure = + array_values( + array_filter($costs['by_clinic'], static fn($item) => (int) $item['contact_id'] === $contact), + )[0] ?? null; + $assert($structure !== null && $structure['label'] === 'Structure test'); + $assert((int) $structure['total_cents'] === 1234); + }); + $test('La recherche globale retrouve les dossiers accessibles', function () use ($assert): void { + $animals = GlobalSearchService::search('Animal Test'); + $assert(($animals['animals'][0]['title'] ?? '') === 'Animal Test', 'Animal absent de la recherche'); + $contacts = GlobalSearchService::search('Structure test'); + $assert(($contacts['directory'][0]['title'] ?? '') === 'Structure test', 'Contact absent de la recherche'); + $agenda = GlobalSearchService::search('Mission bénévole'); + $assert(!empty($agenda['agenda']), 'Événement absent de la recherche'); + }); + $test('Une sauvegarde ZIP contient une base et un manifeste', function () use (&$createdBackup, $assert): void { + if (!class_exists('ZipArchive')) { + return; + } + $createdBackup = BackupService::create('test-suite'); + $path = BackupService::path($createdBackup['name']); + $assert($path !== null); + $zip = new ZipArchive(); + $assert($zip->open($path) === true); + $assert($zip->locateName('manifest.json') !== false); + $assert($zip->locateName('database/refuge.sqlite') !== false); + $zip->close(); + }); + $test('La sauvegarde créée est relue et son SQLite vérifié', function () use (&$createdBackup, $assert): void { + if (!$createdBackup) { + return; + } + $result = BackupService::verify($createdBackup['name']); + $assert($result['valid'] === true); + $assert($result['files'] >= 1); + }); + $test('La pharmacie refuse un stock négatif et valorise l’animal', function () use ($db, $assert): void { + $product = InventoryService::saveProduct([ + 'category' => 'medication', + 'name' => 'Produit stock test', + 'unit' => 'comprimé', + 'minimum_quantity' => '2', + ]); + $batch = InventoryService::receive([ + 'product_id' => $product, + 'quantity' => '10', + 'unit_cost' => '1,20', + 'batch_number' => 'TEST-LOT', + 'expires_on' => date('Y-m-d', strtotime('+1 year')), + ]); + $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn(); + InventoryService::consumeBatch($batch, 2, $animal, 'Test automatisé'); + $assert((float) $db->query('SELECT quantity FROM inventory_batches WHERE id=' . $batch)->fetchColumn() === 8.0); + $assert( + (int) $db + ->query( + "SELECT total_cents FROM animal_expenses WHERE source_type='inventory' AND source_id=(SELECT MAX(id) FROM inventory_movements WHERE batch_id=$batch)", + ) + ->fetchColumn() === 240, + ); + $failed = false; + try { + InventoryService::consumeBatch($batch, 20, null, 'Impossible'); + } catch (RuntimeException) { + $failed = true; + } + $assert($failed, 'Le stock négatif a été accepté'); + $assert( + (int) $db->query('SELECT COUNT(*) FROM inventory_movements WHERE batch_id=' . $batch)->fetchColumn() === 2, + ); + }); + $test('La portabilité et le registre privé sont opérationnels', function () use ($db, $assert): void { + if (!class_exists('ZipArchive')) { + return; + } + $db->exec( + "INSERT INTO directory_contacts(kind,name,email) VALUES('person','Contact RGPD','contact@example.test')", + ); + $id = (int) $db->lastInsertId(); + $path = PrivacyService::exportContact($id); + $assert(is_file($path)); + $zip = new ZipArchive(); + $assert($zip->open($path) === true); + $assert($zip->locateName('contact.json') !== false); + $zip->close(); + unlink($path); + PrivacyService::logAccess('test_document', 1, null, 'test.pdf'); + $assert( + (int) $db + ->query("SELECT COUNT(*) FROM private_file_access_log WHERE resource_type='test_document'") + ->fetchColumn() === 1, + ); + }); + $test('La maintenance RGPD purge uniquement le journal arrivé à échéance', function () use ($db, $assert): void { + AppSettings::save(['privacy_access_log_months' => '12'], Auth::id()); + $db->exec( + "INSERT INTO private_file_access_log(resource_type,action,accessed_at) VALUES('ancien_test','view',datetime('now','-13 months'))", + ); + $result = PrivacyService::maintenance(); + $assert($result['access_logs_removed'] >= 1); + $assert( + (int) $db + ->query("SELECT COUNT(*) FROM private_file_access_log WHERE resource_type='ancien_test'") + ->fetchColumn() === 0, + ); + $assert( + (int) $db->query("SELECT COUNT(*) FROM directory_contacts WHERE name='Contact RGPD'")->fetchColumn() === 1, + 'Un contact a été supprimé automatiquement', + ); + }); + $test('Les appareils persistants sont révocables', function () use ($db, $assert): void { + TrustedDeviceService::create((int) Auth::id(), 'Téléphone de test'); + $device = $db->query('SELECT * FROM trusted_devices ORDER BY id DESC LIMIT 1')->fetch(); + $assert($device && $device['device_name'] === 'Téléphone de test'); + TrustedDeviceService::revoke((int) $device['id'], (int) Auth::id()); + $assert( + (bool) $db->query('SELECT revoked_at FROM trusted_devices WHERE id=' . (int) $device['id'])->fetchColumn(), + ); + }); + $test('La démonstration installe une fiche Twix complète', function () use ($db, $root, $assert): void { + DemoDataService::reset($db); + $summary = DemoDataService::seed($db, Auth::id()); + DemoDataService::clearFiles(); + DemoDataService::installFiles($db); + $twix = (int) $db + ->query("SELECT id FROM animals WHERE internal_code='DEMO-003' AND name='Twix' AND status='quarantaine'") + ->fetchColumn(); + $assert($twix > 0, 'Twix absent de la quarantaine'); + $assert( + $summary['lab_reports'] === 1 && $summary['agenda_items'] === 1 && $summary['photos'] === 1, + 'Résumé de démonstration incomplet', + ); + foreach ( + [ + 'medical_notes', + 'treatments', + 'vaccinations', + 'dewormings', + 'animal_health_conditions', + 'lab_reports', + 'animal_photos', + 'agenda_items', + ] + as $table + ) { + $assert( + (int) $db->query("SELECT COUNT(*) FROM $table WHERE animal_id=$twix")->fetchColumn() > 0, + "Donnée Twix absente : $table", + ); + } + $assert(is_file($root . '/public/media/animals/' . $twix . '/twix.webp'), 'Photo de Twix absente'); + DemoDataService::clearFiles(); + }); +} finally { + if ($createdBackup && isset($createdBackup['name'])) { + try { + BackupService::delete($createdBackup['name']); + } catch (Throwable) { + } + } + DB::close(); + foreach ([$tmp, $tmp . '-wal', $tmp . '-shm'] as $file) { + if (is_file($file)) { + unlink($file); + } + } +} +echo "\n$passed réussi(s), $failed échec(s).\n"; +exit($failed ? 1 : 0);