diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc5dc9..f601345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,93 @@ 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 + +- ajout de pages publiques personnalisées en nombre libre depuis les paramètres ; +- publication automatique dans le menu avec une adresse lisible et compatible avec les deux modes de routage ; +- réorganisation par glisser-déposer, complétée par des boutons monter/descendre utilisables sur mobile et au clavier ; +- présentation repliable des pages Accueil, Présentation, Dons, Contact et Mentions légales afin d’alléger l’écran ; +- suppression des troncatures applicatives sur les textes du site public et redimensionnement automatique des zones de saisie ; +- ajout, modification, masquage et suppression contrôlée des pages personnalisées. + +## [1.0.0-rc.33] — 2026-10-11 + +### Réseaux sociaux dans le menu public + +- ajout des icônes des réseaux configurés immédiatement après le lien Contact ; +- masquage automatique des réseaux sans adresse renseignée ; +- ouverture sécurisée dans un nouvel onglet et libellés accessibles aux lecteurs d’écran ; +- réutilisation d’un catalogue unique entre le menu et la page Contact. + +## [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 + +- correction du lien ouvrant la fiche d’un adoptant depuis la fiche animale ; +- ajout d’une colonne « Actions » et d’un bouton « Modifier » explicite dans la liste des vaccins ; +- transformation du rafraîchissement i-CAD en formulaire utilisable même sans JavaScript ; +- contrôle des erreurs HTTP et maintien de l’actualisation dynamique de la fiche i-CAD ; +- validation réelle du connecteur avec une fiche i-CAD, complétée par des tests de non-régression. + +## [1.0.0-rc.30] — 2026-10-01 + +### Fiabilisation des dossiers et du suivi médical + +- correction de la fausse déclaration de décès de Lucci après vérification ASM3 et protection contre les fiches de décès résiduelles lors d’une correction de statut ; +- réparation de l’actualisation I-CAD après la suppression de l’ancienne adresse `/pro` ; +- exclusion uniforme des animaux adoptés dans les rappels et traitements du tableau de bord ; +- affichage de la date du test FIV/FeLV et possibilité d’en comptabiliser le tarif ; +- ajout direct d’un vaccin depuis sa liste, fabricants suggérés et tri alphabétique ; +- filtrage des tarifs par clinique pour les vaccins, tests et chirurgies ; +- propositions normalisées d’intitulés chirurgicaux et catalogue médical administrable ; +- lien direct entre l’adoptant et sa fiche d’annuaire, ainsi que de nouveaux rôles de professionnels animaliers ; +- amélioration du menu mobile, des zones de texte du site public et des longues listes de rappels ; +- ajout d’un script protégé permettant de réinitialiser automatiquement une instance de démonstration à heure fixe ; +- nouvelle icône Android issue du logo officiel de l’application. + +## [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 + +- possibilité de modifier une vaccination ou une administration de vermifuge / anti-puce directement depuis la fiche animale ; +- préremplissage du formulaire et contrôle de l’appartenance de l’entrée à l’animal ; +- absence de nouveau déstockage, de nouvelle dépense ou d’application à la portée pendant une correction ; +- ajout d’une trace de correction dans l’historique ; +- harmonisation du libellé « Vermifuge / anti-puce » dans toute l’interface ; +- sélection de « Chat Européen » comme race initiale lors de la création d’une fiche de chat. + ## [1.0.0-rc.27] — 2026-09-15 ### Recherche globale des animaux diff --git a/HISTORIQUE.md b/HISTORIQUE.md index f228dfa..5b92140 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,38 @@ 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. + +## `1.0.0-rc.33` — Réseaux visibles dès la navigation + +Le site public reprend désormais dans son menu les réseaux sociaux renseignés par l’association. Les icônes restent discrètes, accessibles et cohérentes avec les liens déjà présentés sur la page Contact. + +## `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. + +## `1.0.0-rc.30` — Corrections issues de la validation terrain + +Une passe croisée avec les sauvegardes ASM3 a corrigé une collision historique entre Gribouille et Lucci. Le suivi médical gagne une date de test FIV/FeLV, des catalogues administrables et une meilleure association entre tarifs et cliniques. La synchronisation I-CAD, la navigation mobile et l’identité Android ont également été adaptées aux conditions réelles d’utilisation. + +## `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. + ## `1.0.0-rc.27` — Recherche tolérante La recherche globale retrouve les animaux par leur numéro de puce ou de tatouage et rapproche les termes indépendamment de leur casse et de leurs accents. diff --git a/README.md b/README.md index 7b6120d..64d813f 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.27`. 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.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 d2e3cec..63f99b9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.27 +1.0.0-rc.35 diff --git a/app/Controllers/AnimalBrowseActions.php b/app/Controllers/AnimalBrowseActions.php index bc14a65..3d4c912 100644 --- a/app/Controllers/AnimalBrowseActions.php +++ b/app/Controllers/AnimalBrowseActions.php @@ -518,7 +518,7 @@ trait AnimalBrowseActions // Adoptions $stmt = $db->prepare(' - SELECT adopter_name, adopter_address, adopter_postal_code, adopter_city, adopter_country, adopter_phone, adopter_email, adoption_date + SELECT adopter_name, adopter_address, adopter_postal_code, adopter_city, adopter_country, adopter_phone, adopter_email, adoption_date, adopter_contact_id FROM adoptions WHERE animal_id = :id ORDER BY id DESC @@ -650,7 +650,10 @@ trait AnimalBrowseActions $ref_med_administration = $db ->query('SELECT id, name, accro FROM ref_med_administration ORDER BY id') ->fetchAll(); - $vaccines = $db->query('SELECT id, name FROM ref_vaccines ORDER BY id')->fetchAll(); + $vaccines = $db->query('SELECT id, name FROM ref_vaccines ORDER BY name COLLATE NOCASE,id')->fetchAll(); + $vaccineManufacturers = $db + ->query('SELECT id,name FROM ref_vaccine_manufacturers WHERE active=1 ORDER BY name COLLATE NOCASE,id') + ->fetchAll(PDO::FETCH_ASSOC); $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)', @@ -958,6 +961,7 @@ trait AnimalBrowseActions 'surgeries' => $surgeries, 'ref_med_administration' => $ref_med_administration, 'vaccines' => $vaccines, + 'vaccineManufacturers' => $vaccineManufacturers, 'inventoryBatches' => $inventoryBatches, 'activeUsers' => $activeUsers, 'clinics' => $clinics, diff --git a/app/Controllers/AnimalMedicalActions.php b/app/Controllers/AnimalMedicalActions.php index 3f8a04e..8564d5f 100644 --- a/app/Controllers/AnimalMedicalActions.php +++ b/app/Controllers/AnimalMedicalActions.php @@ -32,6 +32,8 @@ trait AnimalMedicalActions $testDate = date('Y-m-d'); } $testNotes = trim((string) ($_POST['test_notes'] ?? '')); + $testTariffId = (int) ($_POST['test_tariff_id'] ?? 0); + $testClinicId = (int) ($_POST['clinic_contact_id'] ?? 0); $conditions = is_array($_POST['conditions'] ?? null) ? $_POST['conditions'] : []; $db = DB::pdo(); @@ -53,6 +55,7 @@ trait AnimalMedicalActions UPDATE animals SET fiv_status = :fiv_status, felv_status = :felv_status, + fiv_felv_test_date = :test_date, fiv = :fiv, felv = :felv, updated_at = datetime('now') @@ -61,6 +64,7 @@ trait AnimalMedicalActions )->execute([ ':fiv_status' => $fivStatus, ':felv_status' => $felvStatus, + ':test_date' => $testDate, ':fiv' => $fivStatus === 'pos' ? 1 : 0, ':felv' => $felvStatus === 'pos' ? 1 : 0, ':id' => $animalId, @@ -98,6 +102,15 @@ trait AnimalMedicalActions } } + if ($testTariffId > 0) { + $tariffCheck = $db->prepare("SELECT 1 FROM clinic_tariffs WHERE id=? AND clinic_contact_id=? AND category='test' AND active=1"); + $tariffCheck->execute([$testTariffId, $testClinicId]); + if (!$testClinicId || !$tariffCheck->fetchColumn()) { + throw new RuntimeException(t('error.tariff_not_found')); + } + PricingService::record($db, $animalId, $testTariffId, 1, $testDate, $testNotes); + } + $conditionSelect = $db->prepare( 'SELECT status FROM animal_health_conditions WHERE id = :id AND animal_id = :animal_id', ); @@ -400,6 +413,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 +443,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 +498,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 +512,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" : ''), @@ -562,15 +596,30 @@ trait AnimalMedicalActions $db = DB::pdo(); $id = (int) ($_POST['animal_id'] ?? 0); + $entryId = (int) ($_POST['entry_id'] ?? 0); $vacId = (int) ($_POST['vaccine_id'] ?? 0); if ($id <= 0 || $vacId <= 0) { http_response_code(400); echo h(t('error.bad_request')); return; } + if ($entryId > 0) { + $stmt = $db->prepare('SELECT 1 FROM vaccinations WHERE id=:entry AND animal_id=:animal'); + $stmt->execute([':entry' => $entryId, ':animal' => $id]); + if (!$stmt->fetchColumn()) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + } $done = trim((string) ($_POST['done_date'] ?? date('Y-m-d'))); $due = trim((string) ($_POST['due_date'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $done) || ($due !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $due))) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } // Si due_date est fourni, on respecte. // Sinon auto selon protocole. if ($due === '') { @@ -602,9 +651,9 @@ trait AnimalMedicalActions 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' + WHERE v.animal_id = :aid AND v.id <> :entry AND upper(coalesce(rv.family,'')) = 'L' "); - $stmt->execute([':aid' => $id]); + $stmt->execute([':aid' => $id, ':entry' => $entryId]); $countL = (int) $stmt->fetchColumn(); $isPrimoL = $countL === 0; } @@ -658,11 +707,7 @@ trait AnimalMedicalActions } $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, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) - VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) - '); - $stmt->execute([ + $values = [ ':aid' => $id, ':vid' => $vacId, ':done' => $done, @@ -676,21 +721,38 @@ trait AnimalMedicalActions ':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'); + ]; + if ($entryId > 0) { + unset($values[':aid'], $values[':created_by']); + $values[':entry'] = $entryId; + $stmt = $db->prepare(' + UPDATE vaccinations SET vaccine_id=:vid,done_date=:done,due_date=:due,lot=:lot, + manufacturer=:manufacturer,batch_expires_on=:batch_expiry,notes=:notes,clinic_id=:clinic_id, + vet_name=:vet_name,clinic_contact_id=:clinic_contact_id,vet_contact_id=:vet_contact_id + WHERE id=:entry + '); + $stmt->execute($values); + } else { + $stmt = $db->prepare(' + INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) + VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) + '); + $stmt->execute($values); + 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é', + $entryId > 0 ? 'Vaccin corrigé' : 'Vaccin ajouté', "Vaccin #$vacId\nFait le : $done" . ($due !== '' ? "\nRappel : $due" : '') . ($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') . diff --git a/app/Controllers/AnimalRecordActions.php b/app/Controllers/AnimalRecordActions.php index 462fb8b..187e74e 100644 --- a/app/Controllers/AnimalRecordActions.php +++ b/app/Controllers/AnimalRecordActions.php @@ -182,6 +182,7 @@ trait AnimalRecordActions 'adoption_unavailability_reason' => '', 'website_published' => 0, 'website_description' => '', + 'breed' => 'Chat Européen', 'color' => '', 'hair_type' => '', 'birth_date' => '', @@ -745,6 +746,18 @@ trait AnimalRecordActions ':current_lng' => $currentLng, ]); + // Un décès est une donnée structurée distincte du statut. Lors d'une correction + // explicite d'un animal déclaré à tort décédé, ne pas laisser une ancienne fiche + // de décès continuer à alimenter la bannière et les statistiques. + $oldWasDeceased = in_array(strtolower((string) ($old['status'] ?? '')), ['decede', 'décédé'], true); + $isNowDeceased = in_array(strtolower($status), ['decede', 'décédé'], true); + if ($oldWasDeceased && !$isNowDeceased) { + $db->prepare('DELETE FROM animal_deaths WHERE animal_id=:id')->execute([':id' => $id]); + $db->prepare( + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(:id,'death_correction','update','Décès annulé','La fiche de décès a été retirée après correction du statut de l’animal.',:user)", + )->execute([':id' => $id, ':user' => Auth::id()]); + } + if ($old) { LocationHistoryService::record( $db, diff --git a/app/Controllers/AnimalsController.php b/app/Controllers/AnimalsController.php index 209c6dd..adaec7c 100644 --- a/app/Controllers/AnimalsController.php +++ b/app/Controllers/AnimalsController.php @@ -448,6 +448,7 @@ final class AnimalsController } $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); + $wantsJson = str_contains(strtolower((string) ($_SERVER['HTTP_ACCEPT'] ?? '')), 'application/json'); if ($id <= 0) { http_response_code(400); echo h(t('error.bad_request')); @@ -468,6 +469,10 @@ final class AnimalsController $chipId = preg_replace('/\D+/', '', (string) ($animal['chip_id'] ?? '')); if ($chipId === '') { + if (!$wantsJson) { + header('Location: /animal?id=' . $id . '#tab-ident'); + return; + } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['cached' => null, 'stale' => true, 'error' => 'chip_id vide'], JSON_UNESCAPED_UNICODE); return; @@ -489,6 +494,10 @@ final class AnimalsController $stale = $svc->isStale($cached, 86400); } + if (!$wantsJson) { + header('Location: /animal?id=' . $id . '#tab-ident'); + return; + } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['cached' => $cached, 'stale' => $stale], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index 098b7b6..08ed945 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -119,7 +119,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') AND date(v.due_date) < date('now') ORDER BY date(v.due_date) ASC LIMIT 50 @@ -135,7 +135,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') AND date(v.due_date) >= date('now') AND date(v.due_date) <= date('now', '+7 day') ORDER BY date(v.due_date) ASC @@ -152,7 +152,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') 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 @@ -162,7 +162,7 @@ final class DashboardController $vaccines_30d = $stmt->fetchAll(); // Traitements en cours - $stmt = $db->prepare(' + $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 @@ -171,9 +171,10 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') ORDER BY date(t.start_date) DESC, a.name ASC LIMIT 50 - '); + "); $stmt->execute(); $treatments = $stmt->fetchAll(); @@ -185,6 +186,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') AND t.end_date IS NOT NULL AND date(t.end_date) < date('now') ORDER BY date(t.end_date) ASC diff --git a/app/Controllers/DewormingController.php b/app/Controllers/DewormingController.php index 3ce8082..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') { @@ -17,6 +64,7 @@ final class DewormingController } $db = DB::pdo(); $animalId = (int) ($_POST['animal_id'] ?? 0); + $entryId = (int) ($_POST['entry_id'] ?? 0); $date = trim((string) ($_POST['administered_on'] ?? '')); $dewormer = (int) ($_POST['dewormer_id'] ?? 0); if (!$animalId || !self::date($date)) { @@ -31,6 +79,15 @@ final class DewormingController echo h(t('error.animal_not_found')); return; } + if ($entryId > 0) { + $s = $db->prepare('SELECT 1 FROM dewormings WHERE id=? AND animal_id=?'); + $s->execute([$entryId, $animalId]); + if (!$s->fetchColumn()) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + } if (isset($_POST['create_dewormer'])) { $name = trim((string) ($_POST['new_dewormer_name'] ?? '')); if ($name === '') { @@ -59,7 +116,7 @@ final class DewormingController } $ids = [$animalId]; $litterId = null; - if (isset($_POST['apply_litter'])) { + if ($entryId === 0 && 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', ); @@ -85,8 +142,36 @@ final class DewormingController '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,action,type,label,details,user_id) VALUES(?,'deworming','deworming','Vermifuge administré',?,?)", + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(?,'deworming','deworming','Vermifuge / anti-puce administré',?,?)", ); + if ($entryId > 0) { + $s = $db->prepare('SELECT birth_date FROM animals WHERE id=?'); + $s->execute([$animalId]); + $birthDate = $s->fetchColumn() ?: null; + $next = $manual ?: self::nextDue(is_string($birthDate) ? $birthDate : null, $date); + $db->prepare( + "UPDATE dewormings SET dewormer_id=?,administered_on=?,dose_text=?,weight_kg=?,next_due_date=?,notes=?,updated_at=datetime('now') WHERE id=? AND animal_id=?", + )->execute([ + $dewormer ?: null, + $date, + trim((string) ($_POST['dose_text'] ?? '')) ?: null, + ($_POST['weight_kg'] ?? '') !== '' ? (float) $_POST['weight_kg'] : null, + $next, + trim((string) ($_POST['notes'] ?? '')) ?: null, + $entryId, + $animalId, + ]); + $db->prepare( + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(?,'deworming_updated','deworming','Vermifuge / anti-puce corrigé',?,?)", + )->execute([ + $animalId, + 'Administration : ' . self::fr($date) . ($next ? ' · Prochaine échéance : ' . self::fr($next) : ''), + Auth::id(), + ]); + $db->commit(); + header('Location: /animal?id=' . $animalId . '#tab-med'); + exit(); + } foreach ($ids as $id) { $s = $db->prepare('SELECT name,birth_date FROM animals WHERE id=? AND deleted_at IS NULL'); $s->execute([$id]); @@ -117,7 +202,7 @@ final class DewormingController (int) ($_POST['inventory_batch_id'] ?? 0), $perAnimal * count($ids), $animalId, - 'Vermifuge du ' . $date . ($litterId ? ' · portée #' . $litterId : ''), + 'Vermifuge / anti-puce du ' . $date . ($litterId ? ' · portée #' . $litterId : ''), ); $db->commit(); header('Location: /animal?id=' . $animalId . '#tab-med'); diff --git a/app/Controllers/DirectoryController.php b/app/Controllers/DirectoryController.php index b52389f..1c101c7 100644 --- a/app/Controllers/DirectoryController.php +++ b/app/Controllers/DirectoryController.php @@ -15,6 +15,10 @@ final class DirectoryController 'dirigeant' => t('role.dirigeant'), 'fa' => t('role.fa'), 'fourriere' => t('role.fourriere'), + 'pet_sitter' => t('role.pet_sitter'), + 'refuge_partenaire' => t('role.refuge_partenaire'), + 'toiletteur' => t('role.toiletteur'), + 'professionnel_autre' => t('role.professionnel_autre'), 'veterinaire' => t('role.veterinaire'), ]; } @@ -91,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 ' . @@ -125,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 ' . @@ -158,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/PublicSiteController.php b/app/Controllers/PublicSiteController.php index 111f590..9b80755 100644 --- a/app/Controllers/PublicSiteController.php +++ b/app/Controllers/PublicSiteController.php @@ -13,8 +13,8 @@ final class PublicSiteController if ($path === '/site' || str_starts_with($path, '/site/')) { return true; } - return self::usesRoot() && - in_array( + if (!self::usesRoot()) return false; + if (in_array( $path, [ '/', @@ -28,7 +28,9 @@ final class PublicSiteController '/logo-association', ], true, - ); + )) return true; + return (bool) preg_match('#^/[a-z0-9-]+$#', $path) + && PublicPageService::findBySlug(trim($path, '/')) !== null; } public static function url(string $page = 'home', array $query = []): string { @@ -43,8 +45,10 @@ final class PublicSiteController 'legal' => $root ? '/mentions-legales' : '/site/mentions-legales', 'privacy' => $root ? '/confidentialite' : '/site/confidentialite', 'logo' => $root ? '/logo-association' : '/site/logo', + 'custom' => ($root ? '/' : '/site/page/') . PublicPageService::normalizeSlug((string) ($query['slug'] ?? '')), default => $root ? '/' : '/site', }; + unset($query['slug']); return $path . ($query ? '?' . http_build_query($query) : ''); } public static function dispatch(string $path): void @@ -59,6 +63,10 @@ final class PublicSiteController self::logo(); return; } + if (preg_match('#^/site/page/([a-z0-9-]+)$#', $path, $match)) { + self::customPage($match[1]); + return; + } match ($path) { '/site', '/site/' => self::home(), '/site/animaux' => self::animals(), @@ -73,7 +81,7 @@ final class PublicSiteController } private static function normalize(string $path): string { - return match ($path) { + $normalized = match ($path) { '/' => '/site', '/animaux' => '/site/animaux', '/adoption' => '/site/animal', @@ -85,6 +93,11 @@ final class PublicSiteController '/logo-association' => '/site/logo', default => $path, }; + if ($normalized === $path && self::usesRoot() && preg_match('#^/([a-z0-9-]+)$#', $path, $match) + && PublicPageService::findBySlug($match[1]) !== null) { + return '/site/page/' . $match[1]; + } + return $normalized; } private static function home(): void { @@ -160,6 +173,20 @@ final class PublicSiteController 'kind' => 'about', ]); } + private static function customPage(string $slug): void + { + $page = PublicPageService::findBySlug($slug); + if (!$page) { + self::notFound(); + return; + } + self::render('public_site_text.php', [ + 'title' => $page['title'], + 'content' => $page['content'], + 'kind' => 'custom', + 'customPage' => $page, + ]); + } private static function legal(): void { self::render('public_site_text.php', [ @@ -262,6 +289,7 @@ final class PublicSiteController extract($vars, EXTR_SKIP); $settings = AppSettings::all(); $publicSpecies = self::species(); + $publicPages = PublicPageService::all(true); require dirname(__DIR__) . '/Views/public_site_layout.php'; } private static function notFound(): void diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 35d2ff4..f7d3e0d 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -759,6 +759,7 @@ final class SettingsController 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), 'images' => $images, + 'customPages' => PublicPageService::all(), ]); } @@ -869,22 +870,30 @@ final class SettingsController 'public_site_routing' => $routing, 'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0', ]; - foreach ( - [ - 'public_site_home_title' => 150, - 'public_site_name' => 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); + $navigation = json_decode((string) ($_POST['public_site_navigation_order'] ?? '[]'), true); + $navigation = is_array($navigation) ? array_values(array_unique(array_filter($navigation, static function ($item): bool { + if (!is_string($item)) return false; + return in_array($item, ['home','animals','about','donation','contact'], true) + || (bool) preg_match('/^custom:[a-z0-9-]{1,80}$/', $item); + }))) : []; + $submittedPageSlugs = []; + foreach ((array) ($_POST['pages'] ?? []) as $submittedPage) { + if (!is_array($submittedPage) || !empty($submittedPage['delete'])) continue; + $submittedSlug = PublicPageService::normalizeSlug((string) ($submittedPage['slug'] ?? $submittedPage['title'] ?? '')); + if ($submittedSlug !== '') $submittedPageSlugs[] = $submittedSlug; + } + $navigation = array_values(array_filter($navigation, static function (string $item) use ($submittedPageSlugs): bool { + return !str_starts_with($item, 'custom:') || in_array(substr($item, 7), $submittedPageSlugs, true); + })); + foreach (['home','animals','about','donation','contact'] as $required) { + if (!in_array($required, $navigation, true)) $navigation[] = $required; + } + $values['public_site_navigation_order'] = json_encode($navigation, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + foreach (['public_site_home_title','public_site_name','public_site_about_title','public_site_donation_title'] as $key) { + $values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, 150); + } + foreach (['public_site_home_text','public_site_about_text','public_site_donation_text','public_site_contact_text','public_site_legal_text','public_site_privacy_text'] as $key) { + $values[$key] = trim((string) ($_POST[$key] ?? '')); } $customCss = trim((string) ($_POST['public_site_custom_css'] ?? '')); if (strlen($customCss) > 30000 || preg_match('/(?:<\/style|@import|expression\s*\(|javascript\s*:|behavior\s*:|-moz-binding|url\s*\()/i', $customCss)) { @@ -914,9 +923,19 @@ final class SettingsController } $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'); + $db = DB::pdo(); + try { + $db->beginTransaction(); + AppSettings::save($values, Auth::id()); + PublicPageService::saveAll((array) ($_POST['pages'] ?? [])); + $db->commit(); + AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée'); + header('Location: /settings/public-site?saved=1'); + } catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + $message = $e instanceof InvalidArgumentException ? $e->getMessage() : SecurityService::publicError($e); + header('Location: /settings/public-site?error=' . rawurlencode($message)); + } exit(); } public static function audit(): void @@ -1227,6 +1246,68 @@ final class SettingsController header('X-Content-Type-Options: nosniff'); readfile($path); } + public static function catalogs(): void + { + self::admin(); + $db = DB::pdo(); + render('settings_catalogs.php', [ + 'title' => t('settings.catalogs'), + 'catalogs' => [ + 'medication' => $db->query('SELECT id,name,molecule extra FROM ref_medications ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + 'vaccine' => $db->query('SELECT id,name,code extra FROM ref_vaccines ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + 'dewormer' => $db->query('SELECT id,name,active_ingredient extra FROM ref_dewormers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + 'manufacturer' => $db->query('SELECT id,name,NULL extra FROM ref_vaccine_manufacturers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + ], + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function saveCatalogItem(): void + { + self::admin(); self::postOnly(); + $type = (string) ($_POST['type'] ?? ''); + $id = (int) ($_POST['id'] ?? 0); + $name = trim((string) ($_POST['name'] ?? '')); + $extra = trim((string) ($_POST['extra'] ?? '')) ?: null; + $map = [ + 'medication' => ['ref_medications','molecule'], 'vaccine' => ['ref_vaccines','code'], + 'dewormer' => ['ref_dewormers','active_ingredient'], 'manufacturer' => ['ref_vaccine_manufacturers',null], + ]; + if ($name === '' || !isset($map[$type])) { + header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.invalid'))); exit(); + } + [$table,$extraColumn] = $map[$type]; + try { + if ($id) { + $sql = "UPDATE $table SET name=?" . ($extraColumn ? ",$extraColumn=?" : '') . ' WHERE id=?'; + DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra,$id] : [$name,$id]); + } else { + $sql = "INSERT INTO $table(name" . ($extraColumn ? ",$extraColumn" : '') . ') VALUES(?' . ($extraColumn ? ',?' : '') . ')'; + DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra] : [$name]); + $id = (int) DB::pdo()->lastInsertId(); + } + AuditService::log('catalog_saved', '/settings/catalogs/save', 'Catalogue modifié : ' . $name, $type, $id); + header('Location: /settings/catalogs?saved=1'); + } catch (Throwable $e) { + header('Location: /settings/catalogs?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function deleteCatalogItem(): void + { + self::admin(); self::postOnly(); + $type = (string) ($_POST['type'] ?? ''); $id = (int) ($_POST['id'] ?? 0); + $tables = ['medication'=>'ref_medications','vaccine'=>'ref_vaccines','dewormer'=>'ref_dewormers','manufacturer'=>'ref_vaccine_manufacturers']; + if (!$id || !isset($tables[$type])) { http_response_code(400); return; } + try { + DB::pdo()->prepare('DELETE FROM ' . $tables[$type] . ' WHERE id=?')->execute([$id]); + AuditService::log('catalog_deleted', '/settings/catalogs/delete', 'Élément de catalogue supprimé', $type, $id); + header('Location: /settings/catalogs?saved=1'); + } catch (Throwable) { + header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.in_use'))); + } + exit(); + } private static function postOnly(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { 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/Controllers/SurgeriesController.php b/app/Controllers/SurgeriesController.php index a0739d8..bc5c383 100644 --- a/app/Controllers/SurgeriesController.php +++ b/app/Controllers/SurgeriesController.php @@ -56,6 +56,16 @@ final class SurgeriesController } $db = DB::pdo(); $tariff = (int) ($_POST['tariff_id'] ?? 0) ?: null; + $clinic = (int) ($_POST['clinic_contact_id'] ?? 0) ?: null; + if ($tariff) { + $validTariff = $db->prepare('SELECT 1 FROM clinic_tariffs WHERE id=? AND clinic_contact_id=? AND active=1'); + $validTariff->execute([$tariff, $clinic]); + if (!$clinic || !$validTariff->fetchColumn()) { + http_response_code(400); + echo h(t('surgery.invalid_data')); + return; + } + } $amountRaw = str_replace(',', '.', trim((string) ($_POST['amount'] ?? ''))); $amount = $amountRaw === '' ? null : (int) round(max(0, (float) $amountRaw) * 100); if ($amount === null && $tariff) { @@ -77,7 +87,7 @@ final class SurgeriesController $name, $date, $status, - (int) ($_POST['clinic_contact_id'] ?? 0) ?: null, + $clinic, (int) ($_POST['veterinarian_contact_id'] ?? 0) ?: null, $tariff, $amount, @@ -92,7 +102,7 @@ final class SurgeriesController '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, + $clinic, $tariff, $id, $name, diff --git a/app/Routes/web.php b/app/Routes/web.php index 9a12b0e..ac1a2bf 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(); @@ -335,6 +338,15 @@ switch ($path) { case '/settings/pricing': SettingsController::pricing(); break; + case '/settings/catalogs': + SettingsController::catalogs(); + break; + case '/settings/catalogs/save': + SettingsController::saveCatalogItem(); + break; + case '/settings/catalogs/delete': + SettingsController::deleteCatalogItem(); + break; case '/settings/pricing/tariff': SettingsController::saveTariff(); break; diff --git a/app/Services/AppSettings.php b/app/Services/AppSettings.php index d704200..f0c15de 100644 --- a/app/Services/AppSettings.php +++ b/app/Services/AppSettings.php @@ -62,6 +62,7 @@ final class AppSettings 'update_last_error' => '', 'public_site_legal_text' => '', 'public_site_privacy_text' => '', + 'public_site_navigation_order' => '["home","animals","about","donation","contact"]', ]; private static ?array $cache = null; public static function all(): array diff --git a/app/Services/IcadService.php b/app/Services/IcadService.php index ef381d3..eda60ea 100644 --- a/app/Services/IcadService.php +++ b/app/Services/IcadService.php @@ -112,8 +112,19 @@ final class IcadService ]); $this->assertSuccess($r2, t('service.icad.login_failed')); + // Le point d'entrée renvoie désormais du JSON et fournit l'adresse réelle + // de l'espace connecté. L'ancienne URL fixe /pro répond en 404 depuis 2026. + $loginResponse = json_decode(trim((string) $r2['body']), true); + if (is_array($loginResponse) && trim((string) ($loginResponse['error'] ?? '')) !== '') { + throw new RuntimeException(t('service.icad.login_failed')); + } + $accountPath = is_array($loginResponse) ? trim((string) ($loginResponse['address'] ?? '')) : ''; + if ($accountPath === '' || !str_starts_with($accountPath, '/')) { + $accountPath = '/acteur'; + } + // 3) Vérif session - $r2b = $this->req('https://www.i-cad.fr/pro', $cookie); + $r2b = $this->req('https://www.i-cad.fr' . $accountPath, $cookie); $this->assertSuccess($r2b, t('service.icad.login_failed')); if (stripos($r2b['body'], 'Se connecter') !== false || str_contains((string)$r2b['effective_url'], '/login')) { throw new RuntimeException(t('service.icad.login_failed')); diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 6e56133..3684bcb 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -20,14 +20,14 @@ final class NotificationService 'treatments', 'danger', t('notification.treatments_overdue'), - "SELECT COUNT(*) FROM treatments t JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND t.end_date IS NOT NULL AND date(t.end_date)date(d.administered_on))", + "SELECT COUNT(*) FROM dewormings d JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date)<=date('now','+7 day') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('decede','décédé','adopte','adopté') AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND date(newer.administered_on)>date(d.administered_on))", '/dashboard', ], ]; 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/Services/PricingService.php b/app/Services/PricingService.php index 9b41b5b..e773377 100644 --- a/app/Services/PricingService.php +++ b/app/Services/PricingService.php @@ -12,7 +12,7 @@ final class PricingService 'euthanasia' => 'Euthanasie', 'cremation' => 'Crémation', 'medication' => 'Médicament', - 'dewormer' => 'Vermifuge', + 'dewormer' => 'Vermifuge / anti-puce', 'other' => 'Autre', ]; public const GROUPS = [ 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[] = '
    '; - $listOpen = true; + $table .= '
