From a55d27935f7e25c51916a12a36b98a06bc920dc3 Mon Sep 17 00:00:00 2001 From: oversu <95a7002b-481b-4ef3-b578-f9c421550aa4@localhost> Date: Tue, 22 Sep 2026 18:05:38 +0200 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20corriger=20le=20suivi=20m=C3=A9dical?= =?UTF-8?q?=20et=20les=20styles=20publics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 +++ HISTORIQUE.md | 4 ++ README.md | 2 +- VERSION | 2 +- app/Controllers/AnimalMedicalActions.php | 43 ++++++++---- app/Controllers/DewormingController.php | 47 +++++++++++++ app/Routes/web.php | 3 + app/Services/PermissionService.php | 2 + app/Views/_animal_show_medical.php | 16 ++++- app/Views/_animal_show_modals.php | 84 +++++++++++++++++++++++- app/Views/_deworming_modal.php | 2 - app/Views/animal_show.php | 2 + app/Views/public_site_layout.php | 7 +- resources/lang/en.php | 3 + resources/lang/fr.php | 3 + 15 files changed, 206 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e211222..cdd69e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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.29] — 2026-09-22 + +### Corrections du suivi médical et du site public + +- correction du formulaire d’édition des administrations de vermifuge / anti-puce ; +- modification des traitements déjà saisis et correction des intitulés de produits médicaux ; +- accès plus visible aux vermifuges dans l’onglet médical ; +- chargement du CSS personnalisé après les styles ordinaires du site public ; +- affichage de la date de naissance dans le résumé de la fiche animale. + ## [1.0.0-rc.28] — 2026-09-15 ### Saisies médicales corrigibles diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 558fd31..6fb9616 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden 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.29` — Suivi médical et présentation + +Les traitements et les intitulés des produits médicaux peuvent être corrigés après leur saisie. Le formulaire des vermifuges s’ouvre de nouveau correctement. La fiche animale montre sa date de naissance et le CSS personnalisé du site public prend priorité sur les styles standard. + ## `1.0.0-rc.28` — Corrections des saisies médicales Les vaccinations et administrations de vermifuges ou d’anti-puces peuvent être corrigées sans provoquer une seconde sortie de stock ni une nouvelle dépense. Les nouvelles fiches de chats proposent également la race Chat Européen par défaut. diff --git a/README.md b/README.md index 1796654..4d069ee 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L ## État du projet -La candidate actuelle est `1.0.0-rc.28`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. +La candidate actuelle est `1.0.0-rc.29`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION index 90a7420..3dcfd46 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.28 +1.0.0-rc.29 diff --git a/app/Controllers/AnimalMedicalActions.php b/app/Controllers/AnimalMedicalActions.php index 3846c5f..a8a2c4b 100644 --- a/app/Controllers/AnimalMedicalActions.php +++ b/app/Controllers/AnimalMedicalActions.php @@ -400,6 +400,7 @@ trait AnimalMedicalActions } $id = (int) ($_POST['animal_id'] ?? 0); + $entryId = (int) ($_POST['entry_id'] ?? 0); $medId = (int) ($_POST['medication_id'] ?? 0); $createMedication = !empty($_POST['create_medication']); if ($id <= 0 || (!$createMedication && $medId <= 0)) { @@ -429,6 +430,15 @@ trait AnimalMedicalActions } $db = DB::pdo(); + if ($entryId > 0) { + $existing = $db->prepare('SELECT 1 FROM treatments WHERE id=:entry AND animal_id=:animal'); + $existing->execute([':entry' => $entryId, ':animal' => $id]); + if (!$existing->fetchColumn()) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + } if ($createMedication) { $medicationName = trim((string) ($_POST['new_medication_name'] ?? '')); $medicationMolecule = trim((string) ($_POST['new_medication_molecule'] ?? '')); @@ -475,12 +485,7 @@ trait AnimalMedicalActions 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, + $values = [ ':mid' => $medId, ':route' => $route !== '' ? $route : null, ':dose' => $dose, @@ -494,16 +499,32 @@ trait AnimalMedicalActions ':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'); + ]; + if ($entryId > 0) { + $values[':entry'] = $entryId; + $db->prepare('UPDATE treatments SET medication_id=:mid,route=:route,dose_text=:dose,start_date=:start, + end_date=:end,notes=:notes,clinic_id=:clinic_id,vet_name=:vet_name, + clinic_contact_id=:clinic_contact_id,vet_contact_id=:vet_contact_id, + give_morning=:give_morning,give_evening=:give_evening,give_as_needed=:give_as_needed + WHERE id=:entry')->execute($values); + } else { + $values[':aid'] = $id; + $values[':created_by'] = Auth::id(); + $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)') + ->execute($values); + 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é', + $entryId > 0 ? 'Traitement corrigé' : 'Traitement ajouté', "Médicament #$medId\nDose : $dose\nDébut : $start" . ($end !== '' ? "\nFin : $end" : '') . ($notes !== '' ? "\nNotes : $notes" : ''), diff --git a/app/Controllers/DewormingController.php b/app/Controllers/DewormingController.php index 5757a83..fc83a4e 100644 --- a/app/Controllers/DewormingController.php +++ b/app/Controllers/DewormingController.php @@ -4,6 +4,53 @@ declare(strict_types=1); final class DewormingController { + public static function renameProduct(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + if (!PermissionService::can('medical', 'edit')) { + http_response_code(403); + return; + } + $tables = ['dewormer' => 'ref_dewormers', 'medication' => 'ref_medications', 'vaccine' => 'ref_vaccines']; + $category = (string) ($_POST['category'] ?? ''); + $table = $tables[$category] ?? null; + $id = (int) ($_POST['product_id'] ?? 0); + $animalId = (int) ($_POST['animal_id'] ?? 0); + $name = trim((string) ($_POST['name'] ?? '')); + if ($table === null || $id <= 0 || $animalId <= 0 || $name === '' || mb_strlen($name) > 150) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + $db = DB::pdo(); + $stmt = $db->prepare("SELECT name FROM $table WHERE id=:id"); + $stmt->execute([':id' => $id]); + $previous = $stmt->fetchColumn(); + if ($previous === false) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + $duplicate = $db->prepare("SELECT 1 FROM $table WHERE name=:name COLLATE NOCASE AND id<>:id"); + $duplicate->execute([':name' => $name, ':id' => $id]); + if ($duplicate->fetchColumn()) { + http_response_code(409); + echo h(t('catalog.name_exists')); + return; + } + $db->prepare("UPDATE $table SET name=:name WHERE id=:id")->execute([':name' => $name, ':id' => $id]); + AuditService::log('catalog_renamed', '/animal/product/rename', 'Intitulé de produit corrigé', 'animal', $animalId, [ + 'category' => $category, + 'product_id' => $id, + 'previous_name' => $previous, + 'new_name' => $name, + ]); + header('Location: /animal?id=' . $animalId . '#tab-med'); + } + public static function save(): void { if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { diff --git a/app/Routes/web.php b/app/Routes/web.php index 9a12b0e..81f4db3 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -88,6 +88,9 @@ switch ($path) { case '/animal/add-deworming': DewormingController::save(); break; + case '/animal/product/rename': + DewormingController::renameProduct(); + break; case '/animal/add-vaccine': AnimalsController::addVaccine(); diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 9de5203..a03c5a1 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -91,6 +91,8 @@ final class PermissionService '/animal/medical-status', '/animal/add-treatment', '/animal/add-vaccine', + '/animal/add-deworming', + '/animal/product/rename', '/animal/death', '/animal/prescription', '/animal/lab-report', diff --git a/app/Views/_animal_show_medical.php b/app/Views/_animal_show_medical.php index 9eaa17c..57e97f0 100644 --- a/app/Views/_animal_show_medical.php +++ b/app/Views/_animal_show_medical.php @@ -198,7 +198,7 @@
-

