Corriger les dossiers animaux et le suivi médical

This commit is contained in:
Alexandre NOEL 2026-10-01 15:22:09 +02:00
commit 7b89fd5dfa
34 changed files with 440 additions and 39 deletions

View file

@ -2,6 +2,22 @@
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.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

View file

@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
## `1.0.0-rc.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.

View file

@ -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.29`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
La candidate actuelle est `1.0.0-rc.30`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).

View file

@ -1 +1 @@
1.0.0-rc.29
1.0.0-rc.30

View file

@ -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,

View file

@ -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',
);

View file

@ -746,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,

View file

@ -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

View file

@ -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'),
];
}

View file

@ -1227,6 +1227,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') {

View file

@ -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,

View file

@ -338,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;

View file

@ -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'));

View file

@ -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('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL",
"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('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('decede','décédé','adopte','adopté')",
'/dashboard#active-treatments',
],
[
'dewormings',
'warning',
t('notification.dewormings_due'),
"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 NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND date(newer.administered_on)>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',
],
];

View file

@ -4,7 +4,7 @@
<!-- IDENTITÉ -->
<div class="tab-pane active show" id="tab-ident">
<?php if ($canViewMedical && !empty($death)): ?>
<?php if ($canViewMedical && !empty($death) && in_array(strtolower((string)$animal['status']), ['decede','décédé'], true)): ?>
<?php
$deathCauseLabels = array_combine(array_keys(DeathController::CAUSES), array_map(static fn ($key) => 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);
</div>
<?php if ($animal['status'] === 'adopte'): ?>
<?php if (in_array($animal['status'], ['adopte','adopté'], true) && !empty($adoption)): ?>
<div class="alert alert-success mt-3">
<strong><?= h(t('animal.adopted_by')) ?> :</strong><br>
<?= htmlspecialchars($adoption['adopter_name'] . ' - ' . $adoption['adopter_address'] . ' ' . $adoption['adopter_postal_code'] . ' ' . $adoption['adopter_city'] . ' - (' . $adoption['adopter_phone'] . ' - ' . $adoption['adopter_email'] . ')') ?>
<?php if (!empty($adoption['adopter_contact_id'])): ?><a class="fw-bold" href="/directory/show?id=<?= (int)$adoption['adopter_contact_id'] ?>"><?= h($adoption['adopter_name']) ?></a><?php else: ?><span class="fw-bold"><?= h($adoption['adopter_name']) ?></span><?php endif; ?>
<div class="small mt-1"><?= h(trim(implode(' · ', array_filter([
trim((string)($adoption['adopter_address'] ?? '') . ' ' . (string)($adoption['adopter_postal_code'] ?? '') . ' ' . (string)($adoption['adopter_city'] ?? '')),
$adoption['adopter_phone'] ?? '', $adoption['adopter_email'] ?? ''
])))) ?></div>
</div>
<?php endif; ?>

View file

@ -22,6 +22,10 @@
<div class="col-12 col-md-3">
<div class="subheader mb-1">FeLV</div>
<span class="badge <?= $felvClass ?>"><?= h($felvLabel) ?></span>
</div>
<div class="col-12 col-md-3">
<div class="subheader mb-1"><?= h(t('animal.test_date')) ?></div>
<strong><?= !empty($animal['fiv_felv_test_date']) ? h(date('d/m/Y', strtotime((string)$animal['fiv_felv_test_date']))) : '—' ?></strong>
</div><?php endif; ?>
<div class="col-12 <?= $isCat ? 'col-md-6' : '' ?>">
<div class="subheader mb-1"><?= h(t('animal.conditions_short')) ?></div>
@ -423,6 +427,9 @@ ORDER BY created_at ASC
<!-- VACCINS -->
<div class="tab-pane" id="tab-vac">
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#modalVaccine">+ <?= h(t('animal.add_vaccine')) ?></button>
</div>
<?php if (!$vaccinations): ?>
<div class="text-muted"><?= h(t('animal.no_vaccine_recorded')) ?></div>
<?php else: ?>

View file

@ -300,7 +300,11 @@ $currentTime = date('H:i');
<div class="col-md-4">
<label class="form-label"><?= h(t('animal.manufacturer')) ?></label>
<input class="form-control" name="manufacturer" placeholder="Ex : Boehringer Ingelheim">
<input class="form-control" name="manufacturer" list="vaccineManufacturerList"
placeholder="Ex : Boehringer Ingelheim">
<datalist id="vaccineManufacturerList">
<?php foreach (($vaccineManufacturers ?? []) as $manufacturer): ?><option value="<?= h($manufacturer['name']) ?>"></option><?php endforeach; ?>
</datalist>
</div>
<div class="col-md-4">
@ -619,13 +623,29 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
<div class="col-md-4">
<label class="form-label"><?= h(t('animal.test_date')) ?></label>
<input class="form-control" type="date" name="test_date" value="<?= date('Y-m-d') ?>">
<input class="form-control" type="date" name="test_date"
value="<?= h($animal['fiv_felv_test_date'] ?: date('Y-m-d')) ?>">
</div>
<div class="col-md-8">
<label class="form-label"><?= h(t('animal.test_note')) ?></label>
<input class="form-control" name="test_notes"
placeholder="<?= h(t('animal.test_notes_placeholder')) ?>">
</div>
<div class="col-md-6">
<label class="form-label"><?= h(t('animal.veterinary_clinic')) ?></label>
<select class="form-select" name="clinic_contact_id">
<option value="">— <?= h(t('common.choose')) ?> —</option>
<?php foreach (($directoryClinics ?? []) as $clinic): ?><option value="<?= (int)$clinic['id'] ?>"><?= h($clinic['name']) ?></option><?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label"><?= h(t('animal.tariff_record')) ?> <span class="text-muted">(<?= h(mb_strtolower(t('common.optional'))) ?>)</span></label>
<select class="form-select" name="test_tariff_id">
<option value=""><?= h(t('animal.no_expense')) ?></option>
<?php foreach (($expenseTariffs ?? []) as $tariff): if (($tariff['category'] ?? '') !== 'test') continue; ?><option
value="<?= (int)$tariff['id'] ?>" data-clinic="<?= (int)$tariff['clinic_contact_id'] ?>"><?= h($tariff['clinic_name'] . ' · ' . $tariff['label']) ?> · <?= number_format((int)$tariff['effective_cents']/100, 2, ',', ' ') ?> €</option><?php endforeach; ?>
</select>
</div>
</div>
<hr class="my-4">
@ -694,6 +714,22 @@ document.addEventListener('DOMContentLoaded', () => {
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('modalMedicalStatus');
const clinic = modal?.querySelector('select[name="clinic_contact_id"]');
const tariff = modal?.querySelector('select[name="test_tariff_id"]');
const sync = () => {
[...(tariff?.options || [])].forEach(option => {
if (!option.dataset.clinic) return;
option.hidden = !clinic?.value || option.dataset.clinic !== clinic.value;
option.disabled = option.hidden;
});
if (tariff?.selectedOptions[0]?.hidden) tariff.value = '';
};
clinic?.addEventListener('change', sync); sync();
});
</script>
<div class="modal modal-blur fade" id="modalMedical" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">

View file

@ -3,9 +3,9 @@
name="tariff_id">
<option value=""><?= h(t('animal.no_expense')) ?></option><?php $expenseClinic = '';
foreach (($expenseTariffs ?? []) as $t):if ($expenseClinic !== $t['clinic_name']):if ($expenseClinic !== ''):?>
</optgroup><?php endif;
</optgroup><?php endif;
$expenseClinic = $t['clinic_name'];?><optgroup label="<?= h($expenseClinic) ?>"><?php endif;?><option
value="<?= (int)$t['id'] ?>"><?= h($t['label']) ?> ·
value="<?= (int)$t['id'] ?>" data-clinic="<?= (int)$t['clinic_contact_id'] ?>"><?= h($t['label']) ?> ·
<?= number_format($t['effective_cents'] / 100, 2, ',', ' ') ?> €
<?= h(t('animal.net')) ?><?= (float)$t['discount_percent'] > 0 ? ' (−' . number_format((float)$t['discount_percent'], 0) . ' %)' : '' ?>
</option><?php endforeach;
@ -13,3 +13,27 @@ if ($expenseClinic !== ''):?></optgroup><?php endif;?>
</select>
<div class="form-hint"><?= h(t('animal.expense_hint')) ?></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('form').forEach(form => {
const clinic = form.querySelector('select[name="clinic_contact_id"]');
const tariff = form.querySelector('select[name="tariff_id"]');
if (!clinic || !tariff || tariff.dataset.clinicFilterReady) return;
tariff.dataset.clinicFilterReady = '1';
const sync = () => {
const clinicId = clinic.value;
[...tariff.options].forEach(option => {
if (!option.dataset.clinic) return;
option.hidden = !clinicId || option.dataset.clinic !== clinicId;
option.disabled = option.hidden;
});
[...tariff.querySelectorAll('optgroup')].forEach(group => {
group.hidden = ![...group.querySelectorAll('option')].some(option => !option.hidden);
});
if (tariff.selectedOptions[0]?.hidden) tariff.value = '';
};
clinic.addEventListener('change', sync);
sync();
});
});
</script>

View file

@ -16,6 +16,7 @@ if (!function_exists('settingsNavigationLinks')) {
'rooms' => ['/settings/rooms','🚪',t('settings.rooms'),'business'],
'pricing' => ['/settings/pricing','💶',t('settings.pricing'),'business'],
'inventory' => ['/settings/inventory','💊',t('settings.inventory'),'business'],
'catalogs' => ['/settings/catalogs','📚',t('settings.catalogs'),'business'],
'lab-references' => ['/settings/lab-references','🩸',t('settings.lab_references'),'business'],
'users' => ['/settings/users','👥',t('settings.users'),'access'],
'permissions' => ['/settings/permissions','🔐',t('settings.permissions'),'access'],

View file

@ -27,7 +27,8 @@
<div class="mb-3">
<label class="form-label"><?= h(t('adoption.adopter_name')) ?></label>
<input type="text" name="adopter_name" id="adopterName" class="form-control" required>
<input type="text" name="adopter_name" id="adopterName" class="form-control"
placeholder="Prénom NOM" required>
</div>
<div class="mb-3">

View file

@ -184,7 +184,7 @@ if ($felvPos) {
<?php endif; ?>
<div class="text-muted mt-1" style="font-size:.7rem"><?= h($animal['internal_code']) ?></div>
<div class="text-muted small mt-1"><?= h(t('animal.form.birth_date')) ?> :
<?= !empty($animal['birth_date']) ? h(date('d/m/Y', strtotime((string)$animal['birth_date']))) : '—' ?></div>
<?= !empty($animal['birth_date']) ? (!empty($animal['birth_is_estimated']) ? '~ ' : '') . h(date('d/m/Y', strtotime((string)$animal['birth_date']))) : '—' ?></div>
</div>
<div>

View file

@ -118,6 +118,28 @@ $evening = $careToday['evening'] ?? ['expected' => 0,'checked' => 0,'alerts' =>
</div><?php endif; ?>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.reminder-list').forEach(table => {
const rows = [...table.tBodies[0].rows];
const limit = Number(table.dataset.visibleRows || 6);
if (rows.length <= limit) return;
rows.slice(limit).forEach(row => row.classList.add('d-none'));
const button = document.createElement('button');
button.type = 'button'; button.className = 'btn btn-sm btn-link w-100';
button.textContent = <?= json_encode(t('common.show_more'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
button.addEventListener('click', () => {
const expanded = button.dataset.expanded === '1';
rows.slice(limit).forEach(row => row.classList.toggle('d-none', expanded));
button.dataset.expanded = expanded ? '0' : '1';
button.textContent = expanded
? <?= json_encode(t('common.show_more'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
: <?= json_encode(t('common.show_less'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
});
table.closest('.table-responsive')?.after(button);
});
});
</script>
<div class="row g-2 mb-3">
<div class="col">
@ -264,7 +286,7 @@ if ($grantAlertTotal > 0): ?><div class="alert alert-warning d-flex flex-wrap al
<h3 class="card-title">🚨 <?= h(t('dashboard.vaccines_overdue')) ?></h3>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<table class="table table-vcenter card-table reminder-list" data-visible-rows="6">
<tbody>
<?php if (empty($vaccines_overdue)): ?>
<tr>
@ -290,7 +312,7 @@ if ($grantAlertTotal > 0): ?><div class="alert alert-warning d-flex flex-wrap al
<h3 class="card-title">⏳ <?= h(t('dashboard.vaccines_soon')) ?></h3>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<table class="table table-vcenter card-table reminder-list" data-visible-rows="6">
<tbody>
<?php if (empty($vaccines_7d)): ?>
<tr>
@ -315,7 +337,7 @@ if ($grantAlertTotal > 0): ?><div class="alert alert-warning d-flex flex-wrap al
<h3 class="card-title">📅 <?= h(t('dashboard.vaccines_month')) ?></h3>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<table class="table table-vcenter card-table reminder-list" data-visible-rows="6">
<tbody>
<?php if (empty($vaccines_30d)): ?>
<tr>

View file

@ -31,10 +31,10 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.28</span>
<h3>Saisies médicales corrigibles</h3>
<p class="text-muted">Vaccinations et traitements antiparasitaires peuvent être rectifiés sans
déclencher une seconde sortie de stock ou une nouvelle dépense.</p>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.30</span>
<h3>Données fiabilisées sur le terrain</h3>
<p class="text-muted">Décès historiques corrigibles, synchronisation I-CAD réparée, catalogues
médicaux administrables et tarifs filtrés par clinique.</p>
</div>
</section>
</div>
@ -43,8 +43,8 @@
<div class="card-body">
<span class="badge bg-secondary-lt mb-2">Après la 1.0</span>
<h3>Prochaines étapes</h3>
<p class="text-muted">Tests HTTP plus complets, validation des paquets d’installation et amélioration
continue des parcours.</p>
<p class="text-muted">Réinitialisation planifiée des démonstrations, normalisation assistée des
contacts, validation des paquets YunoHost, ZimaOS et CasaOS, puis amélioration continue des parcours.</p>
</div>
</section>
</div>

View file

@ -0,0 +1,17 @@
<?php require __DIR__ . '/settings_nav.php';
$labels = ['medication'=>t('animal.medication'),'vaccine'=>t('animal.vaccine'),'dewormer'=>t('animals.dewormer'),'manufacturer'=>t('animal.manufacturer')];
$extras = ['medication'=>t('animal.molecule'),'vaccine'=>t('catalog.code'),'dewormer'=>t('catalog.active_ingredient'),'manufacturer'=>'']; ?>
<?php if ($saved): ?><div class="alert alert-success"><?= h(t('catalog.saved')) ?></div><?php endif; ?>
<?php if ($error !== ''): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
<div class="mb-3"><h2 class="mb-1"><?= h(t('settings.catalogs')) ?></h2><div class="text-muted"><?= h(t('catalog.admin_help')) ?></div></div>
<div class="row g-3">
<?php foreach ($catalogs as $type => $rows): ?><div class="col-12 col-xl-6"><div class="card h-100">
<div class="card-header"><h3 class="card-title"><?= h($labels[$type]) ?></h3></div><div class="card-body">
<form class="row g-2 mb-3" method="post" action="/settings/catalogs/save"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>"><input type="hidden" name="type" value="<?= h($type) ?>">
<div class="<?= $type === 'manufacturer' ? 'col-9' : 'col-6' ?>"><input class="form-control" name="name" placeholder="<?= h(t('common.name')) ?>" required></div>
<?php if ($type !== 'manufacturer'): ?><div class="col-3"><input class="form-control" name="extra" placeholder="<?= h($extras[$type]) ?>"></div><?php endif; ?><div class="col-3"><button class="btn btn-primary w-100">+ <?= h(t('common.add')) ?></button></div></form>
<div class="table-responsive"><table class="table table-sm table-vcenter"><tbody><?php foreach ($rows as $row): ?><tr><td>
<form class="d-flex gap-2" method="post" action="/settings/catalogs/save"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>"><input type="hidden" name="type" value="<?= h($type) ?>"><input type="hidden" name="id" value="<?= (int)$row['id'] ?>"><input class="form-control form-control-sm" name="name" value="<?= h($row['name']) ?>" required><?php if ($type !== 'manufacturer'): ?><input class="form-control form-control-sm" name="extra" value="<?= h($row['extra'] ?? '') ?>" placeholder="<?= h($extras[$type]) ?>"><?php endif; ?><button class="btn btn-sm btn-outline-primary">✓</button></form>
</td><td class="w-1"><form method="post" action="/settings/catalogs/delete" onsubmit="return confirm(<?= h(json_encode(t('catalog.delete_confirm'), JSON_HEX_APOS|JSON_HEX_QUOT)) ?>)"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>"><input type="hidden" name="type" value="<?= h($type) ?>"><input type="hidden" name="id" value="<?= (int)$row['id'] ?>"><button class="btn btn-sm btn-outline-danger">×</button></form></td></tr><?php endforeach; ?></tbody></table></div>
</div></div></div><?php endforeach; ?>
</div>

View file

@ -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',

View file

@ -173,6 +173,14 @@ const insertImage = (button) => {
textarea.setRangeText(`![${safeAlt}](${button.dataset.url})\n`, textarea.selectionStart, textarea.selectionEnd, 'end');
textarea.focus();
};
document.querySelectorAll('textarea').forEach((textarea) => {
const resize = () => {
textarea.style.height = 'auto';
textarea.style.height = Math.max(140, textarea.scrollHeight + 8) + 'px';
};
textarea.addEventListener('input', resize);
resize();
});
document.querySelectorAll('.public-rich-text').forEach((textarea) => {
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
const toolbar = document.createElement('div');

View file

@ -8,7 +8,7 @@
<div class="card-body">
<div class="row g-3">
<div class="col-md-4"><label class="form-label">Catégorie</label><select class="form-select"
name="category">
name="category" id="surgeryCategory">
<option value="orthopedic">Orthopédie</option>
<option value="dental">Dentaire</option>
<option value="soft_tissue">Viscérale / tissus mous</option>
@ -20,8 +20,9 @@
<option value="other">Autre</option>
</select></div>
<div class="col-md-8"><label class="form-label">Intitulé de l’intervention *</label><input
class="form-control" name="procedure_name" required
class="form-control" name="procedure_name" id="surgeryProcedure" list="surgeryProcedureList" required
placeholder="Ex. ovariectomie, détartrage, ostéosynthèse…"></div>
<datalist id="surgeryProcedureList"></datalist>
<div class="col-md-4"><label class="form-label">Date *</label><input class="form-control"
type="date" name="surgery_date" value="<?= h(date('Y-m-d')) ?>" required></div>
<div class="col-md-4"><label class="form-label">Statut</label><select class="form-select"
@ -29,7 +30,7 @@
<option value="completed">Réalisée</option>
<option value="planned">Planifiée</option>
<option value="cancelled">Annulée</option>
</select></div>
</select><div class="form-hint">Les tarifs affichés correspondent à la structure sélectionnée.</div></div>
<div class="col-md-4"><label class="form-label">Montant réel (€)</label><input class="form-control"
inputmode="decimal" name="amount" placeholder="Calculé depuis le tarif si vide"></div>
<div class="col-md-6"><label class="form-label">Structure</label><select class="form-select"
@ -66,3 +67,37 @@
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const clinic = document.querySelector('select[name="clinic_contact_id"]');
const tariff = document.querySelector('select[name="tariff_id"]');
const category = document.getElementById('surgeryCategory');
const list = document.getElementById('surgeryProcedureList');
const procedures = {
orthopedic: ['Amputation','Arthrodèse','Ostéosynthèse','Réduction de fracture','Retrait de matériel'],
dental: ['Avulsion dentaire','Détartrage','Extraction dentaire'],
soft_tissue: ['Cystotomie','Entérectomie','Gastrotomie','Hernie','Laparotomie','Splénectomie'],
reproductive: ['Castration','Césarienne','Ovariectomie','Ovario-hystérectomie'],
ophthalmology: ['Énucléation','Entropion'], oncology: ['Exérèse de masse','Mastectomie'],
imaging_guided: ['Biopsie guidée','Ponction guidée'], wound: ['Parage de plaie','Suture','Reconstruction cutanée'],
other: ['Autre']
};
const syncTariffs = () => {
const id = clinic?.value || '';
[...(tariff?.options || [])].forEach(option => {
if (!option.dataset.clinic) return;
option.hidden = !id || option.dataset.clinic !== id;
option.disabled = option.hidden;
});
if (tariff?.selectedOptions[0]?.hidden) tariff.value = '';
};
const syncProcedures = () => {
if (!list) return;
list.replaceChildren(...(procedures[category?.value] || ['Autre']).map(label => {
const option = document.createElement('option'); option.value = label; return option;
}));
};
clinic?.addEventListener('change', syncTariffs); category?.addEventListener('change', syncProcedures);
syncTariffs(); syncProcedures();
});
</script>

View file

@ -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.

View file

@ -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');

View file

@ -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;
}

View file

@ -236,4 +236,19 @@ return [
'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?',
];

View file

@ -236,4 +236,19 @@ return [
'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é ?',
];

28
scripts/reset-demo.php Normal file
View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') { http_response_code(404); exit(1); }
if (!in_array('--confirm-demo-reset', $argv, true)) {
fwrite(STDERR, "Usage: php scripts/reset-demo.php --confirm-demo-reset\n"); exit(2);
}
$root = dirname(__DIR__);
foreach (['DB','Migrations','I18n','AppSettings','Auth','AuditService','BackupService','DemoDataService'] as $service) {
require_once $root . '/app/Services/' . $service . '.php';
}
Migrations::run($root . '/migrations');
I18n::setLocale(AppSettings::get('app_language'));
$db = DB::pdo();
$backup = BackupService::create('scheduled-demo-reset');
try {
$db->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);
}

View file

@ -408,7 +408,8 @@ try {
$alerts = NotificationService::alerts();
$assert((bool) array_filter($alerts, static fn($x) => $x['key'] === 'vaccines'));
});
$test('Les rappels vaccinaux apparaissent automatiquement dans l’agenda', function () use ($assert): void {
$test('Les rappels vaccinaux apparaissent automatiquement dans l’agenda', function () use ($db, $assert): void {
$db->exec("UPDATE vaccinations SET due_date=date('now') WHERE animal_id=(SELECT id FROM animals WHERE internal_code='TEST-001')");
$month = AgendaService::month(new DateTimeImmutable('first day of this month'));
$reminders = array_values(
array_filter(