Corriger les dossiers animaux et le suivi médical
This commit is contained in:
parent
a55d27935f
commit
7b89fd5dfa
34 changed files with 440 additions and 39 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -2,6 +2,22 @@
|
|||
|
||||
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
|
||||
|
||||
## [1.0.0-rc.30] — 2026-10-01
|
||||
|
||||
### Fiabilisation des dossiers et du suivi médical
|
||||
|
||||
- correction de la fausse déclaration de décès de Lucci après vérification ASM3 et protection contre les fiches de décès résiduelles lors d’une correction de statut ;
|
||||
- réparation de l’actualisation I-CAD après la suppression de l’ancienne adresse `/pro` ;
|
||||
- exclusion uniforme des animaux adoptés dans les rappels et traitements du tableau de bord ;
|
||||
- affichage de la date du test FIV/FeLV et possibilité d’en comptabiliser le tarif ;
|
||||
- ajout direct d’un vaccin depuis sa liste, fabricants suggérés et tri alphabétique ;
|
||||
- filtrage des tarifs par clinique pour les vaccins, tests et chirurgies ;
|
||||
- propositions normalisées d’intitulés chirurgicaux et catalogue médical administrable ;
|
||||
- lien direct entre l’adoptant et sa fiche d’annuaire, ainsi que de nouveaux rôles de professionnels animaliers ;
|
||||
- amélioration du menu mobile, des zones de texte du site public et des longues listes de rappels ;
|
||||
- ajout d’un script protégé permettant de réinitialiser automatiquement une instance de démonstration à heure fixe ;
|
||||
- nouvelle icône Android issue du logo officiel de l’application.
|
||||
|
||||
## [1.0.0-rc.29] — 2026-09-22
|
||||
|
||||
### Corrections du suivi médical et du site public
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden
|
|||
|
||||
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
|
||||
|
||||
## `1.0.0-rc.30` — Corrections issues de la validation terrain
|
||||
|
||||
Une passe croisée avec les sauvegardes ASM3 a corrigé une collision historique entre Gribouille et Lucci. Le suivi médical gagne une date de test FIV/FeLV, des catalogues administrables et une meilleure association entre tarifs et cliniques. La synchronisation I-CAD, la navigation mobile et l’identité Android ont également été adaptées aux conditions réelles d’utilisation.
|
||||
|
||||
## `1.0.0-rc.29` — Suivi médical et présentation
|
||||
|
||||
Les traitements et les intitulés des produits médicaux peuvent être corrigés après leur saisie. Le formulaire des vermifuges s’ouvre de nouveau correctement. La fiche animale montre sa date de naissance et le CSS personnalisé du site public prend priorité sur les styles standard.
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
|
|||
|
||||
## État du projet
|
||||
|
||||
La candidate actuelle est `1.0.0-rc.29`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
|
||||
La candidate actuelle est `1.0.0-rc.30`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
|
||||
|
||||
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.0.0-rc.29
|
||||
1.0.0-rc.30
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -746,6 +746,18 @@ trait AnimalRecordActions
|
|||
':current_lng' => $currentLng,
|
||||
]);
|
||||
|
||||
// Un décès est une donnée structurée distincte du statut. Lors d'une correction
|
||||
// explicite d'un animal déclaré à tort décédé, ne pas laisser une ancienne fiche
|
||||
// de décès continuer à alimenter la bannière et les statistiques.
|
||||
$oldWasDeceased = in_array(strtolower((string) ($old['status'] ?? '')), ['decede', 'décédé'], true);
|
||||
$isNowDeceased = in_array(strtolower($status), ['decede', 'décédé'], true);
|
||||
if ($oldWasDeceased && !$isNowDeceased) {
|
||||
$db->prepare('DELETE FROM animal_deaths WHERE animal_id=:id')->execute([':id' => $id]);
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(:id,'death_correction','update','Décès annulé','La fiche de décès a été retirée après correction du statut de l’animal.',:user)",
|
||||
)->execute([':id' => $id, ':user' => Auth::id()]);
|
||||
}
|
||||
|
||||
if ($old) {
|
||||
LocationHistoryService::record(
|
||||
$db,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1227,6 +1227,68 @@ final class SettingsController
|
|||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
}
|
||||
public static function catalogs(): void
|
||||
{
|
||||
self::admin();
|
||||
$db = DB::pdo();
|
||||
render('settings_catalogs.php', [
|
||||
'title' => t('settings.catalogs'),
|
||||
'catalogs' => [
|
||||
'medication' => $db->query('SELECT id,name,molecule extra FROM ref_medications ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'vaccine' => $db->query('SELECT id,name,code extra FROM ref_vaccines ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'dewormer' => $db->query('SELECT id,name,active_ingredient extra FROM ref_dewormers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'manufacturer' => $db->query('SELECT id,name,NULL extra FROM ref_vaccine_manufacturers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC),
|
||||
],
|
||||
'saved' => isset($_GET['saved']),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function saveCatalogItem(): void
|
||||
{
|
||||
self::admin(); self::postOnly();
|
||||
$type = (string) ($_POST['type'] ?? '');
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = trim((string) ($_POST['name'] ?? ''));
|
||||
$extra = trim((string) ($_POST['extra'] ?? '')) ?: null;
|
||||
$map = [
|
||||
'medication' => ['ref_medications','molecule'], 'vaccine' => ['ref_vaccines','code'],
|
||||
'dewormer' => ['ref_dewormers','active_ingredient'], 'manufacturer' => ['ref_vaccine_manufacturers',null],
|
||||
];
|
||||
if ($name === '' || !isset($map[$type])) {
|
||||
header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.invalid'))); exit();
|
||||
}
|
||||
[$table,$extraColumn] = $map[$type];
|
||||
try {
|
||||
if ($id) {
|
||||
$sql = "UPDATE $table SET name=?" . ($extraColumn ? ",$extraColumn=?" : '') . ' WHERE id=?';
|
||||
DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra,$id] : [$name,$id]);
|
||||
} else {
|
||||
$sql = "INSERT INTO $table(name" . ($extraColumn ? ",$extraColumn" : '') . ') VALUES(?' . ($extraColumn ? ',?' : '') . ')';
|
||||
DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra] : [$name]);
|
||||
$id = (int) DB::pdo()->lastInsertId();
|
||||
}
|
||||
AuditService::log('catalog_saved', '/settings/catalogs/save', 'Catalogue modifié : ' . $name, $type, $id);
|
||||
header('Location: /settings/catalogs?saved=1');
|
||||
} catch (Throwable $e) {
|
||||
header('Location: /settings/catalogs?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function deleteCatalogItem(): void
|
||||
{
|
||||
self::admin(); self::postOnly();
|
||||
$type = (string) ($_POST['type'] ?? ''); $id = (int) ($_POST['id'] ?? 0);
|
||||
$tables = ['medication'=>'ref_medications','vaccine'=>'ref_vaccines','dewormer'=>'ref_dewormers','manufacturer'=>'ref_vaccine_manufacturers'];
|
||||
if (!$id || !isset($tables[$type])) { http_response_code(400); return; }
|
||||
try {
|
||||
DB::pdo()->prepare('DELETE FROM ' . $tables[$type] . ' WHERE id=?')->execute([$id]);
|
||||
AuditService::log('catalog_deleted', '/settings/catalogs/delete', 'Élément de catalogue supprimé', $type, $id);
|
||||
header('Location: /settings/catalogs?saved=1');
|
||||
} catch (Throwable) {
|
||||
header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.in_use')));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
private static function postOnly(): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -338,6 +338,15 @@ switch ($path) {
|
|||
case '/settings/pricing':
|
||||
SettingsController::pricing();
|
||||
break;
|
||||
case '/settings/catalogs':
|
||||
SettingsController::catalogs();
|
||||
break;
|
||||
case '/settings/catalogs/save':
|
||||
SettingsController::saveCatalogItem();
|
||||
break;
|
||||
case '/settings/catalogs/delete':
|
||||
SettingsController::deleteCatalogItem();
|
||||
break;
|
||||
case '/settings/pricing/tariff':
|
||||
SettingsController::saveTariff();
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<!-- IDENTITÉ -->
|
||||
<div class="tab-pane active show" id="tab-ident">
|
||||
|
||||
<?php if ($canViewMedical && !empty($death)): ?>
|
||||
<?php if ($canViewMedical && !empty($death) && in_array(strtolower((string)$animal['status']), ['decede','décédé'], true)): ?>
|
||||
<?php
|
||||
$deathCauseLabels = array_combine(array_keys(DeathController::CAUSES), array_map(static fn ($key) => DeathController::causeLabel($key), array_keys(DeathController::CAUSES)));
|
||||
$deathPlaceLabels = array_combine(array_keys(DeathController::PLACES), array_map(static fn ($key) => DeathController::placeLabel($key), array_keys(DeathController::PLACES)));
|
||||
|
|
@ -221,11 +221,15 @@ $hasCurrent = ($currentLat !== null && $currentLng !== null);
|
|||
|
||||
</div>
|
||||
|
||||
<?php if ($animal['status'] === 'adopte'): ?>
|
||||
<?php if (in_array($animal['status'], ['adopte','adopté'], true) && !empty($adoption)): ?>
|
||||
|
||||
<div class="alert alert-success mt-3">
|
||||
<strong><?= h(t('animal.adopted_by')) ?> :</strong><br>
|
||||
<?= htmlspecialchars($adoption['adopter_name'] . ' - ' . $adoption['adopter_address'] . ' ' . $adoption['adopter_postal_code'] . ' ' . $adoption['adopter_city'] . ' - (' . $adoption['adopter_phone'] . ' - ' . $adoption['adopter_email'] . ')') ?>
|
||||
<?php if (!empty($adoption['adopter_contact_id'])): ?><a class="fw-bold" href="/directory/show?id=<?= (int)$adoption['adopter_contact_id'] ?>"><?= h($adoption['adopter_name']) ?></a><?php else: ?><span class="fw-bold"><?= h($adoption['adopter_name']) ?></span><?php endif; ?>
|
||||
<div class="small mt-1"><?= h(trim(implode(' · ', array_filter([
|
||||
trim((string)($adoption['adopter_address'] ?? '') . ' ' . (string)($adoption['adopter_postal_code'] ?? '') . ' ' . (string)($adoption['adopter_city'] ?? '')),
|
||||
$adoption['adopter_phone'] ?? '', $adoption['adopter_email'] ?? ''
|
||||
])))) ?></div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
<div class="col-12 col-md-3">
|
||||
<div class="subheader mb-1">FeLV</div>
|
||||
<span class="badge <?= $felvClass ?>"><?= h($felvLabel) ?></span>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="subheader mb-1"><?= h(t('animal.test_date')) ?></div>
|
||||
<strong><?= !empty($animal['fiv_felv_test_date']) ? h(date('d/m/Y', strtotime((string)$animal['fiv_felv_test_date']))) : '—' ?></strong>
|
||||
</div><?php endif; ?>
|
||||
<div class="col-12 <?= $isCat ? 'col-md-6' : '' ?>">
|
||||
<div class="subheader mb-1"><?= h(t('animal.conditions_short')) ?></div>
|
||||
|
|
@ -423,6 +427,9 @@ ORDER BY created_at ASC
|
|||
|
||||
<!-- VACCINS -->
|
||||
<div class="tab-pane" id="tab-vac">
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#modalVaccine">+ <?= h(t('animal.add_vaccine')) ?></button>
|
||||
</div>
|
||||
<?php if (!$vaccinations): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_vaccine_recorded')) ?></div>
|
||||
<?php else: ?>
|
||||
|
|
|
|||
|
|
@ -300,7 +300,11 @@ $currentTime = date('H:i');
|
|||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= h(t('animal.manufacturer')) ?></label>
|
||||
<input class="form-control" name="manufacturer" placeholder="Ex : Boehringer Ingelheim">
|
||||
<input class="form-control" name="manufacturer" list="vaccineManufacturerList"
|
||||
placeholder="Ex : Boehringer Ingelheim">
|
||||
<datalist id="vaccineManufacturerList">
|
||||
<?php foreach (($vaccineManufacturers ?? []) as $manufacturer): ?><option value="<?= h($manufacturer['name']) ?>"></option><?php endforeach; ?>
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
|
|
@ -619,13 +623,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= h(t('animal.test_date')) ?></label>
|
||||
<input class="form-control" type="date" name="test_date" value="<?= date('Y-m-d') ?>">
|
||||
<input class="form-control" type="date" name="test_date"
|
||||
value="<?= h($animal['fiv_felv_test_date'] ?: date('Y-m-d')) ?>">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label"><?= h(t('animal.test_note')) ?></label>
|
||||
<input class="form-control" name="test_notes"
|
||||
placeholder="<?= h(t('animal.test_notes_placeholder')) ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= h(t('animal.veterinary_clinic')) ?></label>
|
||||
<select class="form-select" name="clinic_contact_id">
|
||||
<option value="">— <?= h(t('common.choose')) ?> —</option>
|
||||
<?php foreach (($directoryClinics ?? []) as $clinic): ?><option value="<?= (int)$clinic['id'] ?>"><?= h($clinic['name']) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= h(t('animal.tariff_record')) ?> <span class="text-muted">(<?= h(mb_strtolower(t('common.optional'))) ?>)</span></label>
|
||||
<select class="form-select" name="test_tariff_id">
|
||||
<option value=""><?= h(t('animal.no_expense')) ?></option>
|
||||
<?php foreach (($expenseTariffs ?? []) as $tariff): if (($tariff['category'] ?? '') !== 'test') continue; ?><option
|
||||
value="<?= (int)$tariff['id'] ?>" data-clinic="<?= (int)$tariff['clinic_contact_id'] ?>"><?= h($tariff['clinic_name'] . ' · ' . $tariff['label']) ?> · <?= number_format((int)$tariff['effective_cents']/100, 2, ',', ' ') ?> €</option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
|
@ -694,6 +714,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('modalMedicalStatus');
|
||||
const clinic = modal?.querySelector('select[name="clinic_contact_id"]');
|
||||
const tariff = modal?.querySelector('select[name="test_tariff_id"]');
|
||||
const sync = () => {
|
||||
[...(tariff?.options || [])].forEach(option => {
|
||||
if (!option.dataset.clinic) return;
|
||||
option.hidden = !clinic?.value || option.dataset.clinic !== clinic.value;
|
||||
option.disabled = option.hidden;
|
||||
});
|
||||
if (tariff?.selectedOptions[0]?.hidden) tariff.value = '';
|
||||
};
|
||||
clinic?.addEventListener('change', sync); sync();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="modal modal-blur fade" id="modalMedical" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ if ($felvPos) {
|
|||
<?php endif; ?>
|
||||
<div class="text-muted mt-1" style="font-size:.7rem"><?= h($animal['internal_code']) ?></div>
|
||||
<div class="text-muted small mt-1"><?= h(t('animal.form.birth_date')) ?> :
|
||||
<?= !empty($animal['birth_date']) ? h(date('d/m/Y', strtotime((string)$animal['birth_date']))) : '—' ?></div>
|
||||
<?= !empty($animal['birth_date']) ? (!empty($animal['birth_is_estimated']) ? '~ ' : '') . h(date('d/m/Y', strtotime((string)$animal['birth_date']))) : '—' ?></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@
|
|||
<div class="col-lg-6">
|
||||
<section class="card h-100">
|
||||
<div class="card-body">
|
||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.28</span>
|
||||
<h3>Saisies médicales corrigibles</h3>
|
||||
<p class="text-muted">Vaccinations et traitements antiparasitaires peuvent être rectifiés sans
|
||||
déclencher une seconde sortie de stock ou une nouvelle dépense.</p>
|
||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.30</span>
|
||||
<h3>Données fiabilisées sur le terrain</h3>
|
||||
<p class="text-muted">Décès historiques corrigibles, synchronisation I-CAD réparée, catalogues
|
||||
médicaux administrables et tarifs filtrés par clinique.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -43,8 +43,8 @@
|
|||
<div class="card-body">
|
||||
<span class="badge bg-secondary-lt mb-2">Après la 1.0</span>
|
||||
<h3>Prochaines étapes</h3>
|
||||
<p class="text-muted">Tests HTTP plus complets, validation des paquets d’installation et amélioration
|
||||
continue des parcours.</p>
|
||||
<p class="text-muted">Réinitialisation planifiée des démonstrations, normalisation assistée des
|
||||
contacts, validation des paquets YunoHost, ZimaOS et CasaOS, puis amélioration continue des parcours.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -173,6 +173,14 @@ const insertImage = (button) => {
|
|||
textarea.setRangeText(`\n`, textarea.selectionStart, textarea.selectionEnd, 'end');
|
||||
textarea.focus();
|
||||
};
|
||||
document.querySelectorAll('textarea').forEach((textarea) => {
|
||||
const resize = () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(140, textarea.scrollHeight + 8) + 'px';
|
||||
};
|
||||
textarea.addEventListener('input', resize);
|
||||
resize();
|
||||
});
|
||||
document.querySelectorAll('.public-rich-text').forEach((textarea) => {
|
||||
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
|
||||
const toolbar = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,4 +236,19 @@ return [
|
|||
'animal.registration_change_pending'=>'I-CAD: ownership change pending',
|
||||
'animal.registration_owner_changed'=>'I-CAD: owner changed',
|
||||
'animal.veterinary_clinic'=>'Veterinary clinic',
|
||||
'animal.add_vaccine'=>'Add a vaccine',
|
||||
'role.pet_sitter'=>'Pet sitter',
|
||||
'role.refuge_partenaire'=>'Partner shelter or charity',
|
||||
'role.toiletteur'=>'Groomer',
|
||||
'role.professionnel_autre'=>'Other animal professional',
|
||||
'common.show_more'=>'Show more',
|
||||
'common.show_less'=>'Show less',
|
||||
'settings.catalogs'=>'Medical catalogues',
|
||||
'catalog.invalid'=>'Invalid catalogue item.',
|
||||
'catalog.in_use'=>'This item is already in use and cannot be deleted. Rename it if needed.',
|
||||
'catalog.saved'=>'Catalogue saved.',
|
||||
'catalog.admin_help'=>'Add, correct or delete the labels offered in medical records.',
|
||||
'catalog.code'=>'Code',
|
||||
'catalog.active_ingredient'=>'Active ingredient',
|
||||
'catalog.delete_confirm'=>'Delete this label?',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -236,4 +236,19 @@ return [
|
|||
'animal.registration_change_pending'=>'I-CAD : en attente de changement',
|
||||
'animal.registration_owner_changed'=>'I-CAD : propriétaire changé',
|
||||
'animal.veterinary_clinic'=>'Clinique vétérinaire',
|
||||
'animal.add_vaccine'=>'Ajouter un vaccin',
|
||||
'role.pet_sitter'=>'Pet-sitter',
|
||||
'role.refuge_partenaire'=>'Refuge ou association partenaire',
|
||||
'role.toiletteur'=>'Toiletteur',
|
||||
'role.professionnel_autre'=>'Autre professionnel animalier',
|
||||
'common.show_more'=>'Afficher plus',
|
||||
'common.show_less'=>'Afficher moins',
|
||||
'settings.catalogs'=>'Catalogues médicaux',
|
||||
'catalog.invalid'=>'Élément de catalogue invalide.',
|
||||
'catalog.in_use'=>'Cet élément est déjà utilisé et ne peut pas être supprimé. Renommez-le si nécessaire.',
|
||||
'catalog.saved'=>'Catalogue enregistré.',
|
||||
'catalog.admin_help'=>'Ajoutez, corrigez ou supprimez les intitulés proposés dans les fiches médicales.',
|
||||
'catalog.code'=>'Code',
|
||||
'catalog.active_ingredient'=>'Principe actif',
|
||||
'catalog.delete_confirm'=>'Supprimer cet intitulé ?',
|
||||
];
|
||||
|
|
|
|||
28
scripts/reset-demo.php
Normal file
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);
|
||||
}
|
||||
|
|
@ -408,7 +408,8 @@ try {
|
|||
$alerts = NotificationService::alerts();
|
||||
$assert((bool) array_filter($alerts, static fn($x) => $x['key'] === 'vaccines'));
|
||||
});
|
||||
$test('Les rappels vaccinaux apparaissent automatiquement dans l’agenda', function () use ($assert): void {
|
||||
$test('Les rappels vaccinaux apparaissent automatiquement dans l’agenda', function () use ($db, $assert): void {
|
||||
$db->exec("UPDATE vaccinations SET due_date=date('now') WHERE animal_id=(SELECT id FROM animals WHERE internal_code='TEST-001')");
|
||||
$month = AgendaService::month(new DateTimeImmutable('first day of this month'));
|
||||
$reminders = array_values(
|
||||
array_filter(
|
||||
|
|
|
|||
Loading…
Reference in a new issue