+

🪱

@@ -400,7 +400,19 @@ ORDER BY created_at ASC - + diff --git a/app/Views/_animal_show_modals.php b/app/Views/_animal_show_modals.php index d56aad7..46b1474 100644 --- a/app/Views/_animal_show_modals.php +++ b/app/Views/_animal_show_modals.php @@ -22,6 +22,7 @@ $currentTime = date('H:i'); @@ -332,6 +337,33 @@ $currentTime = date('H:i'); + + +
+
@@ -264,7 +286,7 @@ if ($grantAlertTotal > 0): ?>
🚨
- +
@@ -290,7 +312,7 @@ if ($grantAlertTotal > 0): ?>
⏳
-
+
@@ -315,7 +337,7 @@ if ($grantAlertTotal > 0): ?>
📅
-
+
diff --git a/app/Views/evolutions.php b/app/Views/evolutions.php index 93df138..e4cef25 100644 --- a/app/Views/evolutions.php +++ b/app/Views/evolutions.php @@ -31,10 +31,10 @@
- 1.0.0-rc.28 -

Saisies médicales corrigibles

-

Vaccinations et traitements antiparasitaires peuvent être rectifiés sans - déclencher une seconde sortie de stock ou une nouvelle dépense.

+ 1.0.0-rc.30 +

Données fiabilisées sur le terrain

+

Décès historiques corrigibles, synchronisation I-CAD réparée, catalogues + médicaux administrables et tarifs filtrés par clinique.

@@ -43,8 +43,8 @@
Après la 1.0

Prochaines étapes

-

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

+

Réinitialisation planifiée des démonstrations, normalisation assistée des + contacts, validation des paquets YunoHost, ZimaOS et CasaOS, puis amélioration continue des parcours.

diff --git a/app/Views/settings_catalogs.php b/app/Views/settings_catalogs.php new file mode 100644 index 0000000..bcdae9b --- /dev/null +++ b/app/Views/settings_catalogs.php @@ -0,0 +1,17 @@ +t('animal.medication'),'vaccine'=>t('animal.vaccine'),'dewormer'=>t('animals.dewormer'),'manufacturer'=>t('animal.manufacturer')]; +$extras = ['medication'=>t('animal.molecule'),'vaccine'=>t('catalog.code'),'dewormer'=>t('catalog.active_ingredient'),'manufacturer'=>'']; ?> +
+
+

+
+ $rows): ?>
+

