382 lines
18 KiB
PHP
382 lines
18 KiB
PHP
<?php
|
||
/** @var string $title */
|
||
require_once __DIR__ . '/_helpers.php';
|
||
require_once __DIR__ . '/_settings_links.php';
|
||
$hasRescue = $hasRescue ?? false;
|
||
$hasCurrent = $hasCurrent ?? false;
|
||
$rescueAddr = $rescueAddr ?? null;
|
||
$currentAddr = $currentAddr ?? null;
|
||
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||
$dashboardActive = $currentPath === '/' || $currentPath === '/dashboard';
|
||
$statisticsActive = $currentPath === '/statistics';
|
||
$animalsActive = $currentPath === '/animals' || str_starts_with($currentPath, '/animal');
|
||
$adminActive = str_starts_with($currentPath, '/admin');
|
||
$directoryActive = str_starts_with($currentPath, '/directory');
|
||
$careRoundActive = str_starts_with($currentPath, '/care-round');
|
||
$agendaActive = str_starts_with($currentPath, '/agenda');
|
||
$accountingActive = str_starts_with($currentPath, '/accounting');
|
||
$settingsActive = str_starts_with($currentPath, '/settings');
|
||
$settingsMenuLinks = settingsNavigationLinks();
|
||
$currentUser = Auth::user();
|
||
$pageName = trim((string)($title ?? ''));
|
||
$documentTitle = $pageName !== '' ? $pageName . ' — Globinours' : 'Globinours';
|
||
$pageDescription = trim((string)($pageDescription ?? ''));
|
||
if ($pageDescription === '') {
|
||
$pageDescription = match (true) {
|
||
$dashboardActive => t('meta.dashboard'),
|
||
$statisticsActive => t('meta.statistics'),
|
||
$adminActive => t('meta.administrative'),
|
||
$animalsActive => t('meta.animals'),
|
||
default => t('meta.default'),
|
||
};
|
||
}
|
||
?>
|
||
<!doctype html>
|
||
<html lang="<?= h(I18n::locale()) ?>">
|
||
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title><?= h($documentTitle) ?></title>
|
||
<meta name="description" content="<?= h($pageDescription) ?>">
|
||
<meta name="application-name" content="Globinours">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<meta name="theme-color" content="#206bc4">
|
||
<meta name="csrf-token" content="<?= h(Auth::csrf()) ?>">
|
||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||
<link rel="manifest" href="/manifest.webmanifest">
|
||
<link rel="stylesheet" href="/assets/mobile.css">
|
||
|
||
<link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css">
|
||
<link rel="stylesheet" href="/assets/vendor/flatpickr/flatpickr.min.css">
|
||
<link rel="stylesheet" href="/assets/app-shell.css">
|
||
|
||
<!-- Chart.js (pour les courbes poids) -->
|
||
<script src="/assets/vendor/chartjs/chart.umd.min.js"></script>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="page">
|
||
<?php require __DIR__ . '/_layout_topbar.php'; ?>
|
||
<div class="page-wrapper">
|
||
<div class="page-header d-print-none">
|
||
<div class="container-xl">
|
||
<h2 class="page-title"><?= htmlspecialchars($title ?? '', ENT_QUOTES) ?></h2>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-body">
|
||
<div class="container-xl">
|
||
<?php require $viewFile; ?>
|
||
</div>
|
||
</div>
|
||
<?php require __DIR__ . '/_layout_footer.php'; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/assets/vendor/tabler/tabler.min.js"></script>
|
||
|
||
<link rel="stylesheet" href="/assets/vendor/leaflet/leaflet.css">
|
||
<script src="/assets/vendor/leaflet/leaflet.js"></script>
|
||
<script>
|
||
(function() {
|
||
const rescue = {
|
||
lat: <?= $hasRescue ? (float) $rescueLat : 'null' ?> ,
|
||
lng: <?= $hasRescue ? (float) $rescueLng : 'null' ?> ,
|
||
addr: <?= json_encode($rescueAddr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
|
||
};
|
||
|
||
const cur = {
|
||
lat: <?= $hasCurrent ? (float) $currentLat : 'null' ?> ,
|
||
lng: <?= $hasCurrent ? (float) $currentLng : 'null' ?> ,
|
||
addr: <?= json_encode($currentAddr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
|
||
};
|
||
|
||
const el = document.getElementById('mapJourney');
|
||
if (!el || typeof L === 'undefined') return;
|
||
|
||
const fallback = [48.5, -4.3];
|
||
|
||
// icônes “pastille” colorées
|
||
const iconRescue = L.divIcon({
|
||
className: '',
|
||
iconSize: [18, 18],
|
||
iconAnchor: [9, 9],
|
||
html: '<div class="twix-pin rescue" title="' + <?= json_encode(t('map.rescue'),
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?> +'"></div>'
|
||
});
|
||
|
||
const iconCurrent = L.divIcon({
|
||
className: '',
|
||
iconSize: [18, 18],
|
||
iconAnchor: [9, 9],
|
||
html: '<div class="twix-pin current" title="' + <?= json_encode(t('map.current'),
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?> +'"></div>'
|
||
});
|
||
|
||
const map = L.map('mapJourney', {
|
||
scrollWheelZoom: false
|
||
});
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>'
|
||
}).addTo(map);
|
||
|
||
const pts = [];
|
||
|
||
// helper anti XSS + joli rendu
|
||
const esc = (s) => String(s ?? '')
|
||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||
.replaceAll('"', '"').replaceAll("'", "'");
|
||
const safePopup = (label, address) => {
|
||
const box = document.createElement('div');
|
||
const strong = document.createElement('strong');
|
||
strong.textContent = label;
|
||
box.append(strong, document.createElement('br'), document.createTextNode(address || '—'));
|
||
return box;
|
||
};
|
||
|
||
if (rescue.lat !== null && rescue.lng !== null) {
|
||
const p = [rescue.lat, rescue.lng];
|
||
pts.push(p);
|
||
L.marker(p, {
|
||
icon: iconRescue
|
||
})
|
||
.addTo(map)
|
||
.bindPopup(safePopup( <?= json_encode(t('map.rescue'), JSON_UNESCAPED_UNICODE |
|
||
JSON_UNESCAPED_SLASHES) ?> , rescue.addr), {
|
||
autoPan: false, // <-- stop le déplacement
|
||
closeButton: false
|
||
});
|
||
}
|
||
|
||
if (cur.lat !== null && cur.lng !== null) {
|
||
const p = [cur.lat, cur.lng];
|
||
pts.push(p);
|
||
L.marker(p, {
|
||
icon: iconCurrent
|
||
})
|
||
.addTo(map)
|
||
.bindPopup(safePopup( <?= json_encode(t('map.current'), JSON_UNESCAPED_UNICODE |
|
||
JSON_UNESCAPED_SLASHES) ?> , cur.addr), {
|
||
autoPan: false, // <-- stop le déplacement
|
||
closeButton: false
|
||
});
|
||
}
|
||
|
||
if (pts.length === 0) {
|
||
map.setView(fallback, 8);
|
||
L.popup({
|
||
autoPan: false
|
||
}).setLatLng(fallback).setContent( <?= json_encode(t('map.missing_coordinates'),
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?> ).openOn(map);
|
||
} else if (pts.length === 1) {
|
||
map.setView(pts[0], 14);
|
||
} else {
|
||
// “vol d’oiseau” : ligne + fitBounds avec padding pour éviter les pins collés au bord
|
||
L.polyline(pts, {
|
||
weight: 3,
|
||
opacity: 0.8,
|
||
dashArray: "6 6"
|
||
}).addTo(map);
|
||
map.fitBounds(L.latLngBounds(pts), {
|
||
padding: [60, 60]
|
||
}); // <-- plus de marge => popup moins au bord
|
||
}
|
||
|
||
// Légende (dans la carte)
|
||
const legend = L.control({
|
||
position: 'topright'
|
||
});
|
||
legend.onAdd = function() {
|
||
const div = L.DomUtil.create('div', 'twix-map-legend');
|
||
div.innerHTML = `
|
||
<div class="row"><span class="dot rescue"></span> ${<?= json_encode(t('map.rescue'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>}</div>
|
||
<div class="row"><span class="dot current"></span> ${<?= json_encode(t('map.current'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>}</div>
|
||
<div class="row" style="opacity:.9"><span class="line"></span> ${<?= json_encode(t('map.flight_path'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>}</div>
|
||
`;
|
||
return div;
|
||
};
|
||
legend.addTo(map);
|
||
|
||
// Leaflet + tabs : fix affichage
|
||
setTimeout(() => map.invalidateSize(), 200);
|
||
})();
|
||
|
||
</script>
|
||
<script src="/assets/vendor/flatpickr/flatpickr.min.js"></script>
|
||
<script src="/assets/vendor/flatpickr/l10n/fr.js"></script>
|
||
<script defer src="/assets/pwa.js"></script>
|
||
<script src="/assets/global-search.js"></script>
|
||
<script>
|
||
const settingsMenu = document.querySelector('.settings-dropdown .dropdown-menu');
|
||
if (settingsMenu) {
|
||
const groupHeaders = [...settingsMenu.children].filter(node => node.classList.contains('dropdown-header'));
|
||
if (groupHeaders.length === 4) {
|
||
const groups = groupHeaders.map(header => {
|
||
const group = document.createElement('div');
|
||
group.className = 'settings-menu-group';
|
||
const nodes = [header];
|
||
let next = header.nextElementSibling;
|
||
while (next && !next.classList.contains('dropdown-divider') && !next.classList.contains(
|
||
'dropdown-header') && next.tagName !== 'FORM') {
|
||
nodes.push(next);
|
||
next = next.nextElementSibling;
|
||
}
|
||
nodes.forEach(node => group.appendChild(node));
|
||
return group;
|
||
});
|
||
const logout = settingsMenu.querySelector(':scope > form[action="/logout"]');
|
||
const logoutDivider = logout?.previousElementSibling?.classList.contains('dropdown-divider') ? logout
|
||
.previousElementSibling : null;
|
||
[...settingsMenu.querySelectorAll(':scope > .dropdown-divider')].forEach((divider, index) => {
|
||
if (index > 0 && divider !== logoutDivider) divider.remove();
|
||
});
|
||
const columns = document.createElement('div');
|
||
columns.className = 'settings-menu-columns';
|
||
const configuration = document.createElement('div');
|
||
configuration.className = 'settings-menu-column';
|
||
configuration.append(groups[0], groups[1]);
|
||
const administration = document.createElement('div');
|
||
administration.className = 'settings-menu-column';
|
||
administration.append(groups[2], groups[3]);
|
||
columns.append(configuration, administration);
|
||
settingsMenu.insertBefore(columns, logoutDivider || logout || null);
|
||
settingsMenu.classList.add('settings-menu-wide');
|
||
}
|
||
}
|
||
const globinoursPermissions = <?= json_encode(PermissionService::currentPermissions(),
|
||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?> ;
|
||
const permissionModuleForPath = path => {
|
||
if (path === '/' || path.startsWith('/dashboard')) return 'dashboard';
|
||
if (path.startsWith('/animal/documents')) return 'medical';
|
||
if (path.startsWith('/animals') || path.startsWith('/animal') || path.startsWith('/litter') || path
|
||
.startsWith('/bonded')) {
|
||
if (['/animal/add-medical', '/animal/medical-status', '/animal/add-treatment',
|
||
'/animal/add-vaccine', '/animal/death', '/animal/surgery'
|
||
].some(prefix => path.startsWith(prefix))) return 'medical';
|
||
return 'animals';
|
||
}
|
||
if (path.startsWith('/care-round')) return 'care';
|
||
if (path.startsWith('/directory')) return 'directory';
|
||
if (path.startsWith('/statistics')) return 'statistics';
|
||
if (path.startsWith('/accounting')) return 'accounting';
|
||
if (path.startsWith('/admin/grants')) return 'grants';
|
||
if (path.startsWith('/admin') || path.startsWith('/documents')) return 'administrative';
|
||
return null;
|
||
};
|
||
const permissionEditPages = new Set(['/animal/new', '/animal/edit', '/animal/death', '/litter/new',
|
||
'/litter/add-existing', '/litter/add-existing/save', '/bonded/new', '/directory/new',
|
||
'/directory/edit', '/care-round/setup'
|
||
]);
|
||
const permissionAllows = (url, action = 'view') => {
|
||
let path;
|
||
try {
|
||
path = new URL(url, location.origin).pathname;
|
||
} catch {
|
||
return true;
|
||
}
|
||
const module = permissionModuleForPath(path);
|
||
if (!module) return true;
|
||
if (action === 'view' && permissionEditPages.has(path)) action = 'edit';
|
||
return Boolean(globinoursPermissions[module]?.[action]);
|
||
};
|
||
document.querySelectorAll('.page-body form').forEach(form => {
|
||
if ((form.method || 'get').toLowerCase() !== 'post') return;
|
||
if (!permissionAllows(form.action, 'edit')) {
|
||
form.hidden = true;
|
||
form.dataset.permissionHidden = 'true';
|
||
}
|
||
});
|
||
document.querySelectorAll('.page-body a[href]').forEach(link => {
|
||
const raw = link.getAttribute('href') || '';
|
||
if (raw.startsWith('#') || raw.startsWith('javascript:')) return;
|
||
if (!permissionAllows(link.href, 'view')) {
|
||
link.hidden = true;
|
||
link.dataset.permissionHidden = 'true';
|
||
}
|
||
});
|
||
document.querySelectorAll('.page-body [data-bs-target]').forEach(trigger => {
|
||
const target = document.querySelector(trigger.getAttribute('data-bs-target'));
|
||
const actions = target ? [...target.querySelectorAll('form')].map(form => form.action) : [];
|
||
if (actions.length && actions.every(action => !permissionAllows(action, 'edit'))) {
|
||
trigger.hidden = true;
|
||
trigger.dataset.permissionHidden = 'true';
|
||
}
|
||
});
|
||
document.querySelectorAll('.page-body .dropdown-menu').forEach(menu => {
|
||
const available = [...menu.querySelectorAll('a,button,form')].some(item => !item.hidden && item
|
||
.dataset.permissionHidden !== 'true' && !item.closest('[data-permission-hidden="true"]'));
|
||
if (!available) {
|
||
const dropdown = menu.closest('.dropdown');
|
||
if (dropdown) dropdown.hidden = true;
|
||
}
|
||
});
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
||
document.querySelectorAll('form[method="post" i]').forEach(form => {
|
||
if (!csrfToken || form.querySelector('input[name="csrf"]')) return;
|
||
const input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = 'csrf';
|
||
input.value = csrfToken;
|
||
form.appendChild(input);
|
||
});
|
||
const validationMessages = {
|
||
required: <?= json_encode(t('validation.required'), JSON_UNESCAPED_UNICODE) ?> ,
|
||
passwordMin: count => <?= json_encode(t('validation.password_min'), JSON_UNESCAPED_UNICODE) ?>
|
||
.replace(':count', count),
|
||
invalidFormat: <?= json_encode(t('validation.invalid_format'), JSON_UNESCAPED_UNICODE) ?> ,
|
||
checkField: <?= json_encode(t('validation.check_field'), JSON_UNESCAPED_UNICODE) ?>
|
||
};
|
||
document.querySelectorAll('form').forEach(form => {
|
||
if (!form.querySelector('input[type="password"]')) return;
|
||
form.noValidate = true;
|
||
form.addEventListener('submit', event => {
|
||
form.querySelectorAll('.is-invalid').forEach(field => field.classList.remove(
|
||
'is-invalid'));
|
||
if (form.checkValidity()) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
form.querySelectorAll(':invalid').forEach(field => {
|
||
field.classList.add('is-invalid');
|
||
let feedback = field.parentElement.querySelector('.invalid-feedback');
|
||
if (!feedback) {
|
||
feedback = document.createElement('div');
|
||
feedback.className = 'invalid-feedback';
|
||
field.insertAdjacentElement('afterend', feedback);
|
||
}
|
||
feedback.textContent = field.validity.valueMissing ? validationMessages
|
||
.required : field.validity.tooShort ? validationMessages.passwordMin(
|
||
field.minLength) : field.validity.typeMismatch ? validationMessages
|
||
.invalidFormat : validationMessages.checkField;
|
||
});
|
||
form.querySelector(':invalid')?.focus();
|
||
});
|
||
form.querySelectorAll('input').forEach(field => field.addEventListener('input', () => field
|
||
.classList.remove('is-invalid')));
|
||
});
|
||
document.querySelectorAll('input[type="date"]').forEach(input => {
|
||
flatpickr(input, {
|
||
locale: <?= json_encode(I18n::locale() === 'fr' ? 'fr' : 'default') ?> ,
|
||
altInput: true,
|
||
altFormat: 'd/m/Y',
|
||
dateFormat: 'Y-m-d',
|
||
allowInput: true,
|
||
disableMobile: true
|
||
});
|
||
});
|
||
|
||
</script>
|
||
<script src="/assets/accounting.js"></script>
|
||
<script>
|
||
document.querySelectorAll('select option').forEach(o => {
|
||
const s = o.textContent || '';
|
||
if (s) o.textContent = s.charAt(0).toLocaleUpperCase(document.documentElement.lang || 'fr') + s
|
||
.slice(1);
|
||
});
|
||
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|