'; + 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/Services/PublicPageService.php b/app/Services/PublicPageService.php new file mode 100644 index 0000000..9f0ff2b --- /dev/null +++ b/app/Services/PublicPageService.php @@ -0,0 +1,81 @@ +query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + public static function findBySlug(string $slug, bool $enabledOnly = true): ?array + { + $slug = self::normalizeSlug($slug); + if ($slug === '') return null; + $stmt = DB::pdo()->prepare( + 'SELECT * FROM public_site_pages WHERE slug=:slug' . ($enabledOnly ? ' AND enabled=1' : '') . ' LIMIT 1', + ); + $stmt->execute([':slug' => $slug]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + public static function saveAll(array $pages): void + { + $db = DB::pdo(); + $pages = array_values(array_filter($pages, 'is_array')); + usort($pages, static fn (array $a, array $b): int => (int) ($a['position'] ?? 0) <=> (int) ($b['position'] ?? 0)); + $seenSlugs = []; + $position = 0; + foreach ($pages as $page) { + if (!is_array($page)) continue; + $id = max(0, (int) ($page['id'] ?? 0)); + if (!empty($page['delete'])) { + if ($id > 0) $db->prepare('DELETE FROM public_site_pages WHERE id=?')->execute([$id]); + continue; + } + $title = mb_substr(trim((string) ($page['title'] ?? '')), 0, 150); + $slug = self::normalizeSlug((string) ($page['slug'] ?? '')); + if ($slug === '') $slug = self::normalizeSlug($title); + if ($title === '' && $slug === '' && trim((string) ($page['content'] ?? '')) === '') continue; + if ($title === '' || $slug === '' || in_array($slug, self::RESERVED, true)) { + throw new InvalidArgumentException(t('public_site.page_invalid')); + } + if (isset($seenSlugs[$slug])) throw new InvalidArgumentException(t('public_site.page_slug_duplicate')); + $seenSlugs[$slug] = true; + $duplicate = $db->prepare('SELECT id FROM public_site_pages WHERE slug=:slug AND id<>:id LIMIT 1'); + $duplicate->execute([':slug' => $slug, ':id' => $id]); + if ($duplicate->fetchColumn()) throw new InvalidArgumentException(t('public_site.page_slug_duplicate')); + $content = trim((string) ($page['content'] ?? '')); + $enabled = !empty($page['enabled']) ? 1 : 0; + if ($id > 0) { + $check = $db->prepare('SELECT 1 FROM public_site_pages WHERE id=?'); + $check->execute([$id]); + if (!$check->fetchColumn()) throw new InvalidArgumentException(t('public_site.page_invalid')); + $stmt = $db->prepare('UPDATE public_site_pages SET slug=:slug,title=:title,content=:content,enabled=:enabled,sort_order=:sort,updated_at=CURRENT_TIMESTAMP WHERE id=:id'); + $stmt->execute([':slug'=>$slug,':title'=>$title,':content'=>$content,':enabled'=>$enabled,':sort'=>$position,':id'=>$id]); + } else { + $stmt = $db->prepare('INSERT INTO public_site_pages(slug,title,content,enabled,sort_order) VALUES(:slug,:title,:content,:enabled,:sort)'); + $stmt->execute([':slug'=>$slug,':title'=>$title,':content'=>$content,':enabled'=>$enabled,':sort'=>$position]); + } + $position++; + } + } + + public static function normalizeSlug(string $value): string + { + $value = mb_strtolower(trim($value), 'UTF-8'); + $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + $value = $ascii === false ? $value : $ascii; + $value = preg_replace('/[^a-z0-9]+/', '-', strtolower($value)) ?? ''; + return mb_substr(trim($value, '-'), 0, 80); + } +} 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/_animal_show_identity.php b/app/Views/_animal_show_identity.php index 3d9488c..6d9fda0 100644 --- a/app/Views/_animal_show_identity.php +++ b/app/Views/_animal_show_identity.php @@ -4,7 +4,7 @@
    - + 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))); @@ -221,11 +221,15 @@ $hasCurrent = ($currentLat !== null && $currentLng !== null);
    - +
    :
    - + +
    @@ -312,10 +316,14 @@ $err = (string)($cache['error'] ?? ''); - +
    + + + + +
    diff --git a/app/Views/_animal_show_medical.php b/app/Views/_animal_show_medical.php index 31b8b95..064d39f 100644 --- a/app/Views/_animal_show_medical.php +++ b/app/Views/_animal_show_medical.php @@ -22,6 +22,10 @@
    FeLV
    +
    +
    +
    +
    @@ -198,7 +202,7 @@
    -

    +

    🪱

    @@ -218,6 +222,7 @@ + @@ -227,6 +232,16 @@ +
    @@ -362,6 +377,7 @@ ORDER BY created_at ASC + @@ -388,7 +404,19 @@ ORDER BY created_at ASC - + @@ -399,6 +427,9 @@ ORDER BY created_at ASC
    +
    + +
    @@ -412,6 +443,7 @@ ORDER BY created_at ASC + @@ -426,6 +458,15 @@ ORDER BY created_at ASC
    + diff --git a/app/Views/_animal_show_modals.php b/app/Views/_animal_show_modals.php index 82c27a8..a01e550 100644 --- a/app/Views/_animal_show_modals.php +++ b/app/Views/_animal_show_modals.php @@ -22,6 +22,7 @@ $currentTime = date('H:i'); +
    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..134a571 100644 --- a/app/Views/settings_public_site.php +++ b/app/Views/settings_public_site.php @@ -28,6 +28,7 @@
    +
    @@ -78,10 +79,17 @@
    -
    -
    +
    +

    + +
    +
    +
    + +

    🏠

    -
    + +
    @@ -89,11 +97,14 @@ class="form-control public-rich-text" name="public_site_home_text" rows="4">
    -
    -
    -
    + +

    🐾

    +
    + +

    📖

    -
    + +
    -
    -
    -
    + +
    + +

    💝

    -
    + +
    -
    -
    -
    + +
    + +

    ✉️

    -
    + +
    + + $page): ?> +
    + + +

    + + + + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    -
    -
    + +
    +

    ⚖️

    -
    + +
    -
    +
    + 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/07-statistiques-et-administratif.md b/docs/07-statistiques-et-administratif.md index 7e53cc4..1ce6af7 100644 --- a/docs/07-statistiques-et-administratif.md +++ b/docs/07-statistiques-et-administratif.md @@ -10,7 +10,7 @@ ## 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. +Choisissez **Toutes les années**, une **Année**, un **Mois** ou une **Période personnalisée** comprise entre deux dates. Une année est comparée à l’année précédente ; un mois ou une période libre est comparé à l’intervalle immédiatement précédent de même durée. 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. @@ -24,7 +24,7 @@ Les anciens dossiers sans commune fiable restent dans les totaux généraux, mai ## 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. +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 période 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. diff --git a/docs/08-documents-et-site-public.md b/docs/08-documents-et-site-public.md index 7af8c24..633086c 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,11 @@ 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 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. diff --git a/docs/11-sauvegardes-et-maintenance.md b/docs/11-sauvegardes-et-maintenance.md index fc7a2eb..35cd2b3 100644 --- a/docs/11-sauvegardes-et-maintenance.md +++ b/docs/11-sauvegardes-et-maintenance.md @@ -19,6 +19,20 @@ La restauration remplace des données actuelles par le contenu de l’archive. A Globinours crée une sauvegarde d’urgence lors des opérations sensibles prévues, mais cette protection ne dispense pas d’une copie externe. +## Réinitialiser automatiquement une démonstration + +La page **Paramètres → Données** permet de recharger manuellement le jeu fictif après création d’une sauvegarde. Une instance de démonstration publique peut aussi être remise à zéro par une tâche planifiée : + +```bash +php scripts/reset-demo.php --confirm-demo-reset +``` + +Le script crée systématiquement une sauvegarde, vide uniquement les données métier puis réinstalle les données et médias fictifs. Il ne doit jamais être planifié sur une instance de production. Exemple quotidien à 03 h 30 avec cron : + +```cron +30 3 * * * cd /chemin/vers/globinours && /usr/bin/php scripts/reset-demo.php --confirm-demo-reset +``` + ## 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. diff --git a/migrations/098_medical_catalogs_and_test_date.sql b/migrations/098_medical_catalogs_and_test_date.sql new file mode 100644 index 0000000..a1ae4ad --- /dev/null +++ b/migrations/098_medical_catalogs_and_test_date.sql @@ -0,0 +1,17 @@ +ALTER TABLE animals ADD COLUMN fiv_felv_test_date TEXT; + +CREATE TABLE IF NOT EXISTS ref_vaccine_manufacturers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + 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')) +); + +INSERT OR IGNORE INTO ref_vaccine_manufacturers(name) VALUES +('Boehringer Ingelheim'), +('Ceva Santé Animale'), +('Elanco'), +('MSD Santé Animale'), +('Virbac'), +('Zoetis'); 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/assets/mobile.css b/public/assets/mobile.css index 8a7ebe3..bc7e315 100644 --- a/public/assets/mobile.css +++ b/public/assets/mobile.css @@ -49,11 +49,14 @@ img { } .topbar-menu > .navbar-nav { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + display: flex; + flex-direction: column; width: 100%; - gap: .5rem; + gap: .35rem; padding-top: .5rem; + max-height: calc(100dvh - 92px); + overflow-y: auto; + overscroll-behavior: contain; } .topbar-menu .btn { @@ -78,6 +81,11 @@ img { margin: 0 !important; } + .topbar-menu .search-trigger-wrap, + .topbar-menu .settings-dropdown { + width: 100%; + } + .page-header { padding-top: .5rem; } 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 76d8dfd..f0efb00 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -210,9 +210,71 @@ return [ 'litter.detach'=>'Detach', 'litter.detach_confirm'=>'Remove this cat from the litter? Its record and data will be preserved.', 'litter.parent_is_kitten'=>'A parent cannot also be a kitten in this litter.', + 'animals.dewormer'=>'Deworming / flea treatment', + 'animal.deworming_record'=>'Record deworming / flea treatment', + 'animal.no_deworming'=>'No deworming / flea treatment recorded.', + 'animal.latest_deworming'=>'Latest deworming / flea treatment: :date', + 'pricing.category.dewormer'=>'Deworming / flea treatment', + 'deworm.catalog_record'=>'Record deworming / flea treatment', + 'deworm.product'=>'Deworming / flea treatment', + 'deworm.add_catalog'=>'Add a new deworming / flea treatment product to the catalogue', + 'deworm.name'=>'Deworming / flea treatment product name', + 'deworm.invalid_date'=>'Invalid deworming / flea treatment date.', + 'deworm.name_required'=>'The deworming / flea treatment product name is required.', + 'notification.dewormings_due'=>'Deworming / flea treatment due or within 7 days', + 'inventory.intro'=>'Track medicines, deworming / flea treatments and vaccines separately, including batches, expiry dates, costs and movements.', + 'inventory.dewormers'=>'Deworming / flea treatments', + 'inventory.dewormer'=>'Deworming / flea treatment', + 'agenda.deworming'=>'Deworming / flea treatment', + 'agenda.deworming_reminder'=>'Deworming / flea treatment: :product', + 'agenda.type_deworming'=>'Next deworming / flea treatment', + 'catalog.rename'=>'Correct a product name', + 'catalog.rename_hint'=>'This correction renames the product in every record that uses it. It does not change recorded administrations.', + 'catalog.name_exists'=>'This name already exists in this catalogue.', 'animal.form.change_pending'=>'Ownership change pending', 'animal.form.owner_changed'=>'Owner changed', 'animal.registration_change_pending'=>'I-CAD: ownership change pending', 'animal.registration_owner_changed'=>'I-CAD: owner changed', 'animal.veterinary_clinic'=>'Veterinary clinic', + 'animal.add_vaccine'=>'Add a vaccine', + 'role.pet_sitter'=>'Pet sitter', + 'role.refuge_partenaire'=>'Partner shelter or charity', + 'role.toiletteur'=>'Groomer', + 'role.professionnel_autre'=>'Other animal professional', + 'common.show_more'=>'Show more', + 'common.show_less'=>'Show less', + 'settings.catalogs'=>'Medical catalogues', + 'catalog.invalid'=>'Invalid catalogue item.', + 'catalog.in_use'=>'This item is already in use and cannot be deleted. Rename it if needed.', + 'catalog.saved'=>'Catalogue saved.', + 'catalog.admin_help'=>'Add, correct or delete the labels offered in medical records.', + 'catalog.code'=>'Code', + 'catalog.active_ingredient'=>'Active ingredient', + 'catalog.delete_confirm'=>'Delete this label?', + 'statistics.by_year'=>'Year', + 'statistics.by_month'=>'Month', + 'statistics.custom_period'=>'Custom period', + 'statistics.month'=>'Month', + 'statistics.from'=>'From', + 'statistics.to'=>'To', + '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.', + 'public_site.markdown_table'=>'Table', ]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php index 75b6400..a7418d6 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -210,9 +210,71 @@ return [ 'litter.detach'=>'Détacher', 'litter.detach_confirm'=>'Retirer ce chat de la portée ? Sa fiche et ses données seront conservées.', 'litter.parent_is_kitten'=>'Un parent ne peut pas être également chaton de cette portée.', + 'animals.dewormer'=>'Vermifuge / anti-puce', + 'animal.deworming_record'=>'Enregistrer un vermifuge / anti-puce', + 'animal.no_deworming'=>'Aucun vermifuge / anti-puce enregistré.', + 'animal.latest_deworming'=>'Dernier vermifuge / anti-puce : :date', + 'pricing.category.dewormer'=>'Vermifuge / anti-puce', + 'deworm.catalog_record'=>'Enregistrer un vermifuge / anti-puce', + 'deworm.product'=>'Vermifuge / anti-puce', + 'deworm.add_catalog'=>'Ajouter un vermifuge / anti-puce au référentiel', + 'deworm.name'=>'Nom du vermifuge / anti-puce', + 'deworm.invalid_date'=>'Date de vermifuge / anti-puce invalide.', + 'deworm.name_required'=>'Le nom du vermifuge / anti-puce est obligatoire.', + 'notification.dewormings_due'=>'Vermifuges / anti-puces échus ou sous 7 jours', + 'inventory.intro'=>'Suivez séparément médicaments, vermifuges / anti-puces et vaccins, leurs lots, péremptions, coûts et mouvements.', + 'inventory.dewormers'=>'Vermifuges / anti-puces', + 'inventory.dewormer'=>'Vermifuge / anti-puce', + 'agenda.deworming'=>'Vermifuge / anti-puce', + 'agenda.deworming_reminder'=>'Vermifuge / anti-puce : :product', + 'agenda.type_deworming'=>'Prochain vermifuge / anti-puce', + 'catalog.rename'=>'Corriger le nom d’un produit', + 'catalog.rename_hint'=>'Cette correction renomme le produit dans toutes les fiches qui l’utilisent. Elle ne modifie pas les administrations enregistrées.', + 'catalog.name_exists'=>'Ce nom existe déjà dans ce catalogue.', 'animal.form.change_pending'=>'En attente de changement', 'animal.form.owner_changed'=>'Propriétaire changé', 'animal.registration_change_pending'=>'I-CAD : en attente de changement', 'animal.registration_owner_changed'=>'I-CAD : propriétaire changé', 'animal.veterinary_clinic'=>'Clinique vétérinaire', + 'animal.add_vaccine'=>'Ajouter un vaccin', + 'role.pet_sitter'=>'Pet-sitter', + 'role.refuge_partenaire'=>'Refuge ou association partenaire', + 'role.toiletteur'=>'Toiletteur', + 'role.professionnel_autre'=>'Autre professionnel animalier', + 'common.show_more'=>'Afficher plus', + 'common.show_less'=>'Afficher moins', + 'settings.catalogs'=>'Catalogues médicaux', + 'catalog.invalid'=>'Élément de catalogue invalide.', + 'catalog.in_use'=>'Cet élément est déjà utilisé et ne peut pas être supprimé. Renommez-le si nécessaire.', + 'catalog.saved'=>'Catalogue enregistré.', + 'catalog.admin_help'=>'Ajoutez, corrigez ou supprimez les intitulés proposés dans les fiches médicales.', + 'catalog.code'=>'Code', + 'catalog.active_ingredient'=>'Principe actif', + 'catalog.delete_confirm'=>'Supprimer cet intitulé ?', + 'statistics.by_year'=>'Année', + 'statistics.by_month'=>'Mois', + 'statistics.custom_period'=>'Période personnalisée', + 'statistics.month'=>'Mois', + 'statistics.from'=>'Du', + 'statistics.to'=>'Au', + '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.', + 'public_site.markdown_table'=>'Tableau', ]; diff --git a/scripts/reset-demo.php b/scripts/reset-demo.php new file mode 100644 index 0000000..7db7d0e --- /dev/null +++ b/scripts/reset-demo.php @@ -0,0 +1,28 @@ +beginTransaction(); + $removed = DemoDataService::reset($db); + DemoDataService::seed($db, null); + $db->commit(); + DemoDataService::clearFiles(); + DemoDataService::installFiles($db); + fwrite(STDOUT, json_encode(['ok'=>true,'backup'=>$backup['name'],'removed'=>$removed], JSON_UNESCAPED_UNICODE) . PHP_EOL); +} catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + fwrite(STDERR, json_encode(['ok'=>false,'error'=>$e->getMessage()], JSON_UNESCAPED_UNICODE) . PHP_EOL); + exit(1); +} diff --git a/tests/run.php b/tests/run.php index 93af42e..7afdb7b 100644 --- a/tests/run.php +++ b/tests/run.php @@ -34,6 +34,7 @@ foreach ( 'SpeciesService', 'LabReferenceService', 'PublicContentFormatter', + 'PublicPageService', 'ShelterRoomService', 'GlobalSearchService', 'PrivateMediaService', @@ -153,6 +154,10 @@ try { 'Les cinq box initiaux ne sont pas disponibles', ); }); + $test('Une nouvelle fiche de chat propose la race Chat Européen', function () use ($assert): void { + $controller = (string) file_get_contents(dirname(__DIR__) . '/app/Controllers/AnimalRecordActions.php'); + $assert(str_contains($controller, "'breed' => 'Chat Européen'")); + }); $test('Les tournées acceptent quatre créneaux configurables', function () use ($db, $assert): void { foreach (ShelterRoomService::PERIODS as $period) { $db->prepare('INSERT INTO care_rounds(round_date,period) VALUES(?,?)')->execute(['2099-01-01', $period]); @@ -198,6 +203,31 @@ try { $assert(!str_contains($modal, 'name="administered_by_name"')); $assert(str_contains($summary, "\$v['clinic_name']")); }); + $test('Vaccins et vermifuges peuvent être corrigés sans nouveau déstockage', function () use ($assert): void { + $modals = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_modals.php'); + $medical = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_medical.php'); + $vaccines = (string) file_get_contents(dirname(__DIR__) . '/app/Controllers/AnimalMedicalActions.php'); + $dewormings = (string) file_get_contents(dirname(__DIR__) . '/app/Controllers/DewormingController.php'); + $assert(substr_count($modals, 'name="entry_id"') >= 2); + $assert(str_contains($medical, 'edit-vaccine')); + $assert(str_contains($medical, 'edit-deworming')); + $assert(str_contains($vaccines, 'if ($entryId > 0)')); + $assert(str_contains($dewormings, 'if ($entryId > 0)')); + $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'); @@ -266,6 +296,44 @@ try { $assert(str_contains($image, ' Important\n\n`\n```"); + $assert(!str_contains($fenced, '