+ +
+
+
+
+
+
+ diff --git a/app/Views/settings_nav.php b/app/Views/settings_nav.php index ba4a8bb..49f5ea9 100644 --- a/app/Views/settings_nav.php +++ b/app/Views/settings_nav.php @@ -8,6 +8,7 @@ $settingsSection = match(true) { 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/catalogs') => 'catalogs', 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', diff --git a/app/Views/settings_public_site.php b/app/Views/settings_public_site.php index d36c089..e875937 100644 --- a/app/Views/settings_public_site.php +++ b/app/Views/settings_public_site.php @@ -173,6 +173,14 @@ const insertImage = (button) => { textarea.setRangeText(`![${safeAlt}](${button.dataset.url})\n`, textarea.selectionStart, textarea.selectionEnd, 'end'); textarea.focus(); }; +document.querySelectorAll('textarea').forEach((textarea) => { + const resize = () => { + textarea.style.height = 'auto'; + textarea.style.height = Math.max(140, textarea.scrollHeight + 8) + 'px'; + }; + textarea.addEventListener('input', resize); + resize(); +}); document.querySelectorAll('.public-rich-text').forEach((textarea) => { textarea.addEventListener('focus', () => { activePublicEditor = textarea; }); const toolbar = document.createElement('div'); diff --git a/app/Views/surgery_form.php b/app/Views/surgery_form.php index ba39841..685a84b 100644 --- a/app/Views/surgery_form.php +++ b/app/Views/surgery_form.php @@ -8,7 +8,7 @@
+
+
Les tarifs affichés correspondent à la structure sélectionnée.
+ + + +
diff --git a/app/Views/_animal_show_medical.php b/app/Views/_animal_show_medical.php index 7835891..064d39f 100644 --- a/app/Views/_animal_show_medical.php +++ b/app/Views/_animal_show_medical.php @@ -443,6 +443,7 @@ ORDER BY created_at ASC + @@ -457,7 +458,7 @@ ORDER BY created_at ASC - + data-notes=""> diff --git a/app/Views/_animal_show_modals.php b/app/Views/_animal_show_modals.php index 7c58636..a01e550 100644 --- a/app/Views/_animal_show_modals.php +++ b/app/Views/_animal_show_modals.php @@ -1160,6 +1160,7 @@ document.addEventListener('DOMContentLoaded', () => { diff --git a/app/Views/evolutions.php b/app/Views/evolutions.php index e4cef25..5aaa120 100644 --- a/app/Views/evolutions.php +++ b/app/Views/evolutions.php @@ -31,10 +31,10 @@
- 1.0.0-rc.30 -

Données fiabilisées sur le terrain

-

Décès historiques corrigibles, synchronisation I-CAD réparée, catalogues - médicaux administrables et tarifs filtrés par clinique.

+ 1.0.0-rc.31 +

Parcours de correction plus fiables

+

Liens d’adoptants corrigés, modification des vaccins plus explicite et + synchronisation i-CAD utilisable même sans JavaScript.

diff --git a/tests/run.php b/tests/run.php index 0ef1c1e..7c8eda6 100644 --- a/tests/run.php +++ b/tests/run.php @@ -215,6 +215,18 @@ try { $assert(strpos($vaccines, 'if ($entryId > 0)') < strpos($vaccines, 'InventoryService::consumeBatch')); $assert(strpos($dewormings, 'if ($entryId > 0)') < strpos($dewormings, 'InventoryService::consumeBatch')); }); + $test('La liste des vaccins affiche une action de modification explicite', function () use ($assert): void { + $medical = file_get_contents(__DIR__ . '/../app/Views/_animal_show_medical.php'); + $assert(str_contains($medical, "h(t('common.actions'))"), 'L’en-tête Actions manque aux vaccins'); + $assert( + str_contains($medical, 'btn btn-sm btn-outline-primary edit-vaccine'), + 'Le bouton Modifier des vaccins est absent', + ); + $assert( + !preg_match('/class="[^"]*edit-vaccine[^"]*"[\s\S]*?>✏️<\/button>/', $medical), + 'Le crayon est encore utilisé pour modifier un vaccin', + ); + }); $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'); @@ -491,6 +503,26 @@ try { $assert($structure !== null && $structure['label'] === 'Structure test'); $assert((int) $structure['total_cents'] === 1234); }); + $test('Le nom de l’adoptant ouvre la bonne route annuaire', function () use ($assert): void { + $view = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php'); + $routes = file_get_contents(__DIR__ . '/../app/Routes/web.php'); + $assert( + str_contains($view, 'href="/directory/view?id="'), + 'Le lien de l’adoptant ne cible pas /directory/view', + ); + $assert(!str_contains($view, '/directory/show?id='), 'L’ancienne route annuaire est encore utilisée'); + $assert(str_contains($routes, "case '/directory/view':"), 'La route /directory/view n’est pas déclarée'); + }); + $test('Le rafraîchissement i-CAD fonctionne avec et sans JavaScript', function () use ($assert): void { + $identity = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php'); + $scripts = file_get_contents(__DIR__ . '/../app/Views/_animal_show_modals.php'); + $controller = file_get_contents(__DIR__ . '/../app/Controllers/AnimalsController.php'); + $assert(str_contains($identity, 'action="/animal/icad"'), 'Le bouton i-CAD n’a pas de formulaire de secours'); + $assert(str_contains($identity, 'name="csrf"'), 'Le formulaire i-CAD ne contient pas le jeton CSRF'); + $assert(str_contains($scripts, "form.addEventListener('submit', refresh)"), 'L’actualisation dynamique i-CAD est absente'); + $assert(str_contains($scripts, 'if (!r.ok)'), 'Les erreurs HTTP i-CAD ne sont pas contrôlées'); + $assert(str_contains($controller, "header('Location: /animal?id=' . \$id . '#tab-ident')"), 'Le repli sans JavaScript est absent'); + }); $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'); From 86642095cd6d9bb34b5b8d6a27e5f0cd967ce668 Mon Sep 17 00:00:00 2001 From: oversu <95a7002b-481b-4ef3-b578-f9c421550aa4@localhost> Date: Fri, 2 Oct 2026 12:11:30 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Ajouter=20les=20statistiques=20par=20p?= =?UTF-8?q?=C3=A9riode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++ HISTORIQUE.md | 4 + README.md | 4 +- VERSION | 2 +- app/Controllers/DirectoryController.php | 39 ++++- app/Controllers/StatisticsController.php | 74 +++++++- app/Services/StatisticsService.php | 205 +++++++++++++---------- app/Views/directory_show.php | 4 +- app/Views/evolutions.php | 8 +- app/Views/statistics.php | 49 ++++-- docs/07-statistiques-et-administratif.md | 4 +- resources/lang/en.php | 9 + resources/lang/fr.php | 9 + tests/run.php | 21 +++ 14 files changed, 316 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb39f70..22fb83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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.32] — 2026-10-02 + +### Statistiques par mois et par période + +- ajout des modes « Année », « Mois » et « Période personnalisée » en complément de la vue globale ; +- application de la période aux entrées, sorties, adoptions, décès, naissances, vaccinations, traitements et notes médicales ; +- filtrage cohérent des coûts vétérinaires, factures, structures et communes sur les mêmes dates ; +- comparaison automatique avec la période précédente de même durée, tout en conservant la comparaison année contre année ; +- conservation de la période lors de la navigation vers une commune ou une structure vétérinaire. + ## [1.0.0-rc.31] — 2026-10-01 ### Navigation et synchronisation des dossiers animaux diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 2b6f8d3..5b78c84 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden 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.32` — Statistiques à la période souhaitée + +Les bilans ne sont plus limités à une année complète. Une association peut désormais analyser un mois précis ou choisir librement deux dates ; les volumes d’activité, profils, coûts vétérinaires et comparaisons utilisent tous le même intervalle. + ## `1.0.0-rc.31` — Parcours de correction plus fiables Les liens entre animaux et adoptants conduisent désormais directement à la bonne fiche d’annuaire. La modification des vaccins devient explicite et la synchronisation i-CAD ne dépend plus entièrement du JavaScript : un formulaire serveur assure le rafraîchissement même lorsque l’interface dynamique rencontre un problème. diff --git a/README.md b/README.md index fffd27a..ef3f491 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ L’application est développée à partir de besoins rencontrés sur le terrain - placements, adoptions, retours, décès, archives et corbeille ; - agenda partagé, rappels médicaux et abonnement iCalendar privé ; - pharmacie, lots, péremptions et mouvements de stock ; -- factures vétérinaires, règlements, justificatifs et statistiques de coûts ; +- factures vétérinaires, règlements, justificatifs et statistiques de coûts par année, mois ou période libre ; - registre des entrées et sorties, exports CSV et dossiers de subvention ; - recherche globale avec contrôle des résultats selon le rôle ; - site public facultatif pour les animaux proposés à l’adoption ; @@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L ## État du projet -La candidate actuelle est `1.0.0-rc.31`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. +La candidate actuelle est `1.0.0-rc.32`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION index 99cb14c..8768c04 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.31 +1.0.0-rc.32 diff --git a/app/Controllers/DirectoryController.php b/app/Controllers/DirectoryController.php index 4f96200..1c101c7 100644 --- a/app/Controllers/DirectoryController.php +++ b/app/Controllers/DirectoryController.php @@ -95,17 +95,39 @@ final class DirectoryController '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); + $periodRequested = array_key_exists('year', $_GET) || array_key_exists('period_type', $_GET); $periodRaw = (string) ($_GET['year'] ?? 'all'); $periodYear = ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null; + $periodType = (string) ($_GET['period_type'] ?? ($periodYear ? 'year' : 'all')); + $periodStart = $periodEnd = null; + $periodQuery = ['period_type' => $periodType]; + if ($periodType === 'year' && $periodYear !== null) { + $periodStart = sprintf('%04d-01-01', $periodYear); + $periodEnd = sprintf('%04d-12-31', $periodYear); + $periodQuery['year'] = $periodYear; + } elseif ($periodType === 'month' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', (string) ($_GET['month'] ?? ''))) { + $periodQuery['month'] = (string) $_GET['month']; + $periodStart = $periodQuery['month'] . '-01'; + $periodEnd = (new DateTimeImmutable($periodStart))->modify('last day of this month')->format('Y-m-d'); + } elseif ($periodType === 'custom') { + $from = (string) ($_GET['date_from'] ?? ''); + $to = (string) ($_GET['date_to'] ?? ''); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + [$periodStart, $periodEnd] = $from <= $to ? [$from, $to] : [$to, $from]; + $periodQuery += ['date_from' => $periodStart,'date_to' => $periodEnd]; + } + } + $periodLabel = $periodStart && $periodEnd + ? (new DateTimeImmutable($periodStart))->format('d/m/Y') . ' – ' . (new DateTimeImmutable($periodEnd))->format('d/m/Y') + : t('statistics.all_years'); $periodMunicipality = trim((string) ($_GET['municipality'] ?? '')); $expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL']; $expenseParams = [':id' => $id]; - if ($periodYear !== null) { + if ($periodStart !== null && $periodEnd !== 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); + $expenseParams[':expense_start'] = $periodStart; + $expenseParams[':expense_end'] = $periodEnd; } $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 ' . @@ -129,9 +151,10 @@ final class DirectoryController $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; + if ($periodStart !== null && $periodEnd !== null) { + $invoiceWhere[] = 'date(invoice_date) BETWEEN :invoice_start AND :invoice_end'; + $invoiceParams[':invoice_start'] = $periodStart; + $invoiceParams[':invoice_end'] = $periodEnd; } $invoiceStmt = $db->prepare( 'SELECT * FROM accounting_invoices WHERE ' . @@ -162,6 +185,8 @@ final class DirectoryController 'expenseCount' => $expenseCount, 'expensePeriodRequested' => $periodRequested, 'expensePeriodYear' => $periodYear, + 'expensePeriodLabel' => $periodLabel, + 'expensePeriodQuery' => $periodQuery, 'expensePeriodMunicipality' => $periodMunicipality, 'contactInvoices' => $contactInvoices, 'contactInvoiceTotal' => $contactInvoiceTotal, diff --git a/app/Controllers/StatisticsController.php b/app/Controllers/StatisticsController.php index 9d1e906..e1f067d 100644 --- a/app/Controllers/StatisticsController.php +++ b/app/Controllers/StatisticsController.php @@ -6,37 +6,95 @@ final class StatisticsController { public static function index(): void { - $requested = (string) ($_GET['year'] ?? date('Y')); - $all = $requested === 'all'; - $year = $all ? null : max(2000, min(2200, (int) $requested)); $years = StatisticsService::years(); + $requestedYear = (string) ($_GET['year'] ?? date('Y')); + $periodType = (string) ($_GET['period_type'] ?? ($requestedYear === 'all' ? 'all' : 'year')); + if (!in_array($periodType, ['all','year','month','custom'], true)) { + $periodType = 'year'; + } + $year = max(2000, min(2200, (int) ($requestedYear === 'all' ? date('Y') : $requestedYear))); + $month = preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', (string) ($_GET['month'] ?? '')) + ? (string) $_GET['month'] + : date('Y-m'); + $validDate = static function (string $value): bool { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value; + }; + $dateFrom = $validDate((string) ($_GET['date_from'] ?? '')) ? (string) $_GET['date_from'] : date('Y-01-01'); + $dateTo = $validDate((string) ($_GET['date_to'] ?? '')) ? (string) $_GET['date_to'] : date('Y-m-d'); + if ($dateFrom > $dateTo) { + [$dateFrom, $dateTo] = [$dateTo, $dateFrom]; + } + $start = $end = null; + if ($periodType === 'year') { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } elseif ($periodType === 'month') { + $start = $month . '-01'; + $end = (new DateTimeImmutable($start))->modify('last day of this month')->format('Y-m-d'); + $year = (int) substr($month, 0, 4); + } elseif ($periodType === 'custom') { + $start = $dateFrom; + $end = $dateTo; + $year = (int) substr($dateFrom, 0, 4); + } + $all = $periodType === 'all'; + $formatDate = static fn(string $date): string => (new DateTimeImmutable($date))->format('d/m/Y'); + $periodLabel = match ($periodType) { + 'all' => t('statistics.all_years'), + 'month' => substr($month, 5, 2) . '/' . substr($month, 0, 4), + 'custom' => $formatDate($dateFrom) . ' – ' . $formatDate($dateTo), + default => (string) $year, + }; + $periodQuery = ['period_type' => $periodType]; + if ($periodType === 'year') $periodQuery['year'] = $year; + if ($periodType === 'month') $periodQuery['month'] = $month; + if ($periodType === 'custom') $periodQuery += ['date_from' => $dateFrom,'date_to' => $dateTo]; $municipalities = StatisticsService::municipalities(); $municipality = trim((string) ($_GET['municipality'] ?? '')); if ($municipality !== '' && !isset($municipalities[$municipality])) { $municipality = ''; } $ids = $municipality !== '' ? StatisticsService::animalIdsForMunicipality($municipality) : null; + if ($all) { + $comparison = []; + $previousPeriodLabel = ''; + } elseif ($periodType === 'year') { + $comparison = StatisticsService::comparison($year, $ids); + $previousPeriodLabel = (string) ($year - 1); + } else { + $comparisonResult = StatisticsService::comparisonPeriod($start, $end, $ids); + $comparison = $comparisonResult['rows']; + $previousPeriodLabel = $formatDate($comparisonResult['previous_start']) . ' – ' . $formatDate($comparisonResult['previous_end']); + } render('statistics.php', [ 'title' => t('statistics.title'), 'pageDescription' => t('statistics.description'), 'year' => $year, 'allYears' => $all, + 'periodType' => $periodType, + 'month' => $month, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'periodLabel' => $periodLabel, + 'previousPeriodLabel' => $previousPeriodLabel, + 'periodQuery' => $periodQuery, 'years' => $years, 'municipality' => $municipality, 'municipalities' => $municipalities, 'municipalityCoverage' => StatisticsService::municipalityCoverage(), - 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids), - 'comparison' => $all ? [] : StatisticsService::comparison($year, $ids), + 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::period($start, $end, $ids), + 'comparison' => $comparison, '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), + 'distributions' => StatisticsService::distributions($all ? null : $year, $ids, $municipality ?: null, $start, $end), 'health' => StatisticsService::currentHealth($ids), - 'costs' => StatisticsService::costs($year, $ids), - 'municipalityCosts' => StatisticsService::costsByMunicipality($year), + 'costs' => StatisticsService::costs($all ? null : $year, $ids, $start, $end), + 'municipalityCosts' => StatisticsService::costsByMunicipality($all ? null : $year, $start, $end), ]); } } diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index 724081d..7975fe8 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -4,10 +4,14 @@ declare(strict_types=1); final class StatisticsService { - public static function costs(?int $year, ?array $ids = null): array + public static function costs(?int $year, ?array $ids = null, ?string $start = null, ?string $end = null): array { $db = DB::pdo(); - $scopeIds = self::costScopeIds($year, $ids); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $scopeIds = self::costScopeIds($year, $ids, $start, $end); $empty = [ 'total_cents' => 0, 'animals' => 0, @@ -23,9 +27,9 @@ final class StatisticsService } $where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')']; $params = []; - if ($year !== null) { + if ($start !== null && $end !== null) { $where[] = 'date(ae.occurred_on) BETWEEN :start AND :end'; - $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $params = [':start' => $start, ':end' => $end]; } $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 ' . @@ -63,10 +67,14 @@ final class StatisticsService ]; } - public static function costsByMunicipality(?int $year): array + public static function costsByMunicipality(?int $year, ?string $start = null, ?string $end = null): array { $db = DB::pdo(); - $scopeIds = self::costScopeIds($year, null); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $scopeIds = self::costScopeIds($year, null, $start, $end); if (!$scopeIds) { return []; } @@ -92,9 +100,9 @@ final class StatisticsService } $params = []; $dateSql = ''; - if ($year !== null) { + if ($start !== null && $end !== null) { $dateSql = ' AND date(occurred_on) BETWEEN :start AND :end'; - $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $params = [':start' => $start, ':end' => $end]; } $s = $db->prepare( 'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' . @@ -133,22 +141,24 @@ final class StatisticsService return $out; } - private static function costScopeIds(?int $year, ?array $ids): array + private static function costScopeIds(?int $year, ?array $ids, ?string $start = null, ?string $end = null): 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 { + if ($start === null && $year !== null) { $start = sprintf('%04d-01-01', $year); $end = sprintf('%04d-12-31', $year); + } + if ($start === null || $end === null) { + $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit; + } else { $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]); + $s->execute($start === null || $end === null ? [] : [':start' => $start, ':end' => $end]); return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN)); } @@ -224,64 +234,49 @@ final class StatisticsService 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); - } + return self::period(sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year), $ids); + } + + public static function period(string $start, string $end, ?array $ids = null): array + { $db = DB::pdo(); - $range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $range = [':start' => $start, ':end' => $end]; + $scalar = static function (string $sql) use ($db, $range): float { + $s = $db->prepare($sql); + $s->execute($range); + return (float) ($s->fetchColumn() ?: 0); + }; + $in = self::idSql($ids); + $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); $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" . @@ -303,6 +298,24 @@ final class StatisticsService return $stats; } + public static function comparisonPeriod(string $start, string $end, ?array $ids = null): array + { + $from = new DateTimeImmutable($start); + $to = new DateTimeImmutable($end); + $days = (int) $from->diff($to)->format('%a') + 1; + $previousEnd = $from->modify('-1 day'); + $previousStart = $previousEnd->modify('-' . ($days - 1) . ' days'); + $current = self::period($start, $end, $ids); + $previous = self::period($previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d'), $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 ['rows' => $out,'previous_start' => $previousStart->format('Y-m-d'),'previous_end' => $previousEnd->format('Y-m-d')]; + } + public static function comparison(int $year, ?array $ids = null): array { $current = self::annual($year, $ids); @@ -532,10 +545,10 @@ final class StatisticsService ]; } - private static function details(int $year, array $ids): array + private static function detailsRange(string $start, string $end, ?array $ids): array { $db = DB::pdo(); - $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $p = [':start' => $start, ':end' => $end]; $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') . @@ -561,7 +574,7 @@ final class StatisticsService ); $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 . + self::idSql($ids) . ' GROUP BY cause_code ORDER BY total DESC', ); $stays = $map( @@ -572,10 +585,33 @@ final class StatisticsService return compact('sex', 'ages', 'deathCauses', 'stays'); } - public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array + private static function originsForPeriod(string $start, string $end, ?array $ids): array { $db = DB::pdo(); - $p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $sql = + "SELECT DISTINCT a.id,a.rescue_location_name,a.rescue_address FROM animals a WHERE a.deleted_at IS NULL" . + self::idSql($ids, 'a.id') . + " 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))"; + $stmt = $db->prepare($sql); + $stmt->execute([':start' => $start, ':end' => $end]); + $origins = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])); + $origins[$city] = ($origins[$city] ?? 0) + 1; + } + arsort($origins, SORT_NUMERIC); + return $origins; + } + + public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null, ?string $start = null, ?string $end = null): array + { + $db = DB::pdo(); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $bounded = $start !== null && $end !== null; + $p = $bounded ? [':start' => $start, ':end' => $end] : []; $rows = static function (string $sql, array $params = []) use ($db): array { $s = $db->prepare($sql); $s->execute($params); @@ -585,9 +621,9 @@ final class StatisticsService } 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"; + $movementDate = $bounded ? ' AND date(m.created_at) BETWEEN :start AND :end' : ''; + $animalDate = $bounded ? ' AND date(intake_date) BETWEEN :start AND :end' : ''; + $datedAnimal = $bounded ? " 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 . @@ -627,7 +663,7 @@ final class StatisticsService "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) { + if (!$bounded) { $animalFilter = self::idSql($ids, 'a.id'); $details = []; $details['sex'] = $rows( @@ -651,12 +687,11 @@ final class StatisticsService ' 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']; + $details = self::detailsRange($start, $end, $ids); + $origins = $municipality !== null + ? [$municipality => (int) array_sum($species)] + : self::originsForPeriod($start, $end, $ids); } foreach ($details as &$items) { arsort($items, SORT_NUMERIC); @@ -674,7 +709,7 @@ final class StatisticsService ' GROUP BY label ORDER BY total DESC,label', ); $releaseSql = - $year === null + !$bounded ? "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' diff --git a/app/Views/directory_show.php b/app/Views/directory_show.php index 50bb429..1886f02 100644 --- a/app/Views/directory_show.php +++ b/app/Views/directory_show.php @@ -30,7 +30,7 @@ $animalRows = static function (array$rows, string$title, string$color, string$ic
← ← + href="/statistics? 'all']) + ['municipality' => $expensePeriodMunicipality])) ?>">←
@@ -78,7 +78,7 @@ if ($address): ?>
📍
· - + ·
(int)$expenseCount])) ?> diff --git a/app/Views/evolutions.php b/app/Views/evolutions.php index 5aaa120..11ce46c 100644 --- a/app/Views/evolutions.php +++ b/app/Views/evolutions.php @@ -31,10 +31,10 @@
- 1.0.0-rc.31 -

