Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

918
app/Views/animals_list.php Normal file
View file

@ -0,0 +1,918 @@
<?php
/** @var array $animals */
/** @var string $q */
/** @var string $status */
/** @var array $statuses */
/** @var string $sort */
/** @var string $dir */
$quality = $quality ?? '';
$qualityLabel = $qualityLabel ?? '';
$species = $species ?? '';
$speciesOptions = $speciesOptions ?? [];
$viewMode = $viewMode ?? 'table';
$speciesByCode = [];
foreach ($speciesOptions as $speciesOption) {
$speciesByCode[SpeciesService::normalize((string)$speciesOption['code'])] = $speciesOption;
}
$shortContactName = static function (?string $name): string {
$name = trim((string)$name);
if ($name === '') {
return '';
}$parts = preg_split('/\s+/u', $name) ?: [];
if (count($parts) < 2) {
return $name;
}$first = (string)array_shift($parts);
$last = (string)array_pop($parts);
$initial = mb_strtoupper(mb_substr($last, 0, 1));
return $first . ($initial !== '' ? ' ' . $initial . '.' : '');
};
$tableQs = $_GET;
$tableQs['view'] = 'table';
$mosaicQs = $_GET;
$mosaicQs['view'] = 'mosaic';
?>
<div class="card">
<div class="card-body">
<form class="row g-2 align-items-center" method="get" action="/animals">
<?php if ($quality !== ''): ?><input type="hidden" name="quality" value="<?= h($quality) ?>"><?php endif; ?>
<input type="hidden" name="sort" value="<?= h($sort) ?>">
<input type="hidden" name="dir" value="<?= h(strtolower($dir)) ?>">
<div class="col-12">
<input class="form-control" name="q" value="<?= htmlspecialchars($q, ENT_QUOTES) ?>"
placeholder="<?= h(t('animals.search_placeholder')) ?>">
</div>
<div class="col-12 col-md-auto animal-filter animal-filter-status">
<select class="form-select" name="status" onchange="this.form.requestSubmit()">
<?php foreach ($statuses as $k => $label): ?>
<option value="<?= htmlspecialchars($k, ENT_QUOTES) ?>" <?= $status === $k ? 'selected' : '' ?>>
<?= htmlspecialchars($label, ENT_QUOTES) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-auto animal-filter animal-filter-species"><select class="form-select"
name="species" onchange="this.form.requestSubmit()">
<option value=""><?= h(t('animals.all_species')) ?></option>
<?php foreach ($speciesOptions as $option): ?><option value="<?= h($option['code']) ?>"
<?= $species === $option['code'] ? 'selected' : '' ?>>
<?= h($option['icon'] . ' ' . $option['name']) ?></option><?php endforeach; ?>
</select></div>
<div class="col-12 col-md-auto animal-filter animal-filter-availability"><select class="form-select"
name="availability" onchange="this.form.requestSubmit()">
<option value=""><?= h(t('animals.all_availability')) ?></option>
<option value="available" <?= (($availability ?? '') === 'available') ? 'selected' : '' ?>>
<?= h(t('animals.available')) ?></option>
<option value="not_available" <?= (($availability ?? '') === 'not_available') ? 'selected' : '' ?>>
<?= h(t('animals.unavailable')) ?></option>
<option value="unknown" <?= (($availability ?? '') === 'unknown') ? 'selected' : '' ?>>
<?= h(t('animals.to_define')) ?></option>
</select></div>
<div class="col-auto">
<a class="btn btn-outline-primary" href="/animal/new">🐈 <?= h(t('animals.add')) ?></a>
</div>
<div class="col-auto">
<a class="btn btn-outline-primary" href="/litter/new">🐾 <?= h(t('animals.add_litter')) ?></a>
</div>
<div class="col-auto ms-auto d-flex align-items-center gap-2">
<div class="btn-group" role="group" aria-label="<?= h(t('animals.table_view')) ?>"><a
class="btn px-3 <?= $viewMode === 'table' ? 'btn-dark' : 'btn-outline-secondary' ?>"
href="/animals?<?= h(http_build_query($tableQs)) ?>" title="<?= h(t('animals.table_view')) ?>"
aria-label="<?= h(t('animals.table_view')) ?>"></a><a
class="btn px-3 <?= $viewMode === 'mosaic' ? 'btn-dark' : 'btn-outline-secondary' ?>"
href="/animals?<?= h(http_build_query($mosaicQs)) ?>" title="<?= h(t('animals.mosaic_view')) ?>"
aria-label="<?= h(t('animals.mosaic_view')) ?>"></a></div>
<div class="dropdown flex-shrink-0">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false" aria-label="<?= h(t('animals.removed_management')) ?>"
title="<?= h(t('animals.removed_management')) ?>">
⚙️
</button>
<div class="dropdown-menu dropdown-menu-end shadow">
<a class="dropdown-item" href="/animals/archived">📦 <?= h(t('animals.archived')) ?></a>
<a class="dropdown-item text-danger" href="/animals/trash">🗑 <?= h(t('animals.trash')) ?></a>
</div>
</div>
</form>
</div>
<?php if ($quality !== ''): ?><div
class="alert alert-warning mx-3 mt-3 mb-0 d-flex flex-wrap align-items-center gap-2"><span>🔎
<?= h(t('animals.quality_filter')) ?> : <strong><?= h($qualityLabel) ?></strong></span><span
class="badge bg-orange-lt"><?= h(t('animals.records', ['count' => count($animals)])) ?></span><a
class="btn btn-sm btn-outline-secondary ms-auto"
href="/animals?view=<?= h($viewMode) ?>"><?= h(t('animals.remove_filter')) ?></a></div><?php endif; ?>
<style>
@media(min-width:768px) {
.animal-filter-status {
width: 12.5rem
}
.animal-filter-species {
width: 10.5rem
}
.animal-filter-availability {
width: 12.5rem
}
}
.litter-kitten-row td:nth-child(2) {
padding-left: 1.5rem;
}
.litter-member-row td:first-child {
padding-left: 2.75rem;
}
.litter-member-row>td {
background-color: rgba(169, 127, 69, .045);
}
.bonded-member-row>td {
background-color: rgba(111, 66, 193, .045);
}
.litter-member-row.bonded-member-row>td {
background: linear-gradient(90deg, rgba(169, 127, 69, .045), rgba(111, 66, 193, .045));
}
.litter-parent-placeholder td {
background: var(--tblr-bg-surface-secondary, #f6f8fb);
border-top-width: 2px;
}
.bonded-group-placeholder td {
background: rgba(111, 66, 193, .06);
border-top: 2px solid rgba(111, 66, 193, .2);
}
.litter-parent-placeholder-icon {
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(98, 105, 118, .12);
color: var(--tblr-secondary);
font-size: 1.25rem;
}
.animal-alert-icon {
display: inline-flex;
width: 1.4rem;
height: 1.4rem;
flex: 0 0 1.4rem;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.animal-alert-icon svg {
width: .9rem;
height: .9rem;
}
.animal-photo-trigger {
display: inline-flex;
border-radius: 10px;
outline: none;
cursor: zoom-in;
}
.animal-photo-trigger:focus-visible {
box-shadow: 0 0 0 3px rgba(32, 107, 196, .25);
}
.animal-photo-zoom {
position: fixed;
z-index: 1090;
display: none;
pointer-events: none;
padding: .35rem;
background: #fff;
border: 1px solid rgba(98, 105, 118, .25);
border-radius: 14px;
box-shadow: 0 1rem 3rem rgba(31, 41, 55, .28);
}
.animal-photo-zoom.is-visible {
display: block;
}
.animal-photo-zoom img {
display: block;
width: auto;
height: auto;
max-width: min(320px, 70vw);
max-height: 60vh;
border-radius: 10px;
object-fit: contain;
}
.animal-mosaic-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: .85rem;
padding: 1rem;
}
.animal-mosaic-card {
min-width: 0;
overflow: hidden;
transition: transform .15s ease, box-shadow .15s ease;
}
.animal-mosaic-card:hover {
transform: translateY(-2px);
box-shadow: 0 .55rem 1.5rem rgba(31, 41, 55, .12);
}
.animal-mosaic-photo {
display: block;
width: 100%;
aspect-ratio: 1/1;
object-fit: cover;
border-radius: 0;
}
.animal-mosaic-card .card-body {
display: flex;
flex-direction: column;
}
.animal-mosaic-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.animal-mosaic-footer {
margin-top: auto;
}
.animal-mosaic-status {
text-align: center;
}
.animal-mosaic-icons {
min-height: 1.4rem;
justify-content: center;
text-align: center;
}
@media(max-width:1199.98px) {
.animal-mosaic-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media(max-width:767.98px) {
.animal-mosaic-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .65rem;
padding: .75rem;
}
}
@media(max-width:479.98px) {
.animal-mosaic-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.animal-sex-icon {
display: inline-flex;
width: 1.25rem;
height: 1.25rem;
flex: 0 0 1.25rem;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: .8rem;
font-weight: 700;
line-height: 1;
}
.animal-weight-column {
width: 7.5rem;
min-width: 7.5rem;
white-space: nowrap;
}
.animal-age-column {
width: 5.75rem;
min-width: 5.75rem;
white-space: nowrap;
}
.animal-species-column {
width: 7rem;
min-width: 7rem;
white-space: nowrap;
}
.life-profile-column {
width: 7.25rem;
min-width: 7.25rem;
text-align: center;
}
.life-profile-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: center;
gap: .15rem;
}
.life-profile-grid span {
display: inline-flex;
justify-content: center;
align-items: center;
min-width: 1.35rem;
line-height: 1.2;
font-weight: 700;
}
.life-profile-sort {
display: inline-flex;
justify-content: center;
color: inherit;
text-decoration: none;
}
.life-profile-sort:hover,
.life-profile-sort:focus {
color: inherit;
text-decoration: none;
background: transparent;
}
.life-profile-yes {
color: var(--tblr-success);
}
.life-profile-no {
color: var(--tblr-danger);
}
.life-profile-unknown {
color: var(--tblr-secondary);
font-weight: 400 !important;
}
/* Conservé pour une éventuelle réactivation de la colonne Prochain vaccin.
.animal-vaccine-column {
width: 6.5rem;
max-width: 6.5rem;
}
*/
</style>
<?php if ($viewMode === 'mosaic'): ?>
<div class="animal-mosaic-grid">
<?php foreach ($animals as $a):
$mosaicSrc = !empty($a['primary_photo']) ? '/media/animals/' . (int)$a['id'] . '/' . rawurlencode($a['primary_photo']) : '/assets/cat_placeholder.svg';
$mosaicSpeciesCode = SpeciesService::normalize((string)($a['species'] ?? ''));
$mosaicSpecies = $speciesByCode[$mosaicSpeciesCode] ?? ['name' => species_label($mosaicSpeciesCode),'icon' => '🐾','color' => '#6c757d'];
[$mosaicStatusClass,$mosaicStatusLabel] = status_badge((string)($a['status'] ?? ''));
[$mosaicSexSymbol,$mosaicSexClass,$mosaicSexLabel] = match((string)($a['sex'] ?? 'U')) {
'M' => ['♂','bg-blue-lt text-blue',t('sex.male')],'F' => ['♀','bg-purple-lt text-purple',t('sex.female')],default => ['?','bg-secondary-lt text-secondary',t('sex.unknown')]
};
$mosaicFoster = in_array((string)($a['status'] ?? ''), ['fa','fa_permanente'], true) ? $shortContactName($a['foster_contact_name'] ?? null) : '';
$mosaicFreeCat = (string)($a['status'] ?? '') === 'chat_libre' ? trim((string)($a['free_cat_site_name'] ?: $a['free_cat_city'])) : '';
?>
<article class="card animal-mosaic-card">
<a href="/animal?id=<?= (int)$a['id'] ?>"
class="animal-photo-trigger d-flex w-100 text-reset text-decoration-none"
data-zoom-src="<?= h($mosaicSrc) ?>"
aria-label="<?= h(t('animals.open_record', ['name' => $a['name']])) ?>"><img class="animal-mosaic-photo"
src="<?= h($mosaicSrc) ?>" alt="<?= h(t('animals.photo_of', ['name' => $a['name']])) ?>"></a>
<div class="card-body p-2">
<div class="d-flex align-items-center gap-1 mb-1"><a
class="fw-bold text-reset text-decoration-none animal-mosaic-name me-auto"
href="/animal?id=<?= (int)$a['id'] ?>"
title="<?= h($a['name']) ?>"><?= h($a['name']) ?></a><span
class="animal-sex-icon <?= h($mosaicSexClass) ?>"
title="<?= h($mosaicSexLabel) ?>"><?= h($mosaicSexSymbol) ?></span></div>
<div class="d-flex justify-content-center mb-1"><span class="badge"
style="background:<?= h($mosaicSpecies['color']) ?>18;color:<?= h($mosaicSpecies['color']) ?>"><?= h($mosaicSpecies['icon'] . ' ' . $mosaicSpecies['name']) ?></span>
</div>
<div class="animal-mosaic-footer">
<?php if ($mosaicFoster !== ''): ?><div class="small text-muted text-truncate text-center mb-1"
title="<?= h(t('animals.foster')) ?> : <?= h($a['foster_contact_name']) ?>">
<?= h($mosaicFoster) ?></div><?php endif; ?>
<?php if ($mosaicFreeCat !== ''): ?><div class="small text-muted text-truncate text-center mb-1"
title="<?= h(t('animals.free_cat_site')) ?> : <?= h($mosaicFreeCat) ?>">🌿
<?= h($mosaicFreeCat) ?></div><?php endif; ?>
<div class="animal-mosaic-status mb-1"><span class="badge <?= h($mosaicStatusClass) ?>"
title="<?= h($mosaicStatusLabel) ?>"><?= h($mosaicStatusLabel) ?></span></div>
<div class="animal-mosaic-icons d-flex align-items-center gap-2 small">
<?php foreach ([['compatibility_dogs','🐶',t('animals.dogs')],['compatibility_cats','🐱',t('animals.cats')],['compatibility_children','🧒',t('animals.children')],['house_trained','🧼',t('animals.cleanliness')]] as [$field,$icon,$label]): $value = (string)($a[$field] ?? 'unknown'); ?><span
title="<?= h($label . ' : ' . t($value === 'yes' ? 'common.yes' : ($value === 'no' ? 'common.no' : 'common.unknown'))) ?>"
class="<?= $value === 'yes' ? 'text-success' : ($value === 'no' ? 'text-danger' : 'text-muted') ?>"><?= $icon ?><?= $value === 'yes' ? '✓' : ($value === 'no' ? '✕' : '—') ?></span><?php endforeach; ?>
</div>
</div>
</div>
</article>
<?php endforeach; ?>
<?php if (!$animals): ?><div class="text-center text-muted py-5" style="grid-column:1/-1">
<?= h(t('animals.no_results')) ?></div><?php endif; ?>
</div>
<?php else: ?>
<div class="table-responsive mt-2">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th><?= h(t('animals.photo')) ?></th>
<!--<th>Code</th>-->
<th>
<?= sort_th(t('animals.name'), 'name', $sort, $dir) ?>
<span class="text-muted mx-1">·</span>
<?= sort_th(t('animals.sex'), 'sex', $sort, $dir) ?>
</th>
<th class="animal-species-column text-center">
<?= sort_th(t('animals.species'), 'species', $sort, $dir) ?></th>
<th class="animal-age-column text-center"><?= sort_th(t('animals.age'), 'age', $sort, $dir) ?></th>
<th class="text-center"><?= sort_th(t('animals.status'), 'status', $sort, $dir) ?></th>
<th class="life-profile-column">
<div class="life-profile-grid" aria-label="<?= h(t('animals.life_profile')) ?>"><?php foreach (['compatibility_dogs' => ['🐶',t('animals.compatible_dogs')],'compatibility_cats' => ['🐱',t('animals.compatible_cats')],'compatibility_children' => ['🧒',t('animals.compatible_children')],'house_trained' => ['🧼',t('animals.cleanliness')]] as $profileSortKey => $profileHeader): $profileQs = $_GET;
$profileQs['sort'] = $profileSortKey;
$profileQs['dir'] = ($sort === $profileSortKey && strtoupper($dir) === 'ASC') ? 'desc' : 'asc';
$profileArrow = $sort === $profileSortKey ? (strtoupper($dir) === 'ASC' ? '↑' : '↓') : ''; ?><a
class="life-profile-sort text-reset"
href="/animals?<?= h(http_build_query($profileQs)) ?>"
title="<?= h(t('animals.sort_by', ['label' => mb_strtolower($profileHeader[1])])) ?>"
aria-label="<?= h(t('animals.sort_by', ['label' => mb_strtolower($profileHeader[1])])) ?>"><?= $profileHeader[0] ?><small><?= h($profileArrow) ?></small></a><?php endforeach; ?>
</div>
</th>
<th class="text-nowrap text-center" style="min-width:9rem"><?= h(t('animals.fiv_felv')) ?></th>
<!--<th class="animal-weight-column"><?= sort_th('Poids', 'weight', $sort, $dir) ?></th>-->
<!--<th><?= sort_th('Dernier médical', 'updated_at', $sort, $dir) ?></th>-->
<!--<th>Traitements</th>-->
<!--<th class="animal-vaccine-column"><?= h(t('animals.next_vaccine')) ?></th>-->
<th><?= sort_th(t('animals.location'), 'rescue_location_name', $sort, $dir) ?></th>
<th class="w-1 text-center"><?= h(t('animals.preview')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($animals as $a): ?>
<?php
$src = !empty($a['primary_photo'])
? '/media/animals/' . (int)$a['id'] . '/' . rawurlencode($a['primary_photo'])
: '/assets/cat_placeholder.svg';
$animalSpecies = SpeciesService::normalize((string)($a['species'] ?? ''));
$speciesMeta = $speciesByCode[$animalSpecies] ?? ['name' => species_label($animalSpecies),'icon' => '🐾','color' => '#6c757d'];
$isCat = SpeciesService::isCat($animalSpecies);
$fiv = $a['fiv'] ?? null;
$felv = $a['felv'] ?? null;
$fivStatus = (string)(($a['fiv_status'] ?? '') ?: ((int)$fiv === 1 ? 'pos' : 'neg'));
$felvStatus = (string)(($a['felv_status'] ?? '') ?: ((int)$felv === 1 ? 'pos' : 'neg'));
$shortStatusLabels = ['unknown' => mb_strtolower(t('animals.test_unknown')), 'neg' => mb_strtolower(t('animals.test_negative')), 'pos' => mb_strtolower(t('animals.test_positive')), 'doubtful' => mb_strtolower(t('animals.test_doubtful'))];
$healthLabel = $isCat ? ('FIV ' . ($shortStatusLabels[$fivStatus] ?? mb_strtolower(t('animals.test_unknown')))
. ' · FeLV ' . ($shortStatusLabels[$felvStatus] ?? mb_strtolower(t('animals.test_unknown')))) : t('animals.tests_not_applicable');
$testBadgeMeta = [
'unknown' => ['?', 'bg-secondary-lt', t('animals.test_unknown')],
'neg' => ['', 'bg-green-lt', t('animals.test_negative')],
'pos' => ['+', 'bg-red-lt', t('animals.test_positive')],
'doubtful' => ['?', 'bg-yellow-lt', t('animals.test_doubtful')],
];
[$fivSymbol, $fivBadgeClass, $fivBadgeTitle] = $testBadgeMeta[$fivStatus] ?? $testBadgeMeta['unknown'];
[$felvSymbol, $felvBadgeClass, $felvBadgeTitle] = $testBadgeMeta[$felvStatus] ?? $testBadgeMeta['unknown'];
$ageLabel = '—';
$ageTitle = t('animals.age_unknown');
if (!empty($a['birth_date'])) {
try {
$birth = new DateTimeImmutable((string)$a['birth_date']);
$age = $birth->diff(new DateTimeImmutable('today'));
if ($age->y > 0) {
$ageLabel = $age->y . ' a';
if ($age->m > 0) {
$ageLabel .= ' · ' . $age->m . ' m';
}
$ageTitle = $age->y . ' an' . ($age->y > 1 ? 's' : '');
if ($age->m > 0) {
$ageTitle .= ' et ' . $age->m . ' mois';
}
} else {
$ageLabel = $age->m . ' m';
$ageTitle = $age->m . ' mois';
}
if (!empty($a['birth_is_estimated'])) {
$ageLabel = '~ ' . $ageLabel;
$ageTitle .= ' (' . t('animals.age_estimated') . ')';
}
} catch (Throwable) {
$ageLabel = '—';
}
}
$sexLabel = match ((string)($a['sex'] ?? 'U')) {
'M' => t('sex.male'),
'F' => t('sex.female'),
default => t('animals.sex_not_provided'),
};
$hairLabel = match ((string)($a['hair_type'] ?? '')) {
'court' => t('animals.short_hair'),
'mi-long' => t('animals.medium_hair'),
'long' => t('animals.long_hair'),
'frise' => t('animals.curly_hair'),
'sans-poil' => t('animals.hairless'),
default => t('animals.hair_unknown'),
};
$appearance = implode(' · ', [
(string)($a['breed'] ?: t('animals.breed_unknown')),
(string)($a['color'] ?: t('animals.coat_unknown')),
$hairLabel,
]);
$weightLabel = t('animals.weight_unknown');
if ($a['last_weight'] !== null) {
$trend = '→';
if ($a['prev_weight'] !== null) {
if ((float)$a['last_weight'] > (float)$a['prev_weight']) {
$trend = '↑';
} elseif ((float)$a['last_weight'] < (float)$a['prev_weight']) {
$trend = '↓';
}
}
$weightLabel = number_format((float)$a['last_weight'], 2, ',', ' ') . ' kg ' . $trend;
}
$vaccineLabel = !empty($a['next_vaccine_due'])
? (($a['next_vaccine_name'] ?: t('animal.vaccine_booster')) . ' · ' . $a['next_vaccine_due'])
: t('animals.no_vaccine');
$treatmentLabel = $a['ongoing_treatment_names'] ?: t('animals.no_current_treatment');
$medicalPoint = $a['latest_medical_point'] ?: t('animals.no_recent_medical');
$profileMark = static function (string $value, string $label): string {
[$symbol,$class,$state] = match($value) {
'yes' => ['✓','life-profile-yes',t('common.yes')],
'no' => ['✕','life-profile-no',t('common.no')],
default => ['—','life-profile-unknown',t('common.unknown')],
};
return '<span class="' . h($class) . '" title="' . h($label . ' : ' . $state) . '" aria-label="' . h($label . ' : ' . $state) . '">' . h($symbol) . '</span>';
};
?>
<?php if (!empty($a['_bonded_group'])): ?>
<tr class="bonded-group-placeholder">
<td><span class="litter-parent-placeholder-icon text-purple" aria-hidden="true">🔗</span></td>
<td>
<div class="fw-bold text-purple"><?= h($a['_bonded_group']['label']) ?></div>
<div class="small text-muted"><?= h($a['_bonded_group']['details']) ?></div>
<?php if ($a['_bonded_group']['notes'] !== ''): ?><div class="small text-muted">
<?= h($a['_bonded_group']['notes']) ?></div><?php endif; ?>
</td>
<td></td>
<td></td>
<td><span class="badge bg-purple-lt"><?= h(t('animals.bonded')) ?></span></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<?php endif; ?>
<?php if (!empty($a['_litter_group'])): ?>
<tr class="litter-parent-placeholder">
<td><span class="litter-parent-placeholder-icon" aria-hidden="true">🐾</span></td>
<td>
<div class="fw-bold text-muted"><?= h($a['_litter_group']['label']) ?></div>
<div class="small text-muted"><?= h($a['_litter_group']['details']) ?></div>
</td>
<td></td>
<td></td>
<td><span class="badge bg-secondary-lt"><?= h(t('animals.litter')) ?></span></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<?php endif; ?>
<tr
class="<?= (!empty($a['_litter_branch']) || !empty($a['_bonded_branch'])) ? 'litter-kitten-row ' : '' ?><?= !empty($a['_litter_branch']) ? 'litter-member-row ' : '' ?><?= !empty($a['_bonded_branch']) ? 'bonded-member-row' : '' ?>">
<td>
<span class="animal-photo-trigger" tabindex="0" data-zoom-src="<?= h($src) ?>"
aria-label="<?= h(t('animals.enlarge_photo', ['name' => $a['name']])) ?>">
<img src="<?= htmlspecialchars($src, ENT_QUOTES) ?>"
style="width:44px;height:44px;object-fit:cover;border-radius:10px" alt="">
</span>
</td>
<!--<td class="text-muted"><php echo //htmlspecialchars($a['internal_code'], ENT_QUOTES) ?></td>-->
<td>
<div class="d-flex align-items-center gap-2">
<?php $treeBranch = $a['_bonded_branch'] ?? $a['_litter_branch'] ?? ''; ?>
<?php if ($treeBranch !== ''): ?>
<span class="text-muted font-monospace" aria-hidden="true"><?= h($treeBranch) ?></span>
<?php endif; ?>
<span class="coat-swatch" style="<?= h(coat_swatch_style($a['color'] ?? '')) ?>"
title="<?= h($a['color'] ?: t('animals.coat_unknown')) ?>" aria-hidden="true"></span>
<a href="/animal?id=<?= (int)$a['id'] ?>" class="fw-bold">
<?= htmlspecialchars($a['name'], ENT_QUOTES) ?>
</a>
<?php
[$sexSymbol, $sexIconClass, $sexIconLabel] = match ((string)($a['sex'] ?? 'U')) {
'M' => ['♂', 'bg-blue-lt text-blue', t('sex.male')],
'F' => ['♀', 'bg-purple-lt text-purple', t('sex.female')],
default => ['?', 'bg-secondary-lt text-secondary', t('sex.unknown')],
};
?>
<span class="animal-sex-icon <?= h($sexIconClass) ?>" title="<?= h($sexIconLabel) ?>"
aria-label="<?= h($sexIconLabel) ?>"><?= h($sexSymbol) ?></span>
<?php if (!empty($a['_bonded_branch'])): ?><span
title="<?= h(t('animals.adopt_together')) ?>"
aria-label="<?= h(t('animals.adopt_together')) ?>">🔗</span><?php endif; ?>
<?php $sterilizationState = $a['sterilization_status'] ?? (!empty($a['sterilized']) ? 'yes' : 'unknown');
if ($sterilizationState !== 'yes'): ?>
<span
class="animal-alert-icon <?= $sterilizationState === 'no' ? 'bg-yellow-lt text-yellow' : 'bg-secondary-lt text-secondary' ?>"
title="<?= $sterilizationState === 'no' ? t('animals.not_sterilized') : t('animals.sterilization_unknown') ?>"
aria-label="<?= $sterilizationState === 'no' ? t('animals.not_sterilized') : t('animals.sterilization_unknown') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="6" cy="7" r="3"></circle>
<circle cx="6" cy="17" r="3"></circle>
<path d="M8.7 8.3L20 14"></path>
<path d="M8.7 15.7L20 10"></path>
</svg>
</span>
<?php endif; ?>
<?php if (trim((string)($a['chip_id'] ?? '')) === ''): ?>
<span class="animal-alert-icon bg-orange-lt text-orange"
title="<?= h(t('animals.not_chipped')) ?>"
aria-label="<?= h(t('animals.not_chipped')) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2"></rect>
<path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"></path>
<circle cx="12" cy="12" r="2"></circle>
</svg>
</span>
<?php endif; ?>
</div>
</td>
<td class="animal-species-column"><span class="badge"
style="background:<?= h($speciesMeta['color']) ?>18;color:<?= h($speciesMeta['color']) ?>"
title="<?= h(t('animals.species_label', ['name' => $speciesMeta['name']])) ?>"><?= h($speciesMeta['icon'] . ' ' . $speciesMeta['name']) ?></span>
</td>
<td class="animal-age-column" title="<?= h($ageTitle) ?>"><?= h($ageLabel) ?></td>
<td>
<?php [$stCls, $stLbl] = status_badge((string)($a['status'] ?? '')); ?>
<span class="badge <?= $stCls ?>"><?= htmlspecialchars($stLbl, ENT_QUOTES) ?></span>
<?php $availability = $a['adoption_availability'] ?? 'unknown';
if ($availability === 'available'): ?><span class="badge bg-green-lt ms-1"
title="<?= h(t('animals.available_title')) ?>"><?= h(t('animals.adoptable')) ?></span><?php elseif ($availability === 'not_available'): ?><span
class="badge bg-orange-lt ms-1"
title="<?= h($a['adoption_unavailability_reason'] ?: t('animals.unavailable_title')) ?>"><?= h(t('animals.not_adoptable')) ?></span><?php endif; ?>
<?php $fosterDisplay = in_array((string)($a['status'] ?? ''), ['fa','fa_permanente'], true) ? $shortContactName($a['foster_contact_name'] ?? null) : '';
if ($fosterDisplay !== ''): ?><div class="small text-muted mt-1"
title="<?= h(t('animals.foster')) ?> : <?= h($a['foster_contact_name']) ?>">
<?= h($fosterDisplay) ?></div><?php endif; ?>
<?php if ((string)($a['status'] ?? '') === 'chat_libre' && (!empty($a['free_cat_site_name']) || !empty($a['free_cat_city']))): ?>
<div class="small text-muted mt-1">🌿 <?= h($a['free_cat_site_name'] ?: $a['free_cat_city']) ?>
</div><?php endif; ?>
</td>
<td class="life-profile-column">
<div class="life-profile-grid">
<?= $profileMark((string)($a['compatibility_dogs'] ?? 'unknown'), t('animals.dogs')) ?><?= $profileMark((string)($a['compatibility_cats'] ?? 'unknown'), t('animals.cats')) ?><?= $profileMark((string)($a['compatibility_children'] ?? 'unknown'), t('animals.children')) ?><?= $profileMark((string)($a['house_trained'] ?? 'unknown'), t('animals.cleanliness')) ?>
</div>
</td>
<td class="text-nowrap">
<?php if ($isCat): ?>
<div class="d-flex flex-nowrap gap-1">
<span class="badge <?= $fivBadgeClass ?>" title="FIV : <?= h($fivBadgeTitle) ?>">FIV
<?= h($fivSymbol) ?></span>
<span class="badge <?= $felvBadgeClass ?>" title="FeLV : <?= h($felvBadgeTitle) ?>">FeLV
<?= h($felvSymbol) ?></span>
</div>
<?php else: ?><span class="text-muted">—</span><?php endif; ?>
</td>
<!--<td class="animal-weight-column">
<?php
$lw = $a['last_weight'];
$pw = $a['prev_weight'];
if ($lw === null) {
echo '—';
} else {
$arrow = '→';
if ($pw !== null) {
if ((float)$lw > (float)$pw) {
$arrow = '↑';
} elseif ((float)$lw < (float)$pw) {
$arrow = '↓';
}
}
echo htmlspecialchars(number_format((float)$lw, 2), ENT_QUOTES) . " kg <span class='text-muted'>$arrow</span>";
}
?>
</td>-->
<!--<td><?= $a['last_med_at'] ? htmlspecialchars($a['last_med_at'], ENT_QUOTES) : '—' ?></td>-->
<!--<td><?= (int)($a['ongoing_treatments'] ?? 0) ?></td>-->
<!--<td class="animal-vaccine-column"><?= $a['next_vaccine_due'] ? htmlspecialchars($a['next_vaccine_due'], ENT_QUOTES) : '—' ?></td>-->
<td>
<div class="fw-bold">
<?= h(($a['rescue_location_name'] ?? '') ?: ($a['rescue_address'] ?? '')) ?></div>
</td>
<td class="text-center">
<button class="btn btn-outline-primary btn-sm" data-bs-toggle="modal"
data-bs-target="#medPreview" data-id="<?= (int)$a['id'] ?>" data-name="<?= h($a['name']) ?>"
data-photo="<?= h($src) ?>" data-identity="<?= h($sexLabel . ' · ' . $ageLabel) ?>"
data-appearance="<?= h($appearance) ?>" data-coat="<?= h($a['color'] ?? '') ?>"
data-coat-style="<?= h(coat_swatch_style($a['color'] ?? '')) ?>"
data-health="<?= h($healthLabel) ?>" data-weight="<?= h($weightLabel) ?>"
data-vaccine="<?= h($vaccineLabel) ?>" data-treatment="<?= h($treatmentLabel) ?>"
data-deworming="<?= h($a['last_deworming_date'] ? (t('animals.latest') . ' : ' . (new DateTimeImmutable($a['last_deworming_date']))->format('d/m/Y') . ($a['next_deworming_due'] ? ' · ' . t('animals.next') . ' : ' . (new DateTimeImmutable($a['next_deworming_due']))->format('d/m/Y') : '')) : t('animals.never_provided')) ?>"
data-medical="<?= h($medicalPoint) ?>"
data-medical-date="<?= h($a['last_med_at'] ?: '') ?>">
<?= h(t('animals.medical_preview')) ?>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$animals): ?>
<tr>
<td colspan="9" class="text-center text-muted py-4"><?= h(t('animals.no_results')) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<div class="animal-photo-zoom" id="animalPhotoZoom" aria-hidden="true"><img src=""
alt="<?= h(t('animals.enlarged_preview')) ?>"></div>
<div class="modal modal-blur fade" id="medPreview" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><?= h(t('animals.medical_preview')) ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="<?= h(t('common.close')) ?>"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-center gap-3 mb-4">
<img id="medPreviewPhoto" src="/assets/cat_placeholder.svg" alt=""
style="width:76px;height:76px;object-fit:cover;border-radius:14px">
<div>
<div id="medPreviewName" class="h2 mb-1"></div>
<div id="medPreviewIdentity" class="text-muted"></div>
<div class="d-flex align-items-center gap-1 text-muted small mt-1">
<span id="medPreviewCoatSwatch" class="coat-swatch"></span>
<span id="medPreviewAppearance"></span>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-12 col-md-6">
<div class="card card-sm h-100">
<div class="card-body">
<div class="subheader mb-1"><?= h(t('animals.fiv_felv')) ?></div>
<div id="medPreviewHealth" class="fw-bold"></div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card card-sm h-100">
<div class="card-body">
<div class="subheader mb-1"><?= h(t('animals.last_weight')) ?></div>
<div id="medPreviewWeight" class="fw-bold"></div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card card-sm h-100">
<div class="card-body">
<div class="subheader mb-1"><?= h(t('animals.next_vaccine')) ?></div>
<div id="medPreviewVaccine" class="fw-bold"></div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card card-sm h-100">
<div class="card-body">
<div class="subheader mb-1"><?= h(t('animals.current_treatment')) ?></div>
<div id="medPreviewTreatment" class="fw-bold"></div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card card-sm h-100">
<div class="card-body">
<div class="subheader mb-1"><?= h(t('animals.dewormer')) ?></div>
<div id="medPreviewDeworming" class="fw-bold"></div>
</div>
</div>
</div>
<div class="col-12">
<div class="card card-sm">
<div class="card-body">
<div class="subheader mb-1"><?= h(t('animals.latest_diagnosis')) ?></div>
<div id="medPreviewMedical" class="fw-bold"></div>
<div id="medPreviewMedicalDate" class="text-muted small mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary"
data-bs-dismiss="modal"><?= h(t('common.close')) ?></button>
<a id="medPreviewLink" class="btn btn-primary"
href="/animals"><?= h(t('animals.open_full_record')) ?></a>
</div>
</div>
</div>
</div>
<script>
const animalListTranslations = < ? = json_encode(['medicalPreview' => t('animals.medical_preview'),
'neverProvided' => t('animals.never_provided'), 'lastUpdate' => t('animals.last_update', ['date' =>
'__DATE__'
])
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ? > ;
(() => {
const zoom = document.getElementById('animalPhotoZoom');
const image = zoom?.querySelector('img');
if (!zoom || !image) return;
const position = (trigger) => {
const rect = trigger.getBoundingClientRect();
const width = Math.min(332, window.innerWidth * .7 + 12);
let left = rect.right + 12;
if (left + width > window.innerWidth - 12) left = Math.max(12, rect.left - width - 12);
let top = rect.top + rect.height / 2 - Math.min(320, window.innerHeight * .6) / 2;
top = Math.max(12, Math.min(top, window.innerHeight - Math.min(332, window.innerHeight * .6) - 12));
zoom.style.left = left + 'px';
zoom.style.top = top + 'px';
};
const show = (trigger) => {
image.src = trigger.dataset.zoomSrc || '';
position(trigger);
zoom.classList.add('is-visible');
zoom.setAttribute('aria-hidden', 'false');
};
const hide = () => {
zoom.classList.remove('is-visible');
zoom.setAttribute('aria-hidden', 'true');
image.removeAttribute('src');
};
document.querySelectorAll('.animal-photo-trigger').forEach((trigger) => {
trigger.addEventListener('mouseenter', () => show(trigger));
trigger.addEventListener('mouseleave', hide);
trigger.addEventListener('focus', () => show(trigger));
trigger.addEventListener('blur', hide);
});
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
})();
document.getElementById('medPreview')?.addEventListener('show.bs.modal', (ev) => {
const btn = ev.relatedTarget;
const name = btn?.getAttribute('data-name') || '';
document.querySelector('#medPreview .modal-title').textContent = animalListTranslations.medicalPreview +
' — ' + name;
document.getElementById('medPreviewName').textContent = name;
document.getElementById('medPreviewIdentity').textContent = btn?.getAttribute('data-identity') || '—';
document.getElementById('medPreviewAppearance').textContent = btn?.getAttribute('data-appearance') ||
'—';
const coatSwatch = document.getElementById('medPreviewCoatSwatch');
const coat = btn?.getAttribute('data-coat') || '';
coatSwatch.style.cssText = btn?.getAttribute('data-coat-style') || 'background:#d9dde3';
coatSwatch.style.display = coat ? 'inline-block' : 'none';
document.getElementById('medPreviewHealth').textContent = btn?.getAttribute('data-health') || '—';
document.getElementById('medPreviewWeight').textContent = btn?.getAttribute('data-weight') || '—';
document.getElementById('medPreviewVaccine').textContent = btn?.getAttribute('data-vaccine') || '—';
document.getElementById('medPreviewTreatment').textContent = btn?.getAttribute('data-treatment') || '—';
document.getElementById('medPreviewDeworming').textContent = btn?.getAttribute('data-deworming') ||
animalListTranslations.neverProvided;
document.getElementById('medPreviewMedical').textContent = btn?.getAttribute('data-medical') || '—';
const medicalDate = btn?.getAttribute('data-medical-date') || '';
document.getElementById('medPreviewMedicalDate').textContent = medicalDate ? animalListTranslations
.lastUpdate.replace('__DATE__', medicalDate) : '';
document.getElementById('medPreviewPhoto').src = btn?.getAttribute('data-photo') ||
'/assets/cat_placeholder.svg';
document.getElementById('medPreviewLink').href = '/animal?id=' + encodeURIComponent(btn?.getAttribute(
'data-id') || '');
});
</script>