Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56a2fddc83 | |||
| 21ee1bec36 | |||
| 9e05196440 | |||
| 86642095cd | |||
| 9ff26ad9be | |||
| 7b89fd5dfa | |||
| a55d27935f | |||
| f20d7cc965 |
53 changed files with 1905 additions and 328 deletions
87
CHANGELOG.md
87
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.0.0-rc.27
|
||||
1.0.0-rc.35
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ($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,7 +721,23 @@ trait AnimalMedicalActions
|
|||
':clinic_contact_id' => $clinicContact['id'] ?? null,
|
||||
':vet_contact_id' => $vetContact['id'] ?? null,
|
||||
':created_by' => Auth::id(),
|
||||
]);
|
||||
];
|
||||
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)),
|
||||
|
|
@ -686,11 +747,12 @@ trait AnimalMedicalActions
|
|||
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" : '') .
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
$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') {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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[] = '</ul>';
|
||||
$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[] = '<pre><code' . $class . '>' . htmlspecialchars(implode("\n", $code), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></pre>';
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) {
|
||||
if (!$listOpen) {
|
||||
$html[] = '<ul>';
|
||||
$listOpen = true;
|
||||
|
||||
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 = '<div class="public-markdown-table"><table><thead><tr>';
|
||||
foreach ($headers as $column => $header) {
|
||||
$align = $alignments[$column] ?? '';
|
||||
$table .= '<th' . ($align ? ' class="text-' . $align . '"' : '') . '>' . self::inline($header) . '</th>';
|
||||
}
|
||||
$html[] = '<li>' . self::inline($match[1]) . '</li>';
|
||||
$table .= '</tr></thead><tbody>';
|
||||
foreach ($rows as $row) {
|
||||
$table .= '<tr>';
|
||||
foreach ($headers as $column => $_) {
|
||||
$align = $alignments[$column] ?? '';
|
||||
$table .= '<td' . ($align ? ' class="text-' . $align . '"' : '') . '>' . self::inline($row[$column] ?? '') . '</td>';
|
||||
}
|
||||
$table .= '</tr>';
|
||||
}
|
||||
$html[] = $table . '</tbody></table></div>';
|
||||
continue;
|
||||
}
|
||||
if ($listOpen) {
|
||||
$html[] = '</ul>';
|
||||
$listOpen = false;
|
||||
}
|
||||
if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) {
|
||||
$level = strlen($match[1]) + 1;
|
||||
$html[] = '<h' . $level . '>' . self::inline($match[2]) . '</h' . $level . '>';
|
||||
|
||||
if (preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line)) { $html[] = '<hr>'; $i++; continue; }
|
||||
if (preg_match('/^(#{1,6})\s+(.+?)\s*#*$/u', trim($line), $heading)) {
|
||||
$level = min(6, strlen($heading[1]) + 1);
|
||||
$html[] = '<h' . $level . '>' . self::inline($heading[2]) . '</h' . $level . '>';
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
$html[] = '<p>' . self::inline($line) . '</p>';
|
||||
if (preg_match('/^\s*>\s?(.*)$/u', $line)) {
|
||||
$quote = [];
|
||||
while ($i < $count && preg_match('/^\s*>\s?(.*)$/u', $lines[$i], $match)) { $quote[] = $match[1]; $i++; }
|
||||
$html[] = '<blockquote>' . self::render(implode("\n", $quote)) . '</blockquote>';
|
||||
continue;
|
||||
}
|
||||
if ($listOpen) {
|
||||
$html[] = '</ul>';
|
||||
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 = '<span class="task-marker" aria-hidden="true">' . ($task[1] === ' ' ? '☐' : '☑') . '</span> ' . self::inline($task[2]);
|
||||
} else $value = self::inline($value);
|
||||
$list .= '<li' . $taskClass . '>' . $value . '</li>';
|
||||
$i++;
|
||||
}
|
||||
$html[] = $list . '</' . $tag . '>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$paragraph = [trim($line)];
|
||||
for ($i++; $i < $count && trim($lines[$i]) !== '' && !self::startsBlock($lines, $i); $i++) $paragraph[] = trim($lines[$i]);
|
||||
$html[] = '<p>' . self::inline(implode(' ', $paragraph)) . '</p>';
|
||||
}
|
||||
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[] = '<img class="public-content-image" src="' .
|
||||
htmlspecialchars($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" alt="' .
|
||||
htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" loading="lazy">';
|
||||
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[] = '<a href="' . htmlspecialchars($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') .
|
||||
'" rel="noopener noreferrer">' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</a>';
|
||||
$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('<code>' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code>'), $text) ?? $text;
|
||||
$text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)(?:\s+["\']([^"\']*)["\'])?\)/u', static fn (array $match): string => $reserve('<img class="public-content-image" src="' . htmlspecialchars($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" alt="' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" loading="lazy">'), $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('<a href="' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"' . (str_starts_with($url, 'https://') ? ' rel="noopener noreferrer"' : '') . '>' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</a>');
|
||||
}, $text) ?? $text;
|
||||
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$text = preg_replace('/~~(.+?)~~/u', '<del>$1</del>', $text) ?? $text;
|
||||
$text = preg_replace('/\*\*(.+?)\*\*/u', '<strong>$1</strong>', $text) ?? $text;
|
||||
$text = preg_replace('/__(.+?)__/u', '<strong>$1</strong>', $text) ?? $text;
|
||||
$text = preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/u', '<em>$1</em>', $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('/(?<!_)_([^_]+)_(?!_)/u', '<em>$1</em>', $text) ?? $text;
|
||||
foreach ($tokens as $index => $html) $text = str_replace("\x1A" . $index . "\x1A", $html, $text);
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
app/Services/PublicPageService.php
Normal file
81
app/Services/PublicPageService.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class PublicPageService
|
||||
{
|
||||
private const RESERVED = [
|
||||
'site','animaux','adoption','presentation','dons','contact','mentions-legales','confidentialite',
|
||||
'logo-association','login','logout','setup','dashboard','statistics','agenda','directory','settings','admin',
|
||||
'accounting','inventory','care-round','animal','litter','documents','media','assets','api',
|
||||
];
|
||||
|
||||
public static function all(bool $enabledOnly = false): array
|
||||
{
|
||||
$sql = 'SELECT * FROM public_site_pages' . ($enabledOnly ? ' WHERE enabled=1' : '') .
|
||||
' ORDER BY sort_order,id';
|
||||
return DB::pdo()->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,17 +234,19 @@ final class StatisticsService
|
|||
|
||||
public static function annual(int $year, ?array $ids = null): array
|
||||
{
|
||||
if ($ids === null) {
|
||||
$stats = GrantService::statistics($year);
|
||||
} else {
|
||||
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();
|
||||
$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 {
|
||||
$range = [':start' => $start, ':end' => $end];
|
||||
$scalar = static function (string $sql) use ($db, $range): float {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($p);
|
||||
$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" .
|
||||
|
|
@ -250,38 +262,21 @@ final class StatisticsService
|
|||
self::idSql($ids, 'd.animal_id') .
|
||||
')',
|
||||
);
|
||||
$stats['adoptions'] = $scalar(
|
||||
'SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in,
|
||||
);
|
||||
$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,
|
||||
"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,
|
||||
"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'),
|
||||
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . self::idSql($ids, 'a.id'),
|
||||
);
|
||||
$stats['current'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
"SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" .
|
||||
self::idSql($ids, 'id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
||||
$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'
|
||||
|
|
|
|||
|
|
@ -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/view?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; ?>
|
||||
|
|
@ -312,10 +316,14 @@ $err = (string)($cache['error'] ?? '');
|
|||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="btn-icad-refresh"
|
||||
data-animal-id="<?= (int)$animal['id'] ?>">
|
||||
<form method="post" action="/animal/icad" id="form-icad-refresh" class="m-0">
|
||||
<input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="force" value="1">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit" id="btn-icad-refresh">
|
||||
<?= h(t('common.refresh')) ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -198,7 +202,7 @@
|
|||
<div class="card mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="card-title m-0"><?= h(t('animals.dewormer')) ?></h3>
|
||||
<h3 class="card-title m-0">🪱 <?= h(t('animals.dewormer')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.deworming_schedule')) ?></div>
|
||||
</div><button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
|
||||
data-bs-target="#modalDeworming"><?= h(t('common.add')) ?></button>
|
||||
|
|
@ -218,6 +222,7 @@
|
|||
<th><?= h(t('animal.dose')) ?></th>
|
||||
<th><?= h(t('common.weight')) ?></th>
|
||||
<th><?= h(t('animal.recorded_by')) ?></th>
|
||||
<th><span class="visually-hidden"><?= h(t('common.actions')) ?></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><?php foreach ($dewormings as $d): ?><tr>
|
||||
|
|
@ -227,6 +232,16 @@
|
|||
<td><?= $d['weight_kg'] !== null ? h(number_format((float)$d['weight_kg'], 2, ',', ' ')) . ' kg' : '—' ?>
|
||||
</td>
|
||||
<td><?= h($d['actor_name'] ?: '—') ?></td>
|
||||
<td class="text-end"><button type="button" class="btn btn-sm btn-ghost-primary edit-deworming"
|
||||
data-bs-toggle="modal" data-bs-target="#modalDeworming"
|
||||
data-entry-id="<?= (int)$d['id'] ?>"
|
||||
data-dewormer-id="<?= (int)($d['dewormer_id'] ?? 0) ?>"
|
||||
data-administered-on="<?= h($d['administered_on']) ?>"
|
||||
data-dose-text="<?= h($d['dose_text'] ?? '') ?>"
|
||||
data-weight-kg="<?= h($d['weight_kg'] ?? '') ?>"
|
||||
data-next-due-date="<?= h($d['next_due_date'] ?? '') ?>"
|
||||
data-notes="<?= h($d['notes'] ?? '') ?>"
|
||||
aria-label="<?= h(t('common.edit')) ?>">✏️</button></td>
|
||||
</tr><?php endforeach; ?></tbody>
|
||||
</table>
|
||||
</div><?php endif; ?>
|
||||
|
|
@ -362,6 +377,7 @@ ORDER BY created_at ASC
|
|||
<th><?= h(t('animal.start')) ?></th>
|
||||
<th><?= h(t('dashboard.end')) ?></th>
|
||||
<th><?= h(t('common.notes')) ?></th>
|
||||
<th><span class="visually-hidden"><?= h(t('common.actions')) ?></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -388,7 +404,19 @@ ORDER BY created_at ASC
|
|||
<td><?= h($t['start_date']) ?></td>
|
||||
<td><?= $t['end_date'] ? h($t['end_date']) : '—' ?></td>
|
||||
<td><?= $t['notes'] ? h($t['notes']) : '—' ?></td>
|
||||
|
||||
<td class="text-end"><button type="button" class="btn btn-sm btn-ghost-primary edit-treatment"
|
||||
data-bs-toggle="modal" data-bs-target="#modalTreatment"
|
||||
data-entry-id="<?= (int)$t['id'] ?>"
|
||||
data-medication-id="<?= (int)$t['medication_id'] ?>"
|
||||
data-route="<?= h($t['route'] ?? '') ?>" data-dose-text="<?= h($t['dose_text']) ?>"
|
||||
data-start-date="<?= h($t['start_date']) ?>" data-end-date="<?= h($t['end_date'] ?? '') ?>"
|
||||
data-clinic-contact-id="<?= (int)($t['clinic_contact_id'] ?? 0) ?>"
|
||||
data-vet-contact-id="<?= (int)($t['vet_contact_id'] ?? 0) ?>"
|
||||
data-give-morning="<?= (int)$t['give_morning'] ?>"
|
||||
data-give-evening="<?= (int)$t['give_evening'] ?>"
|
||||
data-give-as-needed="<?= (int)$t['give_as_needed'] ?>"
|
||||
data-notes="<?= h($t['notes'] ?? '') ?>"
|
||||
aria-label="<?= h(t('common.edit')) ?>">✏️</button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
|
@ -399,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: ?>
|
||||
|
|
@ -412,6 +443,7 @@ ORDER BY created_at ASC
|
|||
<th><?= h(t('animal.batch_traceability')) ?></th>
|
||||
<th><?= h(t('animal.veterinary_clinic')) ?></th>
|
||||
<th><?= h(t('common.notes')) ?></th>
|
||||
<th class="text-end"><?= h(t('common.actions')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -426,6 +458,15 @@ ORDER BY created_at ASC
|
|||
</div><?php endif; ?></td>
|
||||
<td><?= !empty($v['clinic_name']) ? h($v['clinic_name']) : '—' ?></td>
|
||||
<td><?= $v['notes'] ? h($v['notes']) : '—' ?></td>
|
||||
<td class="text-end"><button type="button" class="btn btn-sm btn-outline-primary edit-vaccine"
|
||||
data-bs-toggle="modal" data-bs-target="#modalVaccine"
|
||||
data-entry-id="<?= (int)$v['id'] ?>" data-vaccine-id="<?= (int)$v['vaccine_id'] ?>"
|
||||
data-done-date="<?= h($v['done_date']) ?>" data-due-date="<?= h($v['due_date'] ?? '') ?>"
|
||||
data-clinic-contact-id="<?= (int)($v['clinic_contact_id'] ?? 0) ?>"
|
||||
data-vet-contact-id="<?= (int)($v['vet_contact_id'] ?? 0) ?>"
|
||||
data-lot="<?= h($v['lot'] ?? '') ?>" data-manufacturer="<?= h($v['manufacturer'] ?? '') ?>"
|
||||
data-batch-expires-on="<?= h($v['batch_expires_on'] ?? '') ?>"
|
||||
data-notes="<?= h($v['notes'] ?? '') ?>"><?= h(t('common.edit')) ?></button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ $currentTime = date('H:i');
|
|||
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="entry_id" value="">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8">
|
||||
|
|
@ -37,6 +38,8 @@ $currentTime = date('H:i');
|
|||
<button class="btn btn-outline-primary" type="button" id="treatmentMedicationAdd"
|
||||
aria-expanded="false" aria-controls="treatmentMedicationNew">+
|
||||
<?= h(t('common.add')) ?></button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal"
|
||||
data-bs-target="#modalProductRename">✏️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
|
|
@ -105,7 +108,7 @@ $currentTime = date('H:i');
|
|||
|
||||
<?php $directoryFieldId = 'treatment';
|
||||
require __DIR__ . '/_directory_medical_fields.php'; ?>
|
||||
<?php require __DIR__ . '/_expense_tariff_field.php'; ?>
|
||||
<div data-treatment-new-only style="display: contents"><?php require __DIR__ . '/_expense_tariff_field.php'; ?></div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= h(t('common.notes')) ?></label>
|
||||
|
|
@ -156,6 +159,7 @@ $currentTime = date('H:i');
|
|||
class="btn-close" data-bs-dismiss="modal" aria-label="<?= h(t('common.close')) ?>"></button>
|
||||
</div>
|
||||
<div class="modal-body"><input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="entry_id" value="">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label"><?= h(t('common.date')) ?></label><input
|
||||
class="form-control" type="date" name="administered_on" value="<?= date('Y-m-d') ?>"
|
||||
|
|
@ -165,7 +169,8 @@ $currentTime = date('H:i');
|
|||
<option value=""><?= h(t('common.not_provided')) ?></option>
|
||||
<?php foreach ($dewormers as $m): ?><option value="<?= (int)$m['id'] ?>">
|
||||
<?= h($m['name']) ?></option><?php endforeach; ?>
|
||||
</select></div>
|
||||
</select><button class="btn btn-sm btn-link px-0" type="button" data-bs-toggle="modal"
|
||||
data-bs-target="#modalProductRename"><?= h(t('catalog.rename')) ?></button></div>
|
||||
<div class="col-md-6"><label class="form-label"><?= h(t('animal.dose')) ?></label><input
|
||||
class="form-control" name="dose_text" placeholder="<?= h(t('deworm.dose_example')) ?>">
|
||||
</div>
|
||||
|
|
@ -173,7 +178,7 @@ $currentTime = date('H:i');
|
|||
<div class="input-group"><input class="form-control" type="number" min="0" step="0.01"
|
||||
name="weight_kg"><span class="input-group-text">kg</span></div>
|
||||
</div>
|
||||
<div class="col-md-8"><label class="form-label"><?= h(t('inventory.batch')) ?></label><select
|
||||
<div class="col-md-8" data-new-entry-only><label class="form-label"><?= h(t('inventory.batch')) ?></label><select
|
||||
class="form-select" name="inventory_batch_id">
|
||||
<option value="">—</option><?php foreach (($inventoryBatches ?? []) as $b) {
|
||||
if ($b['category'] === 'dewormer'): ?><option value="<?= (int)$b['id'] ?>">
|
||||
|
|
@ -181,13 +186,13 @@ $currentTime = date('H:i');
|
|||
</option><?php endif;
|
||||
} ?>
|
||||
</select></div>
|
||||
<div class="col-md-4"><label class="form-label"><?= h(t('inventory.quantity')) ?></label><input
|
||||
<div class="col-md-4" data-new-entry-only><label class="form-label"><?= h(t('inventory.quantity')) ?></label><input
|
||||
class="form-control" type="number" min="0" step="0.01" name="inventory_quantity"></div>
|
||||
<div class="col-12"><label class="form-label"><?= h(t('animal.next_due')) ?> <span
|
||||
class="text-muted">(<?= h(mb_strtolower(t('common.optional'))) ?>)</span></label><input
|
||||
class="form-control" type="date" name="next_due_date">
|
||||
<div class="form-hint"><?= h(t('animal.next_due_hint')) ?></div>
|
||||
</div><?php if (!empty($litters)): ?><div class="col-12"><label class="form-check"><input
|
||||
</div><?php if (!empty($litters)): ?><div class="col-12" data-new-entry-only><label class="form-check"><input
|
||||
class="form-check-input" type="checkbox" name="apply_litter" value="1"><span
|
||||
class="form-check-label"><?= h(t('animal.apply_litter')) ?></span></label></div>
|
||||
<?php endif; ?><div class="col-12"><label
|
||||
|
|
@ -257,6 +262,7 @@ $currentTime = date('H:i');
|
|||
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="entry_id" value="">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
|
|
@ -267,7 +273,8 @@ $currentTime = date('H:i');
|
|||
<option value="<?= (int)$v['id'] ?>"><?= htmlspecialchars($v['name'], ENT_QUOTES) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</select><button class="btn btn-sm btn-link px-0" type="button" data-bs-toggle="modal"
|
||||
data-bs-target="#modalProductRename"><?= h(t('catalog.rename')) ?></button>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
@ -284,7 +291,7 @@ $currentTime = date('H:i');
|
|||
|
||||
<?php $directoryFieldId = 'vaccine';
|
||||
require __DIR__ . '/_directory_medical_fields.php'; ?>
|
||||
<?php require __DIR__ . '/_expense_tariff_field.php'; ?>
|
||||
<div data-new-entry-only style="display: contents"><?php require __DIR__ . '/_expense_tariff_field.php'; ?></div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= h(t('animal.batch')) ?></label>
|
||||
|
|
@ -293,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">
|
||||
|
|
@ -301,7 +312,7 @@ $currentTime = date('H:i');
|
|||
<input class="form-control" type="date" name="batch_expires_on">
|
||||
</div>
|
||||
|
||||
<div class="col-md-8"><label class="form-label"><?= h(t('inventory.batch')) ?></label><select
|
||||
<div class="col-md-8" data-new-entry-only><label class="form-label"><?= h(t('inventory.batch')) ?></label><select
|
||||
class="form-select" name="inventory_batch_id">
|
||||
<option value="">—</option><?php foreach (($inventoryBatches ?? []) as $b) {
|
||||
if ($b['category'] === 'vaccine'): ?><option value="<?= (int)$b['id'] ?>">
|
||||
|
|
@ -309,7 +320,7 @@ $currentTime = date('H:i');
|
|||
</option><?php endif;
|
||||
} ?>
|
||||
</select></div>
|
||||
<div class="col-md-4"><label class="form-label"><?= h(t('inventory.quantity')) ?></label><input
|
||||
<div class="col-md-4" data-new-entry-only><label class="form-label"><?= h(t('inventory.quantity')) ?></label><input
|
||||
class="form-control" type="number" min="0" step="0.01" name="inventory_quantity"></div>
|
||||
|
||||
<div class="col-md-12">
|
||||
|
|
@ -328,6 +339,120 @@ $currentTime = date('H:i');
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const treatmentModal = document.getElementById('modalTreatment');
|
||||
treatmentModal?.addEventListener('show.bs.modal', event => {
|
||||
const form = treatmentModal.querySelector('form');
|
||||
const trigger = event.relatedTarget;
|
||||
const editing = trigger?.classList.contains('edit-treatment') ?? false;
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
form.elements.entry_id.value = editing ? trigger.dataset.entryId : '';
|
||||
if (editing) {
|
||||
for (const name of ['medication_id', 'route', 'dose_text', 'start_date', 'end_date',
|
||||
'clinic_contact_id', 'vet_contact_id', 'notes']) {
|
||||
const key = name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
if (form.elements[name]) form.elements[name].value = trigger.dataset[key] || '';
|
||||
}
|
||||
for (const name of ['give_morning', 'give_evening', 'give_as_needed']) {
|
||||
const key = name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
form.elements[name].checked = trigger.dataset[key] === '1';
|
||||
}
|
||||
}
|
||||
treatmentModal.querySelectorAll('[data-treatment-new-only]').forEach(element => {
|
||||
element.classList.toggle('d-none', editing);
|
||||
element.querySelectorAll('input,select,textarea').forEach(input => input.disabled = editing);
|
||||
});
|
||||
treatmentModal.querySelectorAll('.directory-contact-select').forEach(select => {
|
||||
select.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
});
|
||||
});
|
||||
const prepareEditor = (modalId, triggerClass, fields) => {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) return;
|
||||
modal.addEventListener('show.bs.modal', event => {
|
||||
const form = modal.querySelector('form');
|
||||
const trigger = event.relatedTarget;
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
const editing = trigger?.classList.contains(triggerClass) ?? false;
|
||||
form.elements.entry_id.value = editing ? (trigger.dataset.entryId || '') : '';
|
||||
fields.forEach(([name, dataName]) => {
|
||||
if (editing && form.elements[name]) form.elements[name].value = trigger.dataset[dataName] || '';
|
||||
});
|
||||
modal.querySelectorAll('[data-new-entry-only]').forEach(element => {
|
||||
element.classList.toggle('d-none', editing);
|
||||
element.querySelectorAll('input,select,textarea').forEach(input => input.disabled = editing);
|
||||
});
|
||||
if (editing) {
|
||||
modal.querySelectorAll('.directory-contact-select').forEach(select => {
|
||||
select.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
prepareEditor('modalDeworming', 'edit-deworming', [
|
||||
['dewormer_id', 'dewormerId'], ['administered_on', 'administeredOn'],
|
||||
['dose_text', 'doseText'], ['weight_kg', 'weightKg'],
|
||||
['next_due_date', 'nextDueDate'], ['notes', 'notes'],
|
||||
]);
|
||||
prepareEditor('modalVaccine', 'edit-vaccine', [
|
||||
['vaccine_id', 'vaccineId'], ['done_date', 'doneDate'], ['due_date', 'dueDate'],
|
||||
['clinic_contact_id', 'clinicContactId'], ['vet_contact_id', 'vetContactId'],
|
||||
['lot', 'lot'], ['manufacturer', 'manufacturer'],
|
||||
['batch_expires_on', 'batchExpiresOn'], ['notes', 'notes'],
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="modal modal-blur fade" id="modalProductRename" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form class="modal-content" method="post" action="/animal/product/rename">
|
||||
<div class="modal-header"><h5 class="modal-title"><?= h(t('catalog.rename')) ?></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?= h(t('common.close')) ?>"></button></div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<p class="text-muted small"><?= h(t('catalog.rename_hint')) ?></p>
|
||||
<label class="form-label"><?= h(t('animal.product')) ?></label>
|
||||
<select class="form-select mb-3" id="catalogRenameChoice" required>
|
||||
<option value="">—</option>
|
||||
<optgroup label="<?= h(t('animal.medication')) ?>">
|
||||
<?php foreach ($medications as $product): ?><option value="medication:<?= (int)$product['id'] ?>"
|
||||
data-name="<?= h($product['name']) ?>"><?= h($product['name']) ?></option><?php endforeach; ?>
|
||||
</optgroup>
|
||||
<optgroup label="<?= h(t('animals.dewormer')) ?>">
|
||||
<?php foreach ($dewormers as $product): ?><option value="dewormer:<?= (int)$product['id'] ?>"
|
||||
data-name="<?= h($product['name']) ?>"><?= h($product['name']) ?></option><?php endforeach; ?>
|
||||
</optgroup>
|
||||
<optgroup label="<?= h(t('animal.vaccine')) ?>">
|
||||
<?php foreach ($vaccines as $product): ?><option value="vaccine:<?= (int)$product['id'] ?>"
|
||||
data-name="<?= h($product['name']) ?>"><?= h($product['name']) ?></option><?php endforeach; ?>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input type="hidden" name="category" id="catalogRenameCategory">
|
||||
<input type="hidden" name="product_id" id="catalogRenameId">
|
||||
<label class="form-label"><?= h(t('common.name')) ?></label>
|
||||
<input class="form-control" name="name" id="catalogRenameName" maxlength="150" required>
|
||||
</div>
|
||||
<div class="modal-footer"><button type="button" class="btn me-auto" data-bs-dismiss="modal"><?= h(t('common.cancel')) ?></button>
|
||||
<button type="submit" class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const choice = document.getElementById('catalogRenameChoice');
|
||||
choice?.addEventListener('change', () => {
|
||||
const [category, id] = choice.value.split(':');
|
||||
document.getElementById('catalogRenameCategory').value = category || '';
|
||||
document.getElementById('catalogRenameId').value = id || '';
|
||||
document.getElementById('catalogRenameName').value = choice.selectedOptions[0]?.dataset.name || '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="modal modal-blur fade" id="modalPrescription" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<form class="modal-content" method="post" action="/animal/prescription/save" enctype="multipart/form-data">
|
||||
|
|
@ -498,13 +623,29 @@ $currentTime = date('H:i');
|
|||
</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">
|
||||
|
|
@ -573,6 +714,22 @@ $currentTime = date('H:i');
|
|||
</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">
|
||||
|
|
@ -1003,6 +1160,7 @@ $currentTime = date('H:i');
|
|||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
const form = document.getElementById('form-icad-refresh');
|
||||
const btn = document.getElementById('btn-icad-refresh');
|
||||
const body = document.getElementById('icad-card-body');
|
||||
const tr = <?= json_encode(['error' => t('animal.icad_error'), 'empty' => t('animal.no_icad'), 'date' =>
|
||||
|
|
@ -1013,7 +1171,7 @@ $currentTime = date('H:i');
|
|||
'appearance' => t('animal.appearance'), 'history' => t('animal.icad_history'), 'fetching' => t(
|
||||
'animal.icad_fetching'), 'fetchError' => t('animal.icad_fetch_error')
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?> ;
|
||||
if (!btn || !body) return;
|
||||
if (!form || !btn || !body) return;
|
||||
|
||||
function esc(s) {
|
||||
return (s ?? '').toString()
|
||||
|
|
@ -1083,8 +1241,8 @@ ${ownerHtml}
|
|||
`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const id = btn.dataset.animalId;
|
||||
async function refresh(event) {
|
||||
event.preventDefault();
|
||||
btn.disabled = true;
|
||||
const oldTxt = btn.innerText;
|
||||
btn.innerText = '…';
|
||||
|
|
@ -1099,12 +1257,12 @@ ${ownerHtml}
|
|||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
id,
|
||||
force: '1',
|
||||
csrf: <?= json_encode(Auth::csrf()) ?>
|
||||
})
|
||||
credentials: 'same-origin',
|
||||
body: new URLSearchParams(new FormData(form))
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`HTTP ${r.status}: ${await r.text()}`);
|
||||
}
|
||||
const j = await r.json();
|
||||
body.innerHTML = renderIcad(j);
|
||||
} catch (e) {
|
||||
|
|
@ -1117,7 +1275,7 @@ ${ownerHtml}
|
|||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', refresh);
|
||||
form.addEventListener('submit', refresh);
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -57,8 +57,6 @@
|
|||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[data-bs-target="#modalDeworming"]').forEach(b => b.setAttribute(
|
||||
'data-bs-target', '#modalDewormingCatalog'));
|
||||
const t = document.getElementById('dewormerNewToggle'),
|
||||
p = document.getElementById('dewormerNewFields');
|
||||
t?.addEventListener('change', () => p?.classList.toggle('d-none', !t.checked));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
foreach (($expenseTariffs ?? []) as $t):if ($expenseClinic !== $t['clinic_name']):if ($expenseClinic !== ''):?>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -183,6 +183,8 @@ if ($felvPos) {
|
|||
</div>
|
||||
<?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']) ? (!empty($animal['birth_is_estimated']) ? '~ ' : '') . h(date('d/m/Y', strtotime((string)$animal['birth_date']))) : '—' ?></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ $animalRows = static function (array$rows, string$title, string$color, string$ic
|
|||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div class="d-flex flex-wrap gap-2"><a class="btn" href="/directory">←
|
||||
<?= h(t('directory.back')) ?></a><?php if ($expensePeriodRequested): ?><a class="btn btn-outline-orange"
|
||||
href="/statistics?<?= h(http_build_query(['year' => $expensePeriodYear ?? 'all','municipality' => $expensePeriodMunicipality])) ?>">←
|
||||
href="/statistics?<?= h(http_build_query(($expensePeriodQuery ?? ['period_type' => 'all']) + ['municipality' => $expensePeriodMunicipality])) ?>">←
|
||||
<?= h(t('directory.back_statistics')) ?></a><?php endif; ?></div><a class="btn btn-primary"
|
||||
href="/directory/edit?id=<?= (int)$contact['id'] ?>"><?= h(t('common.edit')) ?></a>
|
||||
</div>
|
||||
|
|
@ -78,7 +78,7 @@ if ($address): ?><div>📍 <?= h($address) ?></div>
|
|||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="subheader"><?= h(t('directory.total_invoiced')) ?><?php if ($expensePeriodRequested): ?> ·
|
||||
<?= h($expensePeriodYear ?? t('statistics.all_years')) ?><?php if ($expensePeriodMunicipality !== ''): ?>
|
||||
<?= h($expensePeriodLabel ?? t('statistics.all_years')) ?><?php if ($expensePeriodMunicipality !== ''): ?>
|
||||
· <?= h($expensePeriodMunicipality) ?><?php endif; ?><?php endif; ?></div>
|
||||
<div class="h1 mb-0 text-orange"><?= h($money((int)$expenseTotal)) ?></div>
|
||||
<div class="text-muted small"><?= h(t('directory.expense_count', ['count' => (int)$expenseCount])) ?>
|
||||
|
|
|
|||
|
|
@ -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.27</span>
|
||||
<h3>Recherche tolérante</h3>
|
||||
<p class="text-muted">Les fiches sont retrouvées par nom, code ou identification sans que les
|
||||
accents ni les différences de casse empêchent le résultat.</p>
|
||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.35</span>
|
||||
<h3>Des pages publiques riches et lisibles</h3>
|
||||
<p class="text-muted">Les pages personnalisées accueillent maintenant tableaux responsives, listes,
|
||||
citations et code tout en conservant un rendu strictement sécurisé.</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>
|
||||
|
|
|
|||
|
|
@ -14,12 +14,7 @@ $obfuscate = static fn (string$value): string => implode('', array_reverse(mb_st
|
|||
class="contact-obfuscated"><?= h($obfuscate($settings['association_email'])) ?></span></div>
|
||||
<?php endif; ?></div>
|
||||
<div class="social-links">
|
||||
<?php $socialNetworks = [
|
||||
'facebook' => ['Facebook','f'], 'instagram' => ['Instagram','◎'], 'youtube' => ['YouTube','▶'],
|
||||
'tiktok' => ['TikTok','♪'], 'whatsapp' => ['WhatsApp','☎'], 'linkedin' => ['LinkedIn','in'],
|
||||
'x' => ['X','𝕏'], 'snapchat' => ['Snapchat','👻'],
|
||||
];
|
||||
foreach ($socialNetworks as $network => [$label,$icon]):
|
||||
<?php foreach ($socialNetworks as $network => [$label,$icon]):
|
||||
$url = trim((string)($settings['public_site_' . $network . '_url'] ?? ''));
|
||||
if ($url === '') continue; ?>
|
||||
<a class="social-link social-<?= h($network) ?>" href="<?= h($url) ?>" target="_blank" rel="noopener noreferrer"><span class="social-icon" aria-hidden="true"><?= h($icon) ?></span><span><?= h($label) ?></span></a>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,28 @@ $requestPath = rtrim((string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_
|
|||
$activePage = match($requestPath) {
|
||||
'/','/site' => 'home','/animaux','/site/animaux','/adoption','/site/animal' => 'animals','/presentation','/site/presentation' => 'about','/dons','/site/dons' => 'donation','/contact','/site/contact' => 'contact',default => ''
|
||||
};
|
||||
$activeCustomSlug = '';
|
||||
if (preg_match('#^/site/page/([a-z0-9-]+)$#', $requestPath, $match)) $activeCustomSlug = $match[1];
|
||||
elseif (PublicSiteController::usesRoot() && preg_match('#^/([a-z0-9-]+)$#', $requestPath, $match)) $activeCustomSlug = $match[1];
|
||||
$activeSpecies = (string)($selectedSpecies ?? ($animal['species'] ?? ''));
|
||||
$navClass = static fn (bool$active): string => $active ? ' class="active" aria-current="page"' : '';
|
||||
$fullAddress = implode(', ', array_filter([$settings['association_address'] ?? '',trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? ''))]));
|
||||
$hasContactMap = $activePage === 'contact' && is_numeric($settings['association_latitude'] ?? null) && is_numeric($settings['association_longitude'] ?? null);
|
||||
$socialNetworks = [
|
||||
'facebook' => ['Facebook','f'], 'instagram' => ['Instagram','◎'], 'youtube' => ['YouTube','▶'],
|
||||
'tiktok' => ['TikTok','♪'], 'whatsapp' => ['WhatsApp','☎'], 'linkedin' => ['LinkedIn','in'],
|
||||
'x' => ['X','𝕏'], 'snapchat' => ['Snapchat','👻'],
|
||||
];
|
||||
$customPagesBySlug = [];
|
||||
foreach (($publicPages ?? []) as $publicPage) $customPagesBySlug[$publicPage['slug']] = $publicPage;
|
||||
$navigationOrder = json_decode((string)($settings['public_site_navigation_order'] ?? '[]'), true);
|
||||
$navigationOrder = is_array($navigationOrder) ? $navigationOrder : [];
|
||||
foreach (['home','animals','about','donation','contact'] as $defaultNavigationItem) {
|
||||
if (!in_array($defaultNavigationItem, $navigationOrder, true)) $navigationOrder[] = $defaultNavigationItem;
|
||||
}
|
||||
foreach (array_keys($customPagesBySlug) as $slug) {
|
||||
if (!in_array('custom:' . $slug, $navigationOrder, true)) $navigationOrder[] = 'custom:' . $slug;
|
||||
}
|
||||
if (!function_exists('public_age')) {
|
||||
function public_age(array$a): string
|
||||
{
|
||||
|
|
@ -99,10 +117,6 @@ if (!function_exists('public_age')) {
|
|||
--line: #eadfd5
|
||||
}
|
||||
|
||||
<?php if ($theme === 'custom' && trim((string)($settings['public_site_custom_css'] ?? '')) !== ''): ?>
|
||||
<?= $settings['public_site_custom_css'] ?>
|
||||
<?php endif; ?>
|
||||
|
||||
* {
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
|
@ -174,6 +188,32 @@ if (!function_exists('public_age')) {
|
|||
color: var(--accent-dark)
|
||||
}
|
||||
|
||||
.nav .nav-social-link {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 36px;
|
||||
color: #fff;
|
||||
font-size: .9rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
box-shadow: 0 3px 9px rgba(0, 0, 0, .12)
|
||||
}
|
||||
|
||||
.nav .nav-social-link:hover,
|
||||
.nav .nav-social-link:focus-visible {
|
||||
color: #fff;
|
||||
filter: brightness(.9);
|
||||
transform: translateY(-1px)
|
||||
}
|
||||
|
||||
.nav .nav-social-snapchat,
|
||||
.nav .nav-social-snapchat:hover,
|
||||
.nav .nav-social-snapchat:focus-visible { color: #111 }
|
||||
|
||||
.nav a.active {
|
||||
font-weight: 800
|
||||
}
|
||||
|
|
@ -225,6 +265,40 @@ if (!function_exists('public_age')) {
|
|||
border-radius: var(--radius)
|
||||
}
|
||||
|
||||
.public-markdown-table {
|
||||
max-width: 100%;
|
||||
margin: 1.25rem 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) / 2)
|
||||
}
|
||||
|
||||
.public-markdown-table table {
|
||||
width: 100%;
|
||||
min-width: 420px;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface)
|
||||
}
|
||||
|
||||
.public-markdown-table th,
|
||||
.public-markdown-table td {
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top
|
||||
}
|
||||
|
||||
.public-markdown-table th { background: var(--paper); font-weight: 800 }
|
||||
.public-markdown-table tr:last-child td { border-bottom: 0 }
|
||||
.public-markdown-table .text-center { text-align: center }
|
||||
.public-markdown-table .text-right { text-align: right }
|
||||
blockquote { margin: 1.25rem 0; padding: .75rem 1rem; border-left: 4px solid var(--accent); background: var(--surface) }
|
||||
blockquote p { margin: 0 }
|
||||
pre { max-width: 100%; overflow: auto; padding: 1rem; border-radius: calc(var(--radius) / 2); background: var(--ink); color: var(--surface) }
|
||||
:not(pre)>code { padding: .12rem .35rem; border-radius: 4px; background: var(--line) }
|
||||
.task-list-item { list-style: none }
|
||||
.task-marker { color: var(--accent-dark) }
|
||||
|
||||
.section {
|
||||
padding: 52px 0
|
||||
}
|
||||
|
|
@ -476,6 +550,9 @@ if (!function_exists('public_age')) {
|
|||
}
|
||||
|
||||
</style>
|
||||
<?php if ($theme === 'custom' && trim((string)($settings['public_site_custom_css'] ?? '')) !== ''): ?>
|
||||
<style id="public-site-custom-css"><?= $settings['public_site_custom_css'] ?></style>
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -484,17 +561,23 @@ if (!function_exists('public_age')) {
|
|||
href="<?= h($siteUrl()) ?>"><?php if (AssociationBrand::path()): ?><img src="<?= h($siteUrl('logo')) ?>"
|
||||
alt=""><?php endif; ?><span><?= h($siteName) ?></span></a>
|
||||
<nav class="nav" aria-label="<?= h(t('public_site.navigation')) ?>">
|
||||
<a<?= $navClass($activePage === 'home') ?> href="<?= h($siteUrl()) ?>">
|
||||
<?= h(t('public_site.home')) ?></a><?php foreach ($publicSpecies as $species): ?>
|
||||
<a<?= $navClass($activePage === 'animals' && $activeSpecies === $species['code']) ?>
|
||||
href="<?= h($siteUrl('animals', ['espece' => $species['code']])) ?>">
|
||||
<?= h(($species['icon'] ?: '🐾') . ' ' . $species['name']) ?></a><?php endforeach; ?><?php if (($settings['public_site_about_enabled'] ?? '0') === '1'): ?>
|
||||
<a<?= $navClass($activePage === 'about') ?> href="<?= h($siteUrl('about')) ?>">
|
||||
<?= h($settings['public_site_about_title']) ?></a><?php endif; ?>
|
||||
<a<?= $navClass($activePage === 'donation') ?> href="<?= h($siteUrl('donation')) ?>">
|
||||
<?= h(t('public_site.donation')) ?></a>
|
||||
<a<?= $navClass($activePage === 'contact') ?> href="<?= h($siteUrl('contact')) ?>">
|
||||
<?= h(t('public_site.contact')) ?></a>
|
||||
<?php foreach ($navigationOrder as $navigationItem): ?>
|
||||
<?php if ($navigationItem === 'home'): ?><a<?= $navClass($activePage === 'home') ?> href="<?= h($siteUrl()) ?>"><?= h(t('public_site.home')) ?></a>
|
||||
<?php elseif ($navigationItem === 'animals'): foreach ($publicSpecies as $species): ?><a<?= $navClass($activePage === 'animals' && $activeSpecies === $species['code']) ?> href="<?= h($siteUrl('animals', ['espece' => $species['code']])) ?>"><?= h(($species['icon'] ?: '🐾') . ' ' . $species['name']) ?></a><?php endforeach; ?>
|
||||
<?php elseif ($navigationItem === 'about' && ($settings['public_site_about_enabled'] ?? '0') === '1'): ?><a<?= $navClass($activePage === 'about') ?> href="<?= h($siteUrl('about')) ?>"><?= h($settings['public_site_about_title']) ?></a>
|
||||
<?php elseif ($navigationItem === 'donation'): ?><a<?= $navClass($activePage === 'donation') ?> href="<?= h($siteUrl('donation')) ?>"><?= h(t('public_site.donation')) ?></a>
|
||||
<?php elseif ($navigationItem === 'contact'): ?><a<?= $navClass($activePage === 'contact') ?> href="<?= h($siteUrl('contact')) ?>"><?= h(t('public_site.contact')) ?></a>
|
||||
<?php elseif (str_starts_with((string)$navigationItem, 'custom:')):
|
||||
$navigationSlug = substr((string)$navigationItem, 7);
|
||||
$navigationPage = $customPagesBySlug[$navigationSlug] ?? null;
|
||||
if ($navigationPage): ?><a<?= $navClass($activeCustomSlug === $navigationSlug) ?> href="<?= h($siteUrl('custom', ['slug' => $navigationSlug])) ?>"><?= h($navigationPage['title']) ?></a><?php endif; endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($socialNetworks as $network => [$label,$icon]):
|
||||
$socialUrl = trim((string)($settings['public_site_' . $network . '_url'] ?? ''));
|
||||
if ($socialUrl === '') continue; ?>
|
||||
<a class="nav-social-link nav-social-<?= h($network) ?> social-<?= h($network) ?>"
|
||||
href="<?= h($socialUrl) ?>" target="_blank" rel="noopener noreferrer"
|
||||
aria-label="<?= h($label) ?>" title="<?= h($label) ?>"><span aria-hidden="true"><?= h($icon) ?></span></a><?php endforeach; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
17
app/Views/settings_catalogs.php
Normal file
17
app/Views/settings_catalogs.php
Normal 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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings/public-site/save"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
|
||||
<input type="hidden" id="public-site-navigation-order" name="public_site_navigation_order" value="<?= h($settings['public_site_navigation_order'] ?? '') ?>">
|
||||
<div class="card mb-3">
|
||||
<div class="card-status-top bg-green"></div>
|
||||
<div class="card-header">
|
||||
|
|
@ -78,10 +79,17 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">🏠 <?= h(t('public_site.home')) ?></h3>
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
<div><h3 class="mb-0"><?= h(t('public_site.site_pages')) ?></h3><div class="text-muted small"><?= h(t('public_site.site_pages_help')) ?></div></div>
|
||||
<button class="btn btn-outline-primary" type="button" id="public-page-add">+ <?= h(t('public_site.add_page')) ?></button>
|
||||
</div>
|
||||
<div id="public-pages-sortable" class="mb-3">
|
||||
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="home" draggable="true" open>
|
||||
<summary class="card-header gap-2">
|
||||
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
|
||||
<h3 class="card-title">🏠 <?= h(t('public_site.home')) ?></h3>
|
||||
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
|
||||
</summary>
|
||||
<div class="card-body row g-3">
|
||||
<div class="col-12"><label class="form-label"><?= h(t('common.title')) ?></label><input class="form-control"
|
||||
name="public_site_home_title" value="<?= h($settings['public_site_home_title']) ?>" required></div>
|
||||
|
|
@ -89,11 +97,14 @@
|
|||
class="form-control public-rich-text" name="public_site_home_text"
|
||||
rows="4"><?= h($settings['public_site_home_text']) ?></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
</details>
|
||||
<div class="card mb-2 public-sortable-page" data-page-key="animals" draggable="true"><div class="card-header gap-2"><span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span><h3 class="card-title">🐾 <?= h(t('public_site.animals')) ?></h3><span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button></span></div></div>
|
||||
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="about" draggable="true">
|
||||
<summary class="card-header gap-2">
|
||||
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
|
||||
<h3 class="card-title">📖 <?= h(t('public_site.free_page')) ?></h3>
|
||||
</div>
|
||||
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
|
||||
</summary>
|
||||
<div class="card-body row g-3">
|
||||
<div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox"
|
||||
name="public_site_about_enabled" value="1"
|
||||
|
|
@ -106,11 +117,13 @@
|
|||
class="form-control public-rich-text" name="public_site_about_text"
|
||||
rows="5"><?= h($settings['public_site_about_text']) ?></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
</details>
|
||||
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="donation" draggable="true">
|
||||
<summary class="card-header gap-2">
|
||||
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
|
||||
<h3 class="card-title">💝 <?= h(t('public_site.donation')) ?></h3>
|
||||
</div>
|
||||
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
|
||||
</summary>
|
||||
<div class="card-body row g-3">
|
||||
<div class="col-md-4"><label class="form-label"><?= h(t('common.title')) ?></label><input
|
||||
class="form-control" name="public_site_donation_title"
|
||||
|
|
@ -122,11 +135,13 @@
|
|||
class="form-control public-rich-text" name="public_site_donation_text"
|
||||
rows="4"><?= h($settings['public_site_donation_text']) ?></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
</details>
|
||||
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="contact" draggable="true">
|
||||
<summary class="card-header gap-2">
|
||||
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
|
||||
<h3 class="card-title">✉️ <?= h(t('public_site.contact')) ?></h3>
|
||||
</div>
|
||||
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
|
||||
</summary>
|
||||
<div class="card-body row g-3">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info py-2"><?= h(t('public_site.contact_reuse')) ?></div><label
|
||||
|
|
@ -143,11 +158,41 @@
|
|||
value="<?= h($settings['public_site_' . $network . '_url']) ?>" placeholder="https://..."></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</details>
|
||||
<?php foreach (($customPages ?? []) as $index => $page): ?>
|
||||
<details class="card mb-2 public-page-panel public-custom-page public-sortable-page" data-page-key="custom:<?= h($page['slug']) ?>" draggable="true">
|
||||
<summary class="card-header gap-2">
|
||||
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
|
||||
<h3 class="card-title public-page-summary-title"><?= h($page['title']) ?></h3>
|
||||
<span class="ms-auto d-flex gap-1 public-page-actions">
|
||||
<button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" title="<?= h(t('public_site.move_up')) ?>" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button>
|
||||
<button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" title="<?= h(t('public_site.move_down')) ?>" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button>
|
||||
<span class="public-page-chevron" aria-hidden="true">⌄</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="card-body row g-3">
|
||||
<input type="hidden" name="pages[<?= $index ?>][id]" value="<?= (int) $page['id'] ?>">
|
||||
<input class="public-page-position" type="hidden" name="pages[<?= $index ?>][position]" value="<?= $index ?>">
|
||||
<div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox" name="pages[<?= $index ?>][enabled]" value="1" <?= (int) $page['enabled'] === 1 ? 'checked' : '' ?>><span class="form-check-label"><?= h(t('public_site.page_published')) ?></span></label></div>
|
||||
<div class="col-md-7"><label class="form-label"><?= h(t('public_site.page_title')) ?></label><input class="form-control public-page-title" name="pages[<?= $index ?>][title]" value="<?= h($page['title']) ?>" required></div>
|
||||
<div class="col-md-5"><label class="form-label"><?= h(t('public_site.page_slug')) ?></label><input class="form-control public-page-slug" name="pages[<?= $index ?>][slug]" value="<?= h($page['slug']) ?>" required><div class="form-hint"><?= h(t('public_site.page_slug_help')) ?></div></div>
|
||||
<div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea class="form-control public-rich-text" name="pages[<?= $index ?>][content]" rows="8"><?= h($page['content']) ?></textarea></div>
|
||||
<div class="col-12"><label class="form-check text-danger"><input class="form-check-input" type="checkbox" name="pages[<?= $index ?>][delete]" value="1"><span class="form-check-label"><?= h(t('public_site.delete_page')) ?></span></label></div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<template id="public-page-template">
|
||||
<details class="card mb-2 public-page-panel public-custom-page public-sortable-page" data-page-key="custom:" draggable="true" open>
|
||||
<summary class="card-header gap-2"><span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span><h3 class="card-title public-page-summary-title"><?= h(t('public_site.new_page')) ?></h3><span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span></summary>
|
||||
<div class="card-body row g-3"><input class="public-page-position" type="hidden" name="pages[__INDEX__][position]" value="0"><div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox" name="pages[__INDEX__][enabled]" value="1" checked><span class="form-check-label"><?= h(t('public_site.page_published')) ?></span></label></div><div class="col-md-7"><label class="form-label"><?= h(t('public_site.page_title')) ?></label><input class="form-control public-page-title" name="pages[__INDEX__][title]" required></div><div class="col-md-5"><label class="form-label"><?= h(t('public_site.page_slug')) ?></label><input class="form-control public-page-slug" name="pages[__INDEX__][slug]" required><div class="form-hint"><?= h(t('public_site.page_slug_help')) ?></div></div><div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea class="form-control public-rich-text" name="pages[__INDEX__][content]" rows="8"></textarea></div><div class="col-12"><button class="btn btn-outline-danger public-page-remove" type="button"><?= h(t('public_site.delete_page')) ?></button></div></div>
|
||||
</details>
|
||||
</template>
|
||||
<details class="card mb-3 public-page-panel">
|
||||
<summary class="card-header">
|
||||
<h3 class="card-title">⚖️ <?= h(t('public_site.legal_privacy')) ?></h3>
|
||||
</div>
|
||||
<span class="public-page-chevron" aria-hidden="true">⌄</span>
|
||||
</summary>
|
||||
<div class="card-body row g-3">
|
||||
<div class="col-md-6"><label class="form-label"><?= h(t('public_site.legal')) ?></label><textarea
|
||||
class="form-control" name="public_site_legal_text"
|
||||
|
|
@ -157,9 +202,12 @@
|
|||
rows="16"><?= h(PublicSiteController::privacyText($settings)) ?></textarea></div>
|
||||
<div class="col-12 form-hint"><?= h(t('public_site.legal_hint')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="text-end mb-4"><button class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
|
||||
</form>
|
||||
<style>
|
||||
.public-page-panel>summary{cursor:pointer;display:flex;align-items:center;list-style:none}.public-page-panel>summary::-webkit-details-marker{display:none}.public-page-chevron{display:inline-flex;transition:transform .2s;font-size:1.25rem}.public-page-panel[open]>summary .public-page-chevron{transform:rotate(180deg)}.public-page-drag{cursor:grab;font-size:1.35rem;color:var(--tblr-secondary);padding:.25rem}.public-custom-page.is-dragging{opacity:.45}.public-custom-page.drag-over{outline:2px dashed var(--tblr-primary);outline-offset:2px}.public-page-actions button{position:relative;z-index:2}
|
||||
</style>
|
||||
<script>
|
||||
let activePublicEditor = null;
|
||||
const imageLibrary = document.getElementById('public-image-library');
|
||||
|
|
@ -173,11 +221,24 @@ const insertImage = (button) => {
|
|||
textarea.setRangeText(`\n`, textarea.selectionStart, textarea.selectionEnd, 'end');
|
||||
textarea.focus();
|
||||
};
|
||||
document.querySelectorAll('.public-rich-text').forEach((textarea) => {
|
||||
const enhanceTextarea = (textarea) => {
|
||||
if (textarea.dataset.autoResize === '1') return;
|
||||
textarea.dataset.autoResize = '1';
|
||||
const resize = () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(140, textarea.scrollHeight + 8) + 'px';
|
||||
};
|
||||
textarea.addEventListener('input', resize);
|
||||
resize();
|
||||
};
|
||||
const enhancePublicEditor = (textarea) => {
|
||||
enhanceTextarea(textarea);
|
||||
if (textarea.dataset.richEditor === '1') return;
|
||||
textarea.dataset.richEditor = '1';
|
||||
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'btn-list mb-2';
|
||||
const actions = [['B', '**', '**'], ['I', '*', '*'], ['Titre', '## ', ''], ['Liste', '- ', ''], ['Lien', '[', '](https://)']];
|
||||
const actions = [['B', '**', '**'], ['I', '*', '*'], ['Titre', '## ', ''], ['Liste', '- ', ''], ['Lien', '[', '](https://)'], [<?= json_encode(t('public_site.markdown_table'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>, '| Colonne 1 | Colonne 2 |\n| --- | --- |\n| Valeur 1 | Valeur 2 |\n', '']];
|
||||
actions.forEach(([label, before, after]) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
|
|
@ -203,7 +264,9 @@ document.querySelectorAll('.public-rich-text').forEach((textarea) => {
|
|||
});
|
||||
toolbar.append(imageButton);
|
||||
textarea.before(toolbar);
|
||||
});
|
||||
};
|
||||
document.querySelectorAll('textarea').forEach(enhanceTextarea);
|
||||
document.querySelectorAll('.public-rich-text').forEach(enhancePublicEditor);
|
||||
document.querySelectorAll('.public-image-insert').forEach((button) => {
|
||||
button.addEventListener('click', () => insertImage(button));
|
||||
});
|
||||
|
|
@ -235,4 +298,86 @@ document.getElementById('public-image-upload').addEventListener('submit', async
|
|||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const pagesContainer = document.getElementById('public-pages-sortable');
|
||||
const navigationOrderInput = document.getElementById('public-site-navigation-order');
|
||||
let publicPageIndex = <?= (int) count($customPages ?? []) ?>;
|
||||
const refreshPagePositions = () => {
|
||||
const pages = [...pagesContainer.querySelectorAll('.public-sortable-page')];
|
||||
pages.forEach((page, position) => {
|
||||
const input = page.querySelector('.public-page-position');
|
||||
if (input) input.value = String(position);
|
||||
page.querySelector('.public-page-up')?.toggleAttribute('disabled', position === 0);
|
||||
page.querySelector('.public-page-down')?.toggleAttribute('disabled', position === pages.length - 1);
|
||||
});
|
||||
navigationOrderInput.value = JSON.stringify(pages.map((page) => page.dataset.pageKey).filter((key) => key && key !== 'custom:'));
|
||||
};
|
||||
const slugify = (value) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
|
||||
const wirePage = (page) => {
|
||||
const title = page.querySelector('.public-page-title');
|
||||
const slug = page.querySelector('.public-page-slug');
|
||||
let slugEdited = Boolean(slug?.value);
|
||||
slug?.addEventListener('input', () => { slugEdited = Boolean(slug.value); });
|
||||
slug?.addEventListener('input', () => { page.dataset.pageKey = 'custom:' + slugify(slug.value); refreshPagePositions(); });
|
||||
title?.addEventListener('input', () => {
|
||||
page.querySelector('.public-page-summary-title').textContent = title.value.trim() || <?= json_encode(t('public_site.new_page'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||
if (!slugEdited && slug) {
|
||||
slug.value = slugify(title.value);
|
||||
page.dataset.pageKey = 'custom:' + slug.value;
|
||||
refreshPagePositions();
|
||||
}
|
||||
});
|
||||
page.querySelectorAll('.public-page-actions button').forEach((button) => button.addEventListener('click', (event) => event.stopPropagation()));
|
||||
page.querySelectorAll('input,textarea,button').forEach((control) => control.addEventListener('mousedown', () => { page.draggable = false; }));
|
||||
page.querySelector('.public-page-drag')?.addEventListener('mousedown', () => { page.draggable = true; });
|
||||
page.querySelector('.public-page-up')?.addEventListener('click', () => {
|
||||
if (page.previousElementSibling) pagesContainer.insertBefore(page, page.previousElementSibling);
|
||||
refreshPagePositions();
|
||||
});
|
||||
page.querySelector('.public-page-down')?.addEventListener('click', () => {
|
||||
if (page.nextElementSibling) pagesContainer.insertBefore(page.nextElementSibling, page);
|
||||
refreshPagePositions();
|
||||
});
|
||||
page.querySelector('.public-page-remove')?.addEventListener('click', () => { page.remove(); refreshPagePositions(); });
|
||||
page.querySelectorAll('textarea').forEach(enhanceTextarea);
|
||||
page.querySelectorAll('.public-rich-text').forEach(enhancePublicEditor);
|
||||
page.addEventListener('dragstart', (event) => {
|
||||
if (!event.target.closest('.public-page-drag') && document.activeElement !== page.querySelector('.public-page-drag')) {
|
||||
// The whole card remains draggable for browser compatibility.
|
||||
}
|
||||
page.classList.add('is-dragging');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
page.addEventListener('dragend', () => {
|
||||
page.draggable = true;
|
||||
page.classList.remove('is-dragging');
|
||||
pagesContainer.querySelectorAll('.drag-over').forEach((item) => item.classList.remove('drag-over'));
|
||||
refreshPagePositions();
|
||||
});
|
||||
};
|
||||
let savedNavigationOrder = [];
|
||||
try { savedNavigationOrder = JSON.parse(navigationOrderInput.value || '[]'); } catch (_) {}
|
||||
const initialPages = [...pagesContainer.querySelectorAll('.public-sortable-page')];
|
||||
const orderedPages = savedNavigationOrder.map((key) => initialPages.find((item) => item.dataset.pageKey === key)).filter(Boolean);
|
||||
[...orderedPages, ...initialPages.filter((item) => !orderedPages.includes(item))].forEach((page) => pagesContainer.append(page));
|
||||
pagesContainer.querySelectorAll('.public-sortable-page').forEach(wirePage);
|
||||
pagesContainer.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
const dragging = pagesContainer.querySelector('.is-dragging');
|
||||
const target = event.target.closest('.public-sortable-page');
|
||||
if (!dragging || !target || dragging === target) return;
|
||||
pagesContainer.querySelectorAll('.drag-over').forEach((item) => item.classList.remove('drag-over'));
|
||||
target.classList.add('drag-over');
|
||||
const box = target.getBoundingClientRect();
|
||||
pagesContainer.insertBefore(dragging, event.clientY < box.top + box.height / 2 ? target : target.nextElementSibling);
|
||||
});
|
||||
document.getElementById('public-page-add').addEventListener('click', () => {
|
||||
const html = document.getElementById('public-page-template').innerHTML.replaceAll('__INDEX__', String(publicPageIndex++));
|
||||
pagesContainer.insertAdjacentHTML('beforeend', html);
|
||||
const page = pagesContainer.lastElementChild;
|
||||
wirePage(page);
|
||||
refreshPagePositions();
|
||||
page.querySelector('.public-page-title').focus();
|
||||
});
|
||||
refreshPagePositions();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -54,12 +54,19 @@ $groups = [
|
|||
<div class="text-muted"><?= $municipality !== '' ? t('statistics.city_help') : t('statistics.help') ?></div>
|
||||
</div>
|
||||
<form method="get" action="/statistics" class="d-flex flex-wrap align-items-end gap-2">
|
||||
<div><label class="form-label"><?= h(t('statistics.period')) ?></label><select class="form-select"
|
||||
name="year">
|
||||
<option value="all" <?= $allYears ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option>
|
||||
<?php foreach (array_reverse($years) as $y): ?><option value="<?= $y ?>"
|
||||
<?= !$allYears && $y === $year ? 'selected' : '' ?>><?= $y ?></option><?php endforeach; ?>
|
||||
<div><label class="form-label"><?= h(t('statistics.period')) ?></label><select class="form-select" name="period_type" id="statisticsPeriodType">
|
||||
<option value="all" <?= $periodType === 'all' ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option>
|
||||
<option value="year" <?= $periodType === 'year' ? 'selected' : '' ?>><?= h(t('statistics.by_year')) ?></option>
|
||||
<option value="month" <?= $periodType === 'month' ? 'selected' : '' ?>><?= h(t('statistics.by_month')) ?></option>
|
||||
<option value="custom" <?= $periodType === 'custom' ? 'selected' : '' ?>><?= h(t('statistics.custom_period')) ?></option>
|
||||
</select></div>
|
||||
<div data-period-field="year"><label class="form-label"><?= h(t('statistics.year')) ?></label><select class="form-select" name="year">
|
||||
<?php foreach (array_reverse($years) as $y): ?><option value="<?= $y ?>"
|
||||
<?= $y === $year ? 'selected' : '' ?>><?= $y ?></option><?php endforeach; ?>
|
||||
</select></div>
|
||||
<div data-period-field="month"><label class="form-label"><?= h(t('statistics.month')) ?></label><input class="form-control" type="month" name="month" value="<?= h($month) ?>"></div>
|
||||
<div data-period-field="custom"><label class="form-label"><?= h(t('statistics.from')) ?></label><input class="form-control" type="date" name="date_from" value="<?= h($dateFrom) ?>"></div>
|
||||
<div data-period-field="custom"><label class="form-label"><?= h(t('statistics.to')) ?></label><input class="form-control" type="date" name="date_to" value="<?= h($dateTo) ?>"></div>
|
||||
<div><label class="form-label"><?= h(t('statistics.origin_city')) ?></label><select class="form-select"
|
||||
name="municipality">
|
||||
<option value=""><?= h(t('statistics.all_cities')) ?></option>
|
||||
|
|
@ -69,7 +76,7 @@ $groups = [
|
|||
</select></div><button
|
||||
class="btn btn-cyan"><?= h(t('common.show')) ?></button><?php if ($municipality !== ''): ?><a
|
||||
class="btn btn-outline-secondary"
|
||||
href="/statistics?year=<?= h($allYears ? 'all' : (string)$year) ?>"><?= h(t('common.clear')) ?></a><button
|
||||
href="/statistics?<?= h(http_build_query($periodQuery)) ?>"><?= h(t('common.clear')) ?></a><button
|
||||
class="btn btn-outline-primary" type="button" onclick="window.print()">🖨
|
||||
<?= h(t('common.print')) ?></button><?php endif; ?>
|
||||
</form>
|
||||
|
|
@ -78,7 +85,7 @@ $groups = [
|
|||
|
||||
<?php if ($municipality !== ''): ?><div class="alert alert-info d-flex flex-wrap align-items-center gap-2">
|
||||
<strong><?= h($municipality) ?> ·
|
||||
<?= h($allYears ? t('statistics.all_years') : (string)$year) ?></strong><span><?= h(t('statistics.city_filter_summary', ['count' => (int)$stats['handled']])) ?></span><span
|
||||
<?= h($periodLabel) ?></strong><span><?= h(t('statistics.city_filter_summary', ['count' => (int)$stats['handled']])) ?></span><span
|
||||
class="ms-auto small"><?= h(t('statistics.filter_applied')) ?></span></div><?php endif; ?>
|
||||
<?php if ($municipality === '' && !empty($municipalityCoverage)): ?><div class="alert alert-info mb-4">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
|
|
@ -104,7 +111,7 @@ $groups = [
|
|||
<div class="text-muted small text-uppercase"><?= h($label) ?></div>
|
||||
<div class="display-6 fw-bold"><?= (int)$stats[$key] ?></div>
|
||||
<div class="small text-muted">
|
||||
<?= $allYears ? t('statistics.since_start') : t('statistics.in_year', ['year' => (int)$year]) ?>
|
||||
<?= $allYears ? t('statistics.since_start') : h(t('statistics.in_period', ['period' => $periodLabel])) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -138,7 +145,7 @@ $groups = [
|
|||
<div class="col-md-6">
|
||||
<h4 class="mb-1"><?= h(t('statistics.by_structure')) ?></h4>
|
||||
<div class="text-muted small mb-2"><?= h(t('statistics.structure_cost_help')) ?></div>
|
||||
<?php foreach (($costs['by_clinic'] ?? []) as $r): $structureUrl = !empty($r['contact_id']) ? '/directory/view?' . http_build_query(['id' => (int)$r['contact_id'],'year' => $allYears ? 'all' : $year,'municipality' => $municipality]) : null; ?>
|
||||
<?php foreach (($costs['by_clinic'] ?? []) as $r): $structureUrl = !empty($r['contact_id']) ? '/directory/view?' . http_build_query(['id' => (int)$r['contact_id'],'municipality' => $municipality] + $periodQuery) : null; ?>
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 border-bottom py-2">
|
||||
<span><?php if ($structureUrl): ?><a class="fw-semibold" href="<?= h($structureUrl) ?>"
|
||||
title="<?= h(t('statistics.open_structure_cost', ['structure' => $r['label']])) ?>"><?= h($r['label']) ?></a><?php else: ?><?= h($r['label']) ?><?php endif; ?><span
|
||||
|
|
@ -186,7 +193,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (($municipalityCosts ?? []) as $row): $cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $row['municipality']]); ?>
|
||||
<?php foreach (($municipalityCosts ?? []) as $row): $cityUrl = '/statistics?' . http_build_query($periodQuery + ['municipality' => $row['municipality']]); ?>
|
||||
<tr>
|
||||
<td><a class="fw-semibold" href="<?= h($cityUrl) ?>"
|
||||
title="<?= h(t('statistics.cost_city_open', ['city' => $row['municipality']])) ?>"><?= h($row['municipality']) ?></a>
|
||||
|
|
@ -245,7 +252,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
|
|||
</div><?php else: ?><div class="card h-100">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title"><?= h(t('grant.comparison', ['year' => $year - 1])) ?></h3>
|
||||
<h3 class="card-title"><?= h(t('statistics.previous_period_comparison')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('statistics.no_previous_percent')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -254,8 +261,8 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
|
|||
<thead>
|
||||
<tr>
|
||||
<th><?= h(t('grant.indicator')) ?></th>
|
||||
<th class="text-end"><?= $year - 1 ?></th>
|
||||
<th class="text-end"><?= $year ?></th>
|
||||
<th class="text-end"><?= h($previousPeriodLabel) ?></th>
|
||||
<th class="text-end"><?= h($periodLabel) ?></th>
|
||||
<th class="text-end"><?= h(t('grant.evolution')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -297,7 +304,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
|
|||
|
||||
<div class="d-flex flex-wrap align-items-baseline gap-2 mb-3">
|
||||
<h2 class="mb-0"><?= h(t('statistics.profiles')) ?></h2><span
|
||||
class="text-muted"><?= h($allYears ? t('statistics.profiles_help_all') : t('statistics.profiles_help_year', ['year' => (int)$year])) ?></span>
|
||||
class="text-muted"><?= h($allYears ? t('statistics.profiles_help_all') : t('statistics.profiles_help_period', ['period' => $periodLabel])) ?></span>
|
||||
</div>
|
||||
<div class="row row-cards"><?php foreach ($groups as $group): [$title,$items,$labels,$color] = $group;
|
||||
$linkType = $group[4] ?? null;
|
||||
|
|
@ -317,7 +324,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
|
|||
<?php foreach ($items as $label => $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'])):?><span class="text-muted"><?= h(t('statistics.n
|
|||
</div><?php endforeach; ?></div><?php endif; ?></div>
|
||||
</div>
|
||||
</div><?php endforeach; ?></div>
|
||||
<script>
|
||||
(() => {
|
||||
const selector = document.getElementById('statisticsPeriodType');
|
||||
if (!selector) return;
|
||||
const refresh = () => document.querySelectorAll('[data-period-field]').forEach((field) => {
|
||||
field.hidden = field.dataset.periodField !== selector.value;
|
||||
field.querySelectorAll('input,select').forEach((input) => input.disabled = field.hidden);
|
||||
});
|
||||
selector.addEventListener('change', refresh);
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
<?php if ($allYears): ?><script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canvas = document.getElementById('activityHistoryChart');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
17
migrations/098_medical_catalogs_and_test_date.sql
Normal file
17
migrations/098_medical_catalogs_and_test_date.sql
Normal 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');
|
||||
13
migrations/099_public_custom_pages.sql
Normal file
13
migrations/099_public_custom_pages.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
28
scripts/reset-demo.php
Normal file
28
scripts/reset-demo.php
Normal 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);
|
||||
}
|
||||
112
tests/run.php
112
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, '<img class="public-content-image"'));
|
||||
$externalImage = PublicContentFormatter::render('');
|
||||
$assert(!str_contains($externalImage, '<img'), 'Une image externe a été acceptée');
|
||||
$markdown = PublicContentFormatter::render("| Nom | Prix |\n| :--- | ---: |\n| **Vaccin** | 27 € |\n\n1. Premier\n2. Second\n\n> Important\n\n`<script>`\n\n~~ancien~~");
|
||||
$assert(str_contains($markdown, '<table>'), 'Les tableaux Markdown ne sont pas rendus');
|
||||
$assert(str_contains($markdown, 'class="text-right"'), 'L’alignement des tableaux est perdu');
|
||||
$assert(str_contains($markdown, '<ol>'), 'Les listes numérotées ne sont pas rendues');
|
||||
$assert(str_contains($markdown, '<blockquote>'), 'Les citations ne sont pas rendues');
|
||||
$assert(str_contains($markdown, '<code><script></code>'), 'Le code en ligne n’est pas protégé');
|
||||
$assert(str_contains($markdown, '<del>ancien</del>'), 'Le texte barré n’est pas rendu');
|
||||
$fenced = PublicContentFormatter::render("```html\n<script>alert(1)</script>\n```");
|
||||
$assert(!str_contains($fenced, '<script>'), 'Le bloc de code permet une injection HTML');
|
||||
});
|
||||
$test('Le menu public affiche uniquement les réseaux sociaux configurés', function () use ($assert): void {
|
||||
$layout = file_get_contents(__DIR__ . '/../app/Views/public_site_layout.php');
|
||||
$contact = file_get_contents(__DIR__ . '/../app/Views/public_site_contact.php');
|
||||
$assert(str_contains($layout, '$socialNetworks = ['), 'Le catalogue des réseaux sociaux est absent');
|
||||
$assert(str_contains($layout, "public_site_' . \$network . '_url"), 'Les liens sociaux ne viennent pas des paramètres');
|
||||
$assert(str_contains($layout, 'class="nav-social-link'), 'Les icônes sociales sont absentes du menu');
|
||||
$assert(str_contains($layout, 'aria-label="<?= h($label) ?>"'), 'Les icônes sociales ne sont pas accessibles');
|
||||
$assert(str_contains($layout, 'rel="noopener noreferrer"'), 'Les liens externes ne sont pas protégés');
|
||||
$assert(!str_contains($contact, '$socialNetworks = ['), 'Le catalogue social est encore dupliqué');
|
||||
});
|
||||
$test('Les pages publiques personnalisées conservent ordre et textes longs', function () use ($assert): void {
|
||||
$longText = str_repeat('Une histoire du refuge. ', 1200);
|
||||
PublicPageService::saveAll([
|
||||
['title' => 'Nos partenaires', 'slug' => 'nos-partenaires', 'content' => 'Partenaires', 'enabled' => '1', 'position' => '2'],
|
||||
['title' => 'Notre histoire', 'slug' => 'notre-histoire', 'content' => $longText, 'enabled' => '1', 'position' => '1'],
|
||||
]);
|
||||
$pages = PublicPageService::all(true);
|
||||
$assert(count($pages) === 2, 'Les pages personnalisées ne sont pas enregistrées');
|
||||
$assert($pages[0]['slug'] === 'notre-histoire', 'L’ordre choisi n’est pas conservé');
|
||||
$assert($pages[0]['content'] === trim($longText), 'Le texte long a été tronqué');
|
||||
$assert(PublicPageService::findBySlug('notre-histoire') !== null, 'La page publiée est introuvable');
|
||||
$assert(PublicPageService::normalizeSlug('Équipe & refuge') === 'equipe-refuge', 'L’adresse automatique est invalide');
|
||||
$view = file_get_contents(__DIR__ . '/../app/Views/settings_public_site.php');
|
||||
$assert(str_contains($view, 'id="public-page-add"'), 'Le bouton d’ajout est absent');
|
||||
$assert(str_contains($view, 'draggable="true"'), 'Le glisser-déposer est absent');
|
||||
$assert(str_contains($view, 'public-page-up'), 'Le déplacement accessible est absent');
|
||||
$controller = file_get_contents(__DIR__ . '/../app/Controllers/PublicSiteController.php');
|
||||
$assert(str_contains($controller, "'/site/page/'"), 'La route publique personnalisée est absente');
|
||||
});
|
||||
$test('Authentification et mot de passe haché', function () use ($db, $assert): void {
|
||||
$assert(ini_get('session.use_strict_mode') === '1', 'Le mode strict des sessions est désactivé');
|
||||
|
|
@ -391,7 +459,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(
|
||||
|
|
@ -473,6 +542,47 @@ try {
|
|||
$assert($structure !== null && $structure['label'] === 'Structure test');
|
||||
$assert((int) $structure['total_cents'] === 1234);
|
||||
});
|
||||
$test('Les statistiques acceptent une année, un mois et une période libre', function () use ($assert): void {
|
||||
$year = (int) date('Y');
|
||||
$annual = StatisticsService::annual($year);
|
||||
$yearRange = StatisticsService::period("$year-01-01", "$year-12-31");
|
||||
foreach (['handled','entries','exits','adoptions','deaths','births','vaccinations','treatments','medical_notes'] as $key) {
|
||||
$assert((float) $annual[$key] === (float) $yearRange[$key], 'Écart annuel sur ' . $key);
|
||||
}
|
||||
$today = date('Y-m-d');
|
||||
$daily = StatisticsService::period($today, $today);
|
||||
$assert((int) $daily['adoptions'] >= 1, 'La période du jour ne retrouve pas l’adoption de test');
|
||||
$comparison = StatisticsService::comparisonPeriod($today, $today);
|
||||
$assert(isset($comparison['rows']['adoptions'], $comparison['previous_start'], $comparison['previous_end']));
|
||||
$costs = StatisticsService::costs(null, null, $today, $today);
|
||||
$assert((int) $costs['total_cents'] >= 1234, 'Les coûts ne respectent pas la période personnalisée');
|
||||
$distributions = StatisticsService::distributions(null, null, null, date('Y-m-01'), date('Y-m-t'));
|
||||
$assert(isset($distributions['species'], $distributions['origins']));
|
||||
$view = file_get_contents(__DIR__ . '/../app/Views/statistics.php');
|
||||
foreach (['period_type','type="month"','date_from','date_to'] as $field) {
|
||||
$assert(str_contains($view, $field), 'Filtre statistique absent : ' . $field);
|
||||
}
|
||||
});
|
||||
$test('Le nom de l’adoptant ouvre la bonne route annuaire', function () use ($assert): void {
|
||||
$view = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php');
|
||||
$routes = file_get_contents(__DIR__ . '/../app/Routes/web.php');
|
||||
$assert(
|
||||
str_contains($view, 'href="/directory/view?id=<?= (int)$adoption[\'adopter_contact_id\'] ?>"'),
|
||||
'Le lien de l’adoptant ne cible pas /directory/view',
|
||||
);
|
||||
$assert(!str_contains($view, '/directory/show?id='), 'L’ancienne route annuaire est encore utilisée');
|
||||
$assert(str_contains($routes, "case '/directory/view':"), 'La route /directory/view n’est pas déclarée');
|
||||
});
|
||||
$test('Le rafraîchissement i-CAD fonctionne avec et sans JavaScript', function () use ($assert): void {
|
||||
$identity = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php');
|
||||
$scripts = file_get_contents(__DIR__ . '/../app/Views/_animal_show_modals.php');
|
||||
$controller = file_get_contents(__DIR__ . '/../app/Controllers/AnimalsController.php');
|
||||
$assert(str_contains($identity, 'action="/animal/icad"'), 'Le bouton i-CAD n’a pas de formulaire de secours');
|
||||
$assert(str_contains($identity, 'name="csrf"'), 'Le formulaire i-CAD ne contient pas le jeton CSRF');
|
||||
$assert(str_contains($scripts, "form.addEventListener('submit', refresh)"), 'L’actualisation dynamique i-CAD est absente');
|
||||
$assert(str_contains($scripts, 'if (!r.ok)'), 'Les erreurs HTTP i-CAD ne sont pas contrôlées');
|
||||
$assert(str_contains($controller, "header('Location: /animal?id=' . \$id . '#tab-ident')"), 'Le repli sans JavaScript est absent');
|
||||
});
|
||||
$test('La recherche globale retrouve les dossiers accessibles', function () use ($assert): void {
|
||||
$animals = GlobalSearchService::search('Animal Test');
|
||||
$assert(($animals['animals'][0]['title'] ?? '') === 'Animal Test', 'Animal absent de la recherche');
|
||||
|
|
|
|||
Loading…
Reference in a new issue