Parcours de correction plus fiables

-

Liens d’adoptants corrigés, modification des vaccins plus explicite et - synchronisation i-CAD utilisable même sans JavaScript.

+ 1.0.0-rc.32 +

Statistiques sur mesure

+

Bilans par année, par mois ou entre deux dates, avec coûts vétérinaires et + comparaison de la période précédente.

diff --git a/app/Views/statistics.php b/app/Views/statistics.php index bad7f2b..59efc92 100644 --- a/app/Views/statistics.php +++ b/app/Views/statistics.php @@ -54,12 +54,19 @@ $groups = [
-
+ + + +
+
+
+
+
@@ -78,7 +85,7 @@ $groups = [
· - (int)$stats['handled']])) ?> (int)$stats['handled']])) ?>
@@ -104,7 +111,7 @@ $groups = [
- (int)$year]) ?> + $periodLabel])) ?>
@@ -138,7 +145,7 @@ $groups = [

- (int)$r['contact_id'],'year' => $allYears ? 'all' : $year,'municipality' => $municipality]) : null; ?> + (int)$r['contact_id'],'municipality' => $municipality] + $periodQuery) : null; ?>
- $allYears ? 'all' : $year,'municipality' => $row['municipality']]); ?> + $row['municipality']]); ?> @@ -245,7 +252,7 @@ if (empty($costs['by_clinic'])):?>
-

$year - 1])) ?>

+

@@ -254,8 +261,8 @@ if (empty($costs['by_clinic'])):?> - - + + @@ -297,7 +304,7 @@ if (empty($costs['by_clinic'])):?>

(int)$year])) ?> + class="text-muted"> $periodLabel])) ?>
$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]); + $cityUrl = '/statistics?' . http_build_query($periodQuery + ['municipality' => $label]); $filterValue = $linkType === 'sex' ? match ((string)$label) { 'Femelles' => 'F', 'Mâles' => 'M', default => '' } : (string)$label; @@ -339,6 +346,18 @@ if (empty($costs['by_clinic'])):?>
+ diff --git a/docs/08-documents-et-site-public.md b/docs/08-documents-et-site-public.md index 7af8c24..fa1b093 100644 --- a/docs/08-documents-et-site-public.md +++ b/docs/08-documents-et-site-public.md @@ -14,7 +14,7 @@ Le fichier ODT reste modifiable dans une suite bureautique. La version PDF/HTML ## Activer le site public -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. +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. Chaque rubrique peut être repliée pour conserver un écran lisible. Deux modes existent : @@ -32,7 +32,9 @@ Au moins une photo doit être publique pour illustrer correctement la fiche. Les ## 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 menu présente directement les espèces publiées, puis les pages de texte, dons et contact. Le bouton **Ajouter une page** permet de créer autant de pages libres que nécessaire. Renseignez un titre, une adresse courte et le contenu, puis réorganisez les pages par glisser-déposer. Les flèches monter/descendre offrent la même fonction sur écran tactile ou au clavier. Une page peut être masquée sans être supprimée. + +Les zones de texte acceptent les contenus longs et grandissent avec leur contenu. 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. diff --git a/migrations/099_public_custom_pages.sql b/migrations/099_public_custom_pages.sql new file mode 100644 index 0000000..d5934e6 --- /dev/null +++ b/migrations/099_public_custom_pages.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS public_site_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE COLLATE NOCASE, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_public_site_pages_navigation +ON public_site_pages(enabled, sort_order, title); diff --git a/public/index.php b/public/index.php index 42440ad..fce0847 100644 --- a/public/index.php +++ b/public/index.php @@ -16,6 +16,7 @@ require_once __DIR__ . '/../app/Services/BackupService.php'; require_once __DIR__ . '/../app/Services/UpdateService.php'; require_once __DIR__ . '/../app/Services/AppSettings.php'; require_once __DIR__ . '/../app/Services/PublicContentFormatter.php'; +require_once __DIR__ . '/../app/Services/PublicPageService.php'; require_once __DIR__ . '/../app/Services/InstallationService.php'; require_once __DIR__ . '/../app/Services/SystemHealthService.php'; require_once __DIR__ . '/../app/Services/NotificationService.php'; diff --git a/resources/lang/en.php b/resources/lang/en.php index 01298d5..c34c740 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -260,4 +260,20 @@ return [ 'statistics.in_period'=>'during :period', 'statistics.previous_period_comparison'=>'Comparison with the previous period', 'statistics.profiles_help_period'=>'Profiles cover animals admitted during :period, unless stated otherwise.', + 'public_site.custom_pages'=>'Custom pages', + 'public_site.site_pages'=>'Pages and menu order', + 'public_site.site_pages_help'=>'Expand a page to edit it and drag the panels to choose their order in the public menu.', + 'public_site.custom_pages_help'=>'Add as many pages as needed and reorder them with drag and drop or the arrow buttons.', + 'public_site.add_page'=>'Add a page', + 'public_site.new_page'=>'New page', + 'public_site.page_title'=>'Page title', + 'public_site.page_slug'=>'Page address', + 'public_site.page_slug_help'=>'Letters, numbers and hyphens; it is used in the public URL.', + 'public_site.page_published'=>'Show this page on the public website', + 'public_site.delete_page'=>'Delete this page', + 'public_site.drag_page'=>'Drag to reorder', + 'public_site.move_up'=>'Move page up', + 'public_site.move_down'=>'Move page down', + 'public_site.page_invalid'=>'A custom page title or address is invalid or reserved.', + 'public_site.page_slug_duplicate'=>'Two custom pages cannot use the same address.', ]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php index d1a6bca..8c2c47c 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -260,4 +260,20 @@ return [ 'statistics.in_period'=>'sur :period', 'statistics.previous_period_comparison'=>'Comparaison avec la période précédente', 'statistics.profiles_help_period'=>'Les profils concernent les animaux entrés sur la période :period, sauf mention contraire.', + 'public_site.custom_pages'=>'Pages personnalisées', + 'public_site.site_pages'=>'Pages et ordre du menu', + 'public_site.site_pages_help'=>'Dépliez une page pour la modifier et glissez les blocs pour choisir leur ordre dans le menu public.', + 'public_site.custom_pages_help'=>'Ajoutez autant de pages que nécessaire et réorganisez-les par glisser-déposer ou avec les flèches.', + 'public_site.add_page'=>'Ajouter une page', + 'public_site.new_page'=>'Nouvelle page', + 'public_site.page_title'=>'Titre de la page', + 'public_site.page_slug'=>'Adresse de la page', + 'public_site.page_slug_help'=>'Lettres, chiffres et tirets ; elle sera utilisée dans l’adresse publique.', + 'public_site.page_published'=>'Afficher cette page sur le site public', + 'public_site.delete_page'=>'Supprimer cette page', + 'public_site.drag_page'=>'Glisser pour réorganiser', + 'public_site.move_up'=>'Monter la page', + 'public_site.move_down'=>'Descendre la page', + 'public_site.page_invalid'=>'Le titre ou l’adresse d’une page personnalisée est invalide ou réservé.', + 'public_site.page_slug_duplicate'=>'Deux pages personnalisées ne peuvent pas utiliser la même adresse.', ]; diff --git a/tests/run.php b/tests/run.php index 78b73af..fa93560 100644 --- a/tests/run.php +++ b/tests/run.php @@ -34,6 +34,7 @@ foreach ( 'SpeciesService', 'LabReferenceService', 'PublicContentFormatter', + 'PublicPageService', 'ShelterRoomService', 'GlobalSearchService', 'PrivateMediaService', @@ -306,6 +307,25 @@ try { $assert(str_contains($layout, 'rel="noopener noreferrer"'), 'Les liens externes ne sont pas protégés'); $assert(!str_contains($contact, '$socialNetworks = ['), 'Le catalogue social est encore dupliqué'); }); + $test('Les pages publiques personnalisées conservent ordre et textes longs', function () use ($assert): void { + $longText = str_repeat('Une histoire du refuge. ', 1200); + PublicPageService::saveAll([ + ['title' => 'Nos partenaires', 'slug' => 'nos-partenaires', 'content' => 'Partenaires', 'enabled' => '1', 'position' => '2'], + ['title' => 'Notre histoire', 'slug' => 'notre-histoire', 'content' => $longText, 'enabled' => '1', 'position' => '1'], + ]); + $pages = PublicPageService::all(true); + $assert(count($pages) === 2, 'Les pages personnalisées ne sont pas enregistrées'); + $assert($pages[0]['slug'] === 'notre-histoire', 'L’ordre choisi n’est pas conservé'); + $assert($pages[0]['content'] === trim($longText), 'Le texte long a été tronqué'); + $assert(PublicPageService::findBySlug('notre-histoire') !== null, 'La page publiée est introuvable'); + $assert(PublicPageService::normalizeSlug('Équipe & refuge') === 'equipe-refuge', 'L’adresse automatique est invalide'); + $view = file_get_contents(__DIR__ . '/../app/Views/settings_public_site.php'); + $assert(str_contains($view, 'id="public-page-add"'), 'Le bouton d’ajout est absent'); + $assert(str_contains($view, 'draggable="true"'), 'Le glisser-déposer est absent'); + $assert(str_contains($view, 'public-page-up'), 'Le déplacement accessible est absent'); + $controller = file_get_contents(__DIR__ . '/../app/Controllers/PublicSiteController.php'); + $assert(str_contains($controller, "'/site/page/'"), 'La route publique personnalisée est absente'); + }); $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); From 56a2fddc834f04ebc24d12d633c3038eda8f63a9 Mon Sep 17 00:00:00 2001 From: oversu <95a7002b-481b-4ef3-b578-f9c421550aa4@localhost> Date: Mon, 12 Oct 2026 10:59:11 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=C3=89tendre=20le=20Markdown=20du=20site=20?= =?UTF-8?q?public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++ HISTORIQUE.md | 4 + README.md | 2 +- VERSION | 2 +- app/Services/PublicContentFormatter.php | 160 +++++++++++++++++------- app/Views/evolutions.php | 8 +- app/Views/public_site_layout.php | 34 +++++ app/Views/settings_public_site.php | 2 +- docs/08-documents-et-site-public.md | 2 + resources/lang/en.php | 1 + resources/lang/fr.php | 1 + tests/run.php | 9 ++ 12 files changed, 181 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0619f7d..f601345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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.35] — 2026-10-12 + +### Markdown enrichi sur le site public + +- ajout des tableaux Markdown, avec alignement des colonnes et défilement adapté aux mobiles ; +- prise en charge des listes numérotées et à cocher, citations, séparateurs, blocs de code, code en ligne et texte barré ; +- ajout d’un bouton insérant un modèle de tableau dans l’éditeur ; +- maintien de l’échappement du HTML brut, des protocoles de liens autorisés et des images limitées à la médiathèque publique ; +- tests de non-régression couvrant le rendu enrichi et les tentatives d’injection. + ## [1.0.0-rc.34] — 2026-10-12 ### Pages libres et navigation du site public diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 222bee8..5b92140 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden 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.35` — Des contenus publics réellement structurés + +Le moteur Markdown léger apprend à présenter tableaux, listes numérotées ou à cocher, citations, code et séparateurs sans introduire de dépendance externe. Les tableaux restent lisibles sur smartphone et le HTML non fiable demeure neutralisé. + ## `1.0.0-rc.34` — Un site public librement structuré Les associations peuvent désormais créer autant de pages éditoriales que nécessaire, les publier dans la navigation et choisir leur ordre par glisser-déposer. L’écran d’édition adopte des panneaux repliables et les contenus longs ne sont plus tronqués par Globinours. diff --git a/README.md b/README.md index 58ed471..64d813f 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L ## État du projet -La candidate actuelle est `1.0.0-rc.34`. Le site public accepte désormais des pages personnalisées, réordonnables et sans troncature applicative du contenu. La branche 1.0 reste en stabilisation avant `1.0.0`. +La candidate actuelle est `1.0.0-rc.35`. Le site public accepte des pages personnalisées réordonnables et un Markdown enrichi, notamment les tableaux responsives. La branche 1.0 reste en stabilisation avant `1.0.0`. Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION index 0f0d470..63f99b9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.34 +1.0.0-rc.35 diff --git a/app/Services/PublicContentFormatter.php b/app/Services/PublicContentFormatter.php index ae4bb1a..0736b4a 100644 --- a/app/Services/PublicContentFormatter.php +++ b/app/Services/PublicContentFormatter.php @@ -2,77 +2,143 @@ declare(strict_types=1); +/** Lightweight Markdown renderer. Raw HTML is escaped and images stay local. */ final class PublicContentFormatter { public static function render(string $text): string { $lines = preg_split('/\R/u', trim($text)) ?: []; $html = []; - $listOpen = false; + $count = count($lines); + for ($i = 0; $i < $count;) { + $line = rtrim($lines[$i]); + if (trim($line) === '') { $i++; continue; } - foreach ($lines as $line) { - $line = trim($line); - if ($line === '') { - if ($listOpen) { - $html[] = ''; - $listOpen = false; + if (preg_match('/^\s*```\s*([A-Za-z0-9_-]*)\s*$/u', $line, $fence)) { + $language = strtolower($fence[1]); + $code = []; + for ($i++; $i < $count && !preg_match('/^\s*```\s*$/u', $lines[$i]); $i++) $code[] = $lines[$i]; + if ($i < $count) $i++; + $class = $language !== '' ? ' class="language-' . htmlspecialchars($language, ENT_QUOTES, 'UTF-8') . '"' : ''; + $html[] = '
' . htmlspecialchars(implode("\n", $code), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + continue; + } + + if ($i + 1 < $count && self::isTableRow($line) && self::isTableSeparator($lines[$i + 1])) { + $headers = self::tableCells($line); + $alignments = array_map([self::class, 'tableAlignment'], self::tableCells($lines[$i + 1])); + $rows = []; + for ($i += 2; $i < $count && self::isTableRow($lines[$i]) && trim($lines[$i]) !== ''; $i++) $rows[] = self::tableCells($lines[$i]); + $table = '
'; + foreach ($headers as $column => $header) { + $align = $alignments[$column] ?? ''; + $table .= '' . self::inline($header) . ''; } - continue; - } - if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) { - if (!$listOpen) { - $html[] = ''; + foreach ($rows as $row) { + $table .= ''; + foreach ($headers as $column => $_) { + $align = $alignments[$column] ?? ''; + $table .= '' . self::inline($row[$column] ?? '') . ''; + } + $table .= ''; } - $html[] = '
  • ' . self::inline($match[1]) . '
  • '; + $html[] = $table . '
    '; continue; } - if ($listOpen) { - $html[] = ''; - $listOpen = false; - } - if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) { - $level = strlen($match[1]) + 1; - $html[] = '' . self::inline($match[2]) . ''; + + if (preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line)) { $html[] = '
    '; $i++; continue; } + if (preg_match('/^(#{1,6})\s+(.+?)\s*#*$/u', trim($line), $heading)) { + $level = min(6, strlen($heading[1]) + 1); + $html[] = '' . self::inline($heading[2]) . ''; + $i++; continue; } - $html[] = '

    ' . self::inline($line) . '

    '; - } - if ($listOpen) { - $html[] = ''; + if (preg_match('/^\s*>\s?(.*)$/u', $line)) { + $quote = []; + while ($i < $count && preg_match('/^\s*>\s?(.*)$/u', $lines[$i], $match)) { $quote[] = $match[1]; $i++; } + $html[] = '
    ' . self::render(implode("\n", $quote)) . '
    '; + continue; + } + if (preg_match('/^\s*(?:[-+*]|\d+[.)])\s+(.+)$/u', $line)) { + $ordered = (bool) preg_match('/^\s*\d+[.)]\s+/u', $line); + $tag = $ordered ? 'ol' : 'ul'; + $list = '<' . $tag . '>'; + while ($i < $count) { + $pattern = $ordered ? '/^\s*\d+[.)]\s+(.+)$/u' : '/^\s*[-+*]\s+(.+)$/u'; + if (!preg_match($pattern, $lines[$i], $item)) break; + $value = $item[1]; + $taskClass = ''; + if (preg_match('/^\[([ xX])\]\s+(.+)$/u', $value, $task)) { + $taskClass = ' class="task-list-item"'; + $value = ' ' . self::inline($task[2]); + } else $value = self::inline($value); + $list .= '' . $value . ''; + $i++; + } + $html[] = $list . ''; + continue; + } + + $paragraph = [trim($line)]; + for ($i++; $i < $count && trim($lines[$i]) !== '' && !self::startsBlock($lines, $i); $i++) $paragraph[] = trim($lines[$i]); + $html[] = '

    ' . self::inline(implode(' ', $paragraph)) . '

    '; } return implode("\n", $html); } + private static function startsBlock(array $lines, int $index): bool + { + $line = rtrim((string) ($lines[$index] ?? '')); + return preg_match('/^\s*(?:```|#{1,6}\s+|>\s?|[-+*]\s+|\d+[.)]\s+)/u', $line) === 1 + || preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line) === 1 + || ($index + 1 < count($lines) && self::isTableRow($line) && self::isTableSeparator($lines[$index + 1])); + } + + private static function isTableRow(string $line): bool { return str_contains(trim($line), '|'); } + private static function isTableSeparator(string $line): bool + { + $cells = self::tableCells($line); + return $cells !== [] && count(array_filter($cells, static fn (string $cell): bool => preg_match('/^:?-{3,}:?$/', trim($cell)) === 1)) === count($cells); + } + private static function tableCells(string $line): array + { + $line = trim($line); + if (str_starts_with($line, '|')) $line = substr($line, 1); + if (str_ends_with($line, '|') && !str_ends_with($line, '\\|')) $line = substr($line, 0, -1); + $marker = "\x1D"; + return array_map(static fn (string $cell): string => str_replace($marker, '|', trim($cell)), explode('|', str_replace('\\|', $marker, $line))); + } + private static function tableAlignment(string $separator): string + { + $separator = trim($separator); + if (str_starts_with($separator, ':') && str_ends_with($separator, ':')) return 'center'; + if (str_ends_with($separator, ':')) return 'right'; + return str_starts_with($separator, ':') ? 'left' : ''; + } + private static function inline(string $text): string { - $images = []; - $text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)\)/u', static function (array $match) use (&$images): string { - $token = "\x1B" . count($images) . "\x1B"; - $images[] = '' .
-                htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; - return $token; - }, $text) ?? $text; - $links = []; - $text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/u', static function (array $match) use (&$links): string { - if (!filter_var($match[2], FILTER_VALIDATE_URL)) { - return $match[0]; - } - $token = "\x1A" . count($links) . "\x1A"; - $links[] = '' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + $tokens = []; + $reserve = static function (string $html) use (&$tokens): string { + $token = "\x1A" . count($tokens) . "\x1A"; + $tokens[] = $html; return $token; + }; + $text = preg_replace_callback('/`([^`]+)`/u', static fn (array $match): string => $reserve('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''), $text) ?? $text; + $text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)(?:\s+["\']([^"\']*)["\'])?\)/u', static fn (array $match): string => $reserve('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''), $text) ?? $text; + $text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+|\/[^\s)]*)\)/u', static function (array $match) use ($reserve): string { + $url = $match[2]; + if (str_starts_with($url, 'https://') && !filter_var($url, FILTER_VALIDATE_URL)) return $match[0]; + return $reserve('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''); }, $text) ?? $text; $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $text = preg_replace('/~~(.+?)~~/u', '$1', $text) ?? $text; $text = preg_replace('/\*\*(.+?)\*\*/u', '$1', $text) ?? $text; + $text = preg_replace('/__(.+?)__/u', '$1', $text) ?? $text; $text = preg_replace('/(?$1', $text) ?? $text; - foreach ($links as $index => $link) { - $text = str_replace("\x1A" . $index . "\x1A", $link, $text); - } - foreach ($images as $index => $image) { - $text = str_replace("\x1B" . $index . "\x1B", $image, $text); - } + $text = preg_replace('/(?$1', $text) ?? $text; + foreach ($tokens as $index => $html) $text = str_replace("\x1A" . $index . "\x1A", $html, $text); return $text; } } diff --git a/app/Views/evolutions.php b/app/Views/evolutions.php index 553c8ee..cbb4341 100644 --- a/app/Views/evolutions.php +++ b/app/Views/evolutions.php @@ -31,10 +31,10 @@
    - 1.0.0-rc.34 -

    Un site public qui s’adapte au refuge

    -

    Pages personnalisées, contenus longs, panneaux repliables et ordre du menu - réorganisable rendent chaque site aussi simple ou aussi complet que nécessaire.

    + 1.0.0-rc.35 +

    Des pages publiques riches et lisibles

    +

    Les pages personnalisées accueillent maintenant tableaux responsives, listes, + citations et code tout en conservant un rendu strictement sécurisé.

    diff --git a/app/Views/public_site_layout.php b/app/Views/public_site_layout.php index 96c8657..c56113b 100644 --- a/app/Views/public_site_layout.php +++ b/app/Views/public_site_layout.php @@ -265,6 +265,40 @@ if (!function_exists('public_age')) { border-radius: var(--radius) } + .public-markdown-table { + max-width: 100%; + margin: 1.25rem 0; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: calc(var(--radius) / 2) + } + + .public-markdown-table table { + width: 100%; + min-width: 420px; + border-collapse: collapse; + background: var(--surface) + } + + .public-markdown-table th, + .public-markdown-table td { + padding: 11px 14px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top + } + + .public-markdown-table th { background: var(--paper); font-weight: 800 } + .public-markdown-table tr:last-child td { border-bottom: 0 } + .public-markdown-table .text-center { text-align: center } + .public-markdown-table .text-right { text-align: right } + blockquote { margin: 1.25rem 0; padding: .75rem 1rem; border-left: 4px solid var(--accent); background: var(--surface) } + blockquote p { margin: 0 } + pre { max-width: 100%; overflow: auto; padding: 1rem; border-radius: calc(var(--radius) / 2); background: var(--ink); color: var(--surface) } + :not(pre)>code { padding: .12rem .35rem; border-radius: 4px; background: var(--line) } + .task-list-item { list-style: none } + .task-marker { color: var(--accent-dark) } + .section { padding: 52px 0 } diff --git a/app/Views/settings_public_site.php b/app/Views/settings_public_site.php index 3527bf5..134a571 100644 --- a/app/Views/settings_public_site.php +++ b/app/Views/settings_public_site.php @@ -238,7 +238,7 @@ const enhancePublicEditor = (textarea) => { textarea.addEventListener('focus', () => { activePublicEditor = textarea; }); const toolbar = document.createElement('div'); toolbar.className = 'btn-list mb-2'; - const actions = [['B', '**', '**'], ['I', '*', '*'], ['Titre', '## ', ''], ['Liste', '- ', ''], ['Lien', '[', '](https://)']]; + const actions = [['B', '**', '**'], ['I', '*', '*'], ['Titre', '## ', ''], ['Liste', '- ', ''], ['Lien', '[', '](https://)'], [, '| Colonne 1 | Colonne 2 |\n| --- | --- |\n| Valeur 1 | Valeur 2 |\n', '']]; actions.forEach(([label, before, after]) => { const button = document.createElement('button'); button.type = 'button'; diff --git a/docs/08-documents-et-site-public.md b/docs/08-documents-et-site-public.md index fa1b093..633086c 100644 --- a/docs/08-documents-et-site-public.md +++ b/docs/08-documents-et-site-public.md @@ -36,6 +36,8 @@ Le menu présente directement les espèces publiées, puis les pages de texte, d Les zones de texte acceptent les contenus longs et grandissent avec leur contenu. 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 contenu accepte les principaux éléments Markdown utiles à une page publique : titres, paragraphes, gras, italique, texte barré, liens HTTPS ou internes, images de la médiathèque, listes simples, numérotées ou à cocher, citations, séparateurs, code et tableaux avec alignement. Le bouton **Tableau** insère une structure prête à compléter. Les tableaux deviennent défilables horizontalement sur les petits écrans. Le HTML brut reste volontairement neutralisé pour protéger le site. + 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é diff --git a/resources/lang/en.php b/resources/lang/en.php index c34c740..f0efb00 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -276,4 +276,5 @@ return [ 'public_site.move_down'=>'Move page down', 'public_site.page_invalid'=>'A custom page title or address is invalid or reserved.', 'public_site.page_slug_duplicate'=>'Two custom pages cannot use the same address.', + 'public_site.markdown_table'=>'Table', ]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php index 8c2c47c..a7418d6 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -276,4 +276,5 @@ return [ 'public_site.move_down'=>'Descendre la page', 'public_site.page_invalid'=>'Le titre ou l’adresse d’une page personnalisée est invalide ou réservé.', 'public_site.page_slug_duplicate'=>'Deux pages personnalisées ne peuvent pas utiliser la même adresse.', + 'public_site.markdown_table'=>'Tableau', ]; diff --git a/tests/run.php b/tests/run.php index fa93560..7afdb7b 100644 --- a/tests/run.php +++ b/tests/run.php @@ -296,6 +296,15 @@ try { $assert(str_contains($image, ' Important\n\n`\n```"); + $assert(!str_contains($fenced, '