998 lines
40 KiB
PHP
998 lines
40 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
final class SettingsController
|
||
{
|
||
private const ROLES = PermissionService::ROLES;
|
||
public static function general(): void
|
||
{
|
||
self::admin();
|
||
render('settings_general.php', [
|
||
'title' => t('settings.general_title'),
|
||
'settings' => AppSettings::all(),
|
||
'saved' => isset($_GET['saved']),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
'associationLogo' => AssociationBrand::path(),
|
||
]);
|
||
}
|
||
public static function saveGeneral(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$values = [];
|
||
foreach (
|
||
[
|
||
'association_name',
|
||
'association_address',
|
||
'association_postal_code',
|
||
'association_city',
|
||
'association_phone',
|
||
'association_email',
|
||
'association_siret',
|
||
'association_rna',
|
||
]
|
||
as $key
|
||
) {
|
||
$values[$key] = trim((string) ($_POST[$key] ?? ''));
|
||
}
|
||
if ($values['association_name'] === '') {
|
||
http_response_code(400);
|
||
echo h(t('settings.association_required'));
|
||
return;
|
||
}
|
||
if ($values['association_email'] !== '' && !filter_var($values['association_email'], FILTER_VALIDATE_EMAIL)) {
|
||
http_response_code(400);
|
||
echo h(t('settings.invalid_email'));
|
||
return;
|
||
}
|
||
$values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15)));
|
||
$values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10)));
|
||
$values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')])
|
||
? (string) $_POST['app_language']
|
||
: 'fr';
|
||
try {
|
||
$fullAddress = implode(
|
||
', ',
|
||
array_filter([
|
||
$values['association_address'],
|
||
trim($values['association_postal_code'] . ' ' . $values['association_city']),
|
||
]),
|
||
);
|
||
if ($fullAddress === '') {
|
||
$values['association_latitude'] = '';
|
||
$values['association_longitude'] = '';
|
||
} elseif ($point = GeoService::geocode(DB::pdo(), $fullAddress)) {
|
||
$values['association_latitude'] = (string) $point['lat'];
|
||
$values['association_longitude'] = (string) $point['lng'];
|
||
}
|
||
if (isset($_POST['delete_association_logo'])) {
|
||
AssociationBrand::delete();
|
||
} elseif (isset($_FILES['association_logo'])) {
|
||
AssociationBrand::store($_FILES['association_logo']);
|
||
}
|
||
AppSettings::save($values, Auth::id());
|
||
AuditService::log(
|
||
'settings_saved',
|
||
'/settings/general/save',
|
||
'Paramètres généraux et identité visuelle modifiés',
|
||
);
|
||
header('Location: /settings/general?saved=1');
|
||
} catch (Throwable $e) {
|
||
header('Location: /settings/general?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function associationLogo(): void
|
||
{
|
||
self::admin();
|
||
$path = AssociationBrand::path();
|
||
if (!$path) {
|
||
http_response_code(404);
|
||
return;
|
||
}
|
||
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($path) ?: 'application/octet-stream';
|
||
header('Content-Type: ' . $mime);
|
||
header('Content-Length: ' . filesize($path));
|
||
header('Cache-Control: private, max-age=3600');
|
||
header('X-Content-Type-Options: nosniff');
|
||
readfile($path);
|
||
}
|
||
public static function users(): void
|
||
{
|
||
self::admin();
|
||
$users = DB::pdo()
|
||
->query('SELECT * FROM users ORDER BY active DESC,display_name COLLATE NOCASE,username COLLATE NOCASE')
|
||
->fetchAll(PDO::FETCH_ASSOC);
|
||
render('settings_users.php', ['title' => t('settings.users_title'), 'users' => $users, 'roles' => self::ROLES]);
|
||
}
|
||
public static function saveUser(): void
|
||
{
|
||
self::admin();
|
||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||
http_response_code(405);
|
||
return;
|
||
}
|
||
$db = DB::pdo();
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
$username = trim((string) ($_POST['username'] ?? ''));
|
||
$display = trim((string) ($_POST['display_name'] ?? ''));
|
||
$role = (string) ($_POST['role'] ?? 'lecture');
|
||
$active = isset($_POST['active']) ? 1 : 0;
|
||
$password = (string) ($_POST['password'] ?? '');
|
||
if (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username) || $display === '' || !isset(self::ROLES[$role])) {
|
||
http_response_code(400);
|
||
echo h(t('settings.invalid_data'));
|
||
return;
|
||
}
|
||
if ($id === Auth::id() && (!$active || $role !== 'admin')) {
|
||
http_response_code(400);
|
||
echo h(t('settings.keep_admin'));
|
||
return;
|
||
}
|
||
if ($id) {
|
||
$params = [':u' => $username, ':d' => $display, ':r' => $role, ':a' => $active, ':id' => $id];
|
||
$sql = "UPDATE users SET username=:u,display_name=:d,role=:r,active=:a,updated_at=datetime('now')";
|
||
if ($password !== '') {
|
||
if (strlen($password) < 10) {
|
||
http_response_code(400);
|
||
echo h(t('settings.password_short'));
|
||
return;
|
||
}
|
||
$sql .= ',password_hash=:p';
|
||
$params[':p'] = password_hash($password, PASSWORD_DEFAULT);
|
||
}
|
||
$sql .= ' WHERE id=:id';
|
||
$db->prepare($sql)->execute($params);
|
||
$summary = 'Utilisateur modifié : ' . $display;
|
||
} else {
|
||
if (strlen($password) < 10) {
|
||
http_response_code(400);
|
||
echo h(t('settings.password_required'));
|
||
return;
|
||
}
|
||
$db->prepare(
|
||
'INSERT INTO users(username,password_hash,display_name,role,active) VALUES(:u,:p,:d,:r,:a)',
|
||
)->execute([
|
||
':u' => $username,
|
||
':p' => password_hash($password, PASSWORD_DEFAULT),
|
||
':d' => $display,
|
||
':r' => $role,
|
||
':a' => $active,
|
||
]);
|
||
$id = (int) $db->lastInsertId();
|
||
$summary = 'Utilisateur créé : ' . $display;
|
||
}
|
||
AuditService::log('user_saved', '/settings/users/save', $summary, 'user', $id, [
|
||
'role' => $role,
|
||
'active' => $active,
|
||
]);
|
||
header('Location: /settings/users');
|
||
exit();
|
||
}
|
||
public static function permissions(): void
|
||
{
|
||
self::admin();
|
||
render('settings_permissions.php', [
|
||
'title' => t('settings.permissions_title'),
|
||
'roles' => PermissionService::ROLES,
|
||
'modules' => PermissionService::MODULES,
|
||
'matrix' => PermissionService::matrix(),
|
||
'saved' => isset($_GET['saved']),
|
||
]);
|
||
}
|
||
public static function savePermissions(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
PermissionService::save((array) ($_POST['permissions'] ?? []), Auth::id());
|
||
AuditService::log('permissions_saved', '/settings/permissions/save', 'Permissions des rôles modifiées');
|
||
header('Location: /settings/permissions?saved=1');
|
||
exit();
|
||
}
|
||
public static function species(): void
|
||
{
|
||
self::admin();
|
||
render('settings_species.php', [
|
||
'title' => t('settings.species_title'),
|
||
'species' => SpeciesService::all(),
|
||
'categories' => SpeciesService::CATEGORIES,
|
||
'saved' => isset($_GET['saved']),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
]);
|
||
}
|
||
public static function rooms(): void
|
||
{
|
||
self::admin();
|
||
render('settings_rooms.php', [
|
||
'title' => t('rooms.title'),
|
||
'rooms' => ShelterRoomService::all(),
|
||
'saved' => isset($_GET['saved']),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
]);
|
||
}
|
||
public static function saveRooms(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$db = DB::pdo();
|
||
$submitted = (array) ($_POST['rooms'] ?? []);
|
||
try {
|
||
$db->beginTransaction();
|
||
foreach ($submitted as $id => $row) {
|
||
$id = (int) $id;
|
||
if (!$id) {
|
||
continue;
|
||
}
|
||
$name = trim((string) ($row['name'] ?? ''));
|
||
$type = (string) ($row['room_type'] ?? 'collective');
|
||
$status = (string) ($row['status_code'] ?? 'refuge');
|
||
$color = (string) ($row['color'] ?? '#6c757d');
|
||
if (
|
||
$name === '' ||
|
||
!in_array($type, ['collective', 'boxes'], true) ||
|
||
!in_array($status, ['refuge', 'soin', 'quarantaine'], true) ||
|
||
!preg_match('/^#[0-9a-fA-F]{6}$/', $color)
|
||
) {
|
||
throw new RuntimeException(t('rooms.invalid'));
|
||
}
|
||
$db->prepare(
|
||
"UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,updated_at=datetime('now') WHERE id=?",
|
||
)->execute([
|
||
$name,
|
||
$type,
|
||
$status,
|
||
$color,
|
||
isset($row['bulk_validation']) ? 1 : 0,
|
||
isset($row['active']) ? 1 : 0,
|
||
max(0, (int) ($row['sort_order'] ?? 100)),
|
||
$id,
|
||
]);
|
||
self::syncBoxes($db, $id, (string) ($row['boxes'] ?? ''));
|
||
}
|
||
$new = (array) ($_POST['new_room'] ?? []);
|
||
$newName = trim((string) ($new['name'] ?? ''));
|
||
if ($newName !== '') {
|
||
$code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName));
|
||
if (strlen($code) < 2) {
|
||
throw new RuntimeException(t('rooms.invalid'));
|
||
}
|
||
$type = in_array($new['room_type'] ?? '', ['collective', 'boxes'], true)
|
||
? (string) $new['room_type']
|
||
: 'collective';
|
||
$status = in_array($new['status_code'] ?? '', ['refuge', 'soin', 'quarantaine'], true)
|
||
? (string) $new['status_code']
|
||
: 'refuge';
|
||
$color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? ''))
|
||
? (string) $new['color']
|
||
: '#6c757d';
|
||
$db->prepare(
|
||
'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)',
|
||
)->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]);
|
||
self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? ''));
|
||
}
|
||
$db->commit();
|
||
AuditService::log('rooms_saved', '/settings/rooms/save', 'Salles et box du refuge modifiés');
|
||
header('Location: /settings/rooms?saved=1');
|
||
} catch (Throwable $e) {
|
||
if ($db->inTransaction()) {
|
||
$db->rollBack();
|
||
}
|
||
header('Location: /settings/rooms?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
private static function syncBoxes(PDO $db, int $roomId, string $lines): void
|
||
{
|
||
$names = array_values(
|
||
array_filter(array_map('trim', preg_split('/\R/u', $lines) ?: []), static fn($name) => $name !== ''),
|
||
);
|
||
$existing = $db->prepare('SELECT id,code FROM shelter_boxes WHERE room_id=? ORDER BY sort_order,id');
|
||
$existing->execute([$roomId]);
|
||
$known = $existing->fetchAll(PDO::FETCH_ASSOC);
|
||
$kept = [];
|
||
foreach ($names as $index => $name) {
|
||
$position = ($index + 1) * 10;
|
||
if (isset($known[$index])) {
|
||
$id = (int) $known[$index]['id'];
|
||
$kept[] = $id;
|
||
$db->prepare(
|
||
"UPDATE shelter_boxes SET name=?,active=1,sort_order=?,updated_at=datetime('now') WHERE id=?",
|
||
)->execute([$name, $position, $id]);
|
||
continue;
|
||
}
|
||
$code = ShelterRoomService::normalizeCode($name) ?: 'box-' . ($index + 1);
|
||
$base = $code;
|
||
$suffix = 2;
|
||
$exists = $db->prepare('SELECT 1 FROM shelter_boxes WHERE room_id=? AND code=?');
|
||
while (true) {
|
||
$exists->execute([$roomId, $code]);
|
||
if (!$exists->fetchColumn()) {
|
||
break;
|
||
}
|
||
$code = $base . '-' . $suffix++;
|
||
}
|
||
$db->prepare('INSERT INTO shelter_boxes(room_id,code,name,sort_order) VALUES(?,?,?,?)')->execute([
|
||
$roomId,
|
||
$code,
|
||
$name,
|
||
$position,
|
||
]);
|
||
$kept[] = (int) $db->lastInsertId();
|
||
}
|
||
foreach ($known as $box) {
|
||
if (!in_array((int) $box['id'], $kept, true)) {
|
||
$db->prepare("UPDATE shelter_boxes SET active=0,updated_at=datetime('now') WHERE id=?")->execute([
|
||
(int) $box['id'],
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
public static function saveSpecies(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$db = DB::pdo();
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
$name = trim((string) ($_POST['name'] ?? ''));
|
||
$code = SpeciesService::normalize((string) ($_POST['code'] ?? ''));
|
||
$category = (string) ($_POST['category'] ?? 'autre');
|
||
$icon = trim((string) ($_POST['icon'] ?? '')) ?: '🐾';
|
||
$color = trim((string) ($_POST['color'] ?? '#6c757d'));
|
||
$active = isset($_POST['active']) ? 1 : 0;
|
||
$sort = max(0, min(999, (int) ($_POST['sort_order'] ?? 100)));
|
||
if (
|
||
$name === '' ||
|
||
!preg_match('/^[a-z0-9][a-z0-9-]{1,49}$/', $code) ||
|
||
!isset(SpeciesService::CATEGORIES[$category]) ||
|
||
!preg_match('/^#[0-9a-fA-F]{6}$/', $color)
|
||
) {
|
||
header('Location: /settings/species?error=' . rawurlencode(t('settings.species_invalid')));
|
||
exit();
|
||
}
|
||
try {
|
||
if ($id > 0) {
|
||
$old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id');
|
||
$old->execute([':id' => $id]);
|
||
$oldRow = $old->fetch(PDO::FETCH_ASSOC);
|
||
if (!$oldRow) {
|
||
throw new RuntimeException(t('settings.species_not_found'));
|
||
}
|
||
$oldCode = (string) $oldRow['code'];
|
||
if ((int) $oldRow['is_system'] === 1) {
|
||
$code = $oldCode;
|
||
}
|
||
$db->beginTransaction();
|
||
$db->prepare(
|
||
'UPDATE ref_species SET code=:code,name=:name,category=:category,icon=:icon,color=:color,active=:active,sort_order=:sort,updated_at=datetime(\'now\') WHERE id=:id',
|
||
)->execute([
|
||
':code' => $code,
|
||
':name' => $name,
|
||
':category' => $category,
|
||
':icon' => $icon,
|
||
':color' => $color,
|
||
':active' => $active,
|
||
':sort' => $sort,
|
||
':id' => $id,
|
||
]);
|
||
if ($oldCode !== $code) {
|
||
$db->prepare('UPDATE animals SET species=:new WHERE lower(trim(species))=lower(:old)')->execute([
|
||
':new' => $code,
|
||
':old' => $oldCode,
|
||
]);
|
||
}
|
||
$db->commit();
|
||
} else {
|
||
$db->prepare(
|
||
'INSERT INTO ref_species(code,name,category,icon,color,active,sort_order) VALUES(:code,:name,:category,:icon,:color,:active,:sort)',
|
||
)->execute([
|
||
':code' => $code,
|
||
':name' => $name,
|
||
':category' => $category,
|
||
':icon' => $icon,
|
||
':color' => $color,
|
||
':active' => $active,
|
||
':sort' => $sort,
|
||
]);
|
||
}
|
||
AuditService::log(
|
||
'species_saved',
|
||
'/settings/species/save',
|
||
'Espèce enregistrée : ' . $name,
|
||
'species',
|
||
$id ?: ((int) $db->lastInsertId()),
|
||
);
|
||
header('Location: /settings/species?saved=1');
|
||
} catch (Throwable $e) {
|
||
if ($db->inTransaction()) {
|
||
$db->rollBack();
|
||
}
|
||
header('Location: /settings/species?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function pricing(): void
|
||
{
|
||
self::admin();
|
||
$db = DB::pdo();
|
||
$clinics = $db
|
||
->query(
|
||
"SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere') WHERE dc.kind='organization' AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE",
|
||
)
|
||
->fetchAll(PDO::FETCH_ASSOC);
|
||
$discounts = [];
|
||
foreach ($db->query('SELECT * FROM clinic_discount_rules')->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||
$discounts[(int) $r['clinic_contact_id']][(string) $r['discount_group']] = $r;
|
||
}
|
||
render('settings_pricing.php', [
|
||
'title' => t('pricing.title'),
|
||
'tariffs' => PricingService::tariffs(),
|
||
'clinics' => $clinics,
|
||
'discounts' => $discounts,
|
||
'categories' => PricingService::CATEGORIES,
|
||
'groups' => PricingService::GROUPS,
|
||
'medications' => $db
|
||
->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE')
|
||
->fetchAll(PDO::FETCH_ASSOC),
|
||
'vaccines' => $db
|
||
->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE')
|
||
->fetchAll(PDO::FETCH_ASSOC),
|
||
'dewormers' => $db
|
||
->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE')
|
||
->fetchAll(PDO::FETCH_ASSOC),
|
||
'settings' => AppSettings::all(),
|
||
'saved' => isset($_GET['saved']),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
]);
|
||
}
|
||
public static function saveTariff(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$db = DB::pdo();
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
$clinic = (int) ($_POST['clinic_contact_id'] ?? 0);
|
||
$label = trim((string) ($_POST['label'] ?? ''));
|
||
$category = (string) ($_POST['category'] ?? 'other');
|
||
$group = (string) ($_POST['discount_group'] ?? 'act');
|
||
$unit = trim((string) ($_POST['unit_label'] ?? 'acte')) ?: 'acte';
|
||
$amount = PricingService::cents((string) ($_POST['amount'] ?? '0'));
|
||
$active = isset($_POST['active']) ? 1 : 0;
|
||
$eligible = $db->prepare(
|
||
"SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))",
|
||
);
|
||
$eligible->execute([':id' => $clinic]);
|
||
if (
|
||
!$clinic ||
|
||
!$eligible->fetchColumn() ||
|
||
$label === '' ||
|
||
!isset(PricingService::CATEGORIES[$category]) ||
|
||
!isset(PricingService::GROUPS[$group])
|
||
) {
|
||
header('Location: /settings/pricing?error=' . rawurlencode(t('pricing.invalid_structure')));
|
||
exit();
|
||
}
|
||
$params = [
|
||
$clinic,
|
||
mb_substr($label, 0, 180),
|
||
$category,
|
||
$group,
|
||
mb_substr($unit, 0, 50),
|
||
(int) ($_POST['medication_id'] ?? 0) ?: null,
|
||
(int) ($_POST['vaccine_id'] ?? 0) ?: null,
|
||
(int) ($_POST['dewormer_id'] ?? 0) ?: null,
|
||
$amount,
|
||
$active,
|
||
trim((string) ($_POST['notes'] ?? '')) ?: null,
|
||
];
|
||
try {
|
||
if ($id) {
|
||
$params[] = $id;
|
||
$db->prepare(
|
||
"UPDATE clinic_tariffs SET clinic_contact_id=?,label=?,category=?,discount_group=?,unit_label=?,medication_id=?,vaccine_id=?,dewormer_id=?,amount_cents=?,active=?,notes=?,updated_at=datetime('now') WHERE id=?",
|
||
)->execute($params);
|
||
} else {
|
||
$db->prepare(
|
||
'INSERT INTO clinic_tariffs(clinic_contact_id,label,category,discount_group,unit_label,medication_id,vaccine_id,dewormer_id,amount_cents,active,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||
)->execute($params);
|
||
$id = (int) $db->lastInsertId();
|
||
}
|
||
AuditService::log(
|
||
'tariff_saved',
|
||
'/settings/pricing/tariff',
|
||
'Tarif enregistré : ' . $label,
|
||
'clinic_tariff',
|
||
$id,
|
||
);
|
||
header('Location: /settings/pricing?saved=1');
|
||
} catch (Throwable $e) {
|
||
header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function saveDiscount(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$clinic = (int) ($_POST['clinic_contact_id'] ?? 0);
|
||
$submitted = (array) ($_POST['discounts'] ?? []);
|
||
$db = DB::pdo();
|
||
$eligible = $db->prepare(
|
||
"SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))",
|
||
);
|
||
$eligible->execute([':id' => $clinic]);
|
||
if (!$clinic || !$eligible->fetchColumn()) {
|
||
http_response_code(400);
|
||
echo h(t('pricing.invalid_discount_structure'));
|
||
return;
|
||
}
|
||
$stmt = $db->prepare(
|
||
'INSERT INTO clinic_discount_rules(clinic_contact_id,discount_group,label,percent) VALUES(?,?,?,?) ON CONFLICT(clinic_contact_id,discount_group) DO UPDATE SET label=excluded.label,percent=excluded.percent',
|
||
);
|
||
$saved = [];
|
||
try {
|
||
$db->beginTransaction();
|
||
foreach (PricingService::GROUPS as $group => $label) {
|
||
if ($group === 'none' || !array_key_exists($group, $submitted)) {
|
||
continue;
|
||
}
|
||
$percent = max(0, min(100, (float) str_replace(',', '.', (string) $submitted[$group])));
|
||
$stmt->execute([$clinic, $group, $label, $percent]);
|
||
$saved[$group] = $percent;
|
||
}
|
||
$db->commit();
|
||
AuditService::log(
|
||
'discount_saved',
|
||
'/settings/pricing/discount',
|
||
'Remises tarifaires modifiées',
|
||
'clinic',
|
||
$clinic,
|
||
$saved,
|
||
);
|
||
header('Location: /settings/pricing?saved=1');
|
||
} catch (Throwable $e) {
|
||
if ($db->inTransaction()) {
|
||
$db->rollBack();
|
||
}
|
||
header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function saveAdoptionFees(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
AppSettings::save(
|
||
[
|
||
'adoption_fee_sterilized' => (string) max(
|
||
0,
|
||
min(2000, (int) ($_POST['adoption_fee_sterilized'] ?? 220)),
|
||
),
|
||
'adoption_fee_unsterilized' => (string) max(
|
||
0,
|
||
min(2000, (int) ($_POST['adoption_fee_unsterilized'] ?? 200)),
|
||
),
|
||
],
|
||
Auth::id(),
|
||
);
|
||
AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs d’adoption modifiés');
|
||
header('Location: /settings/pricing?saved=1');
|
||
exit();
|
||
}
|
||
public static function labReferences(): void
|
||
{
|
||
self::admin();
|
||
$rows = LabReferenceService::all();
|
||
$groups = [];
|
||
foreach ($rows as $row) {
|
||
$groups[$row['analyzer_name']][$row['species_code']][] = $row;
|
||
}
|
||
render('settings_lab_references.php', [
|
||
'title' => t('lab_reference.title'),
|
||
'groups' => $groups,
|
||
'saved' => isset($_GET['saved']),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
]);
|
||
}
|
||
public static function saveLabReferences(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$db = DB::pdo();
|
||
$input = (array) ($_POST['ranges'] ?? []);
|
||
$stmt = $db->prepare(
|
||
"UPDATE lab_reference_ranges SET parameter_name=?,unit=?,reference_min=?,reference_max=?,source_note=?,active=?,updated_by=?,updated_at=datetime('now') WHERE id=?",
|
||
);
|
||
try {
|
||
$db->beginTransaction();
|
||
foreach ($input as $id => $row) {
|
||
$id = (int) $id;
|
||
$name = trim((string) ($row['name'] ?? ''));
|
||
$unit = trim((string) ($row['unit'] ?? ''));
|
||
$min = self::nullableNumber($row['min'] ?? '');
|
||
$max = self::nullableNumber($row['max'] ?? '');
|
||
if (!$id || $name === '' || $unit === '' || ($min !== null && $max !== null && $min > $max)) {
|
||
throw new RuntimeException(t('lab_reference.invalid'));
|
||
}
|
||
$stmt->execute([
|
||
mb_substr($name, 0, 120),
|
||
mb_substr($unit, 0, 30),
|
||
$min,
|
||
$max,
|
||
mb_substr(trim((string) ($row['source'] ?? '')), 0, 255) ?: null,
|
||
isset($row['active']) ? 1 : 0,
|
||
Auth::id(),
|
||
$id,
|
||
]);
|
||
}
|
||
$db->commit();
|
||
AuditService::log(
|
||
'lab_references_saved',
|
||
'/settings/lab-references/save',
|
||
'Intervalles de référence biologiques modifiés',
|
||
);
|
||
header('Location: /settings/lab-references?saved=1');
|
||
} catch (RuntimeException $e) {
|
||
if ($db->inTransaction()) {
|
||
$db->rollBack();
|
||
}
|
||
header('Location: /settings/lab-references?error=' . rawurlencode($e->getMessage()));
|
||
} catch (Throwable $e) {
|
||
if ($db->inTransaction()) {
|
||
$db->rollBack();
|
||
}
|
||
header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function publicSite(): void
|
||
{
|
||
self::admin();
|
||
render('settings_public_site.php', [
|
||
'title' => t('public_site.settings_title'),
|
||
'settings' => AppSettings::all(),
|
||
'saved' => isset($_GET['saved']),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
]);
|
||
}
|
||
public static function health(): void
|
||
{
|
||
self::admin();
|
||
render('settings_health.php', [
|
||
'title' => t('health.page_title'),
|
||
'report' => SystemHealthService::report(),
|
||
'installed' => isset($_GET['installed']),
|
||
]);
|
||
}
|
||
public static function notifications(): void
|
||
{
|
||
self::admin();
|
||
render('settings_notifications.php', [
|
||
'title' => t('notification.page_title'),
|
||
'settings' => AppSettings::all(),
|
||
'saved' => isset($_GET['saved']),
|
||
'sent' => (string) ($_GET['sent'] ?? ''),
|
||
]);
|
||
}
|
||
public static function saveNotifications(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$email = trim((string) ($_POST['notification_email_recipient'] ?? ''));
|
||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
header('Location: /settings/notifications?sent=invalid');
|
||
exit();
|
||
}
|
||
$frequency = in_array((string) ($_POST['notification_email_frequency'] ?? ''), ['daily', 'weekly'], true)
|
||
? (string) $_POST['notification_email_frequency']
|
||
: 'weekly';
|
||
AppSettings::save(
|
||
[
|
||
'notification_email_enabled' => isset($_POST['notification_email_enabled']) ? '1' : '0',
|
||
'notification_email_frequency' => $frequency,
|
||
'notification_email_recipient' => $email,
|
||
],
|
||
Auth::id(),
|
||
);
|
||
AuditService::log('notifications_saved', '/settings/notifications', 'Configuration des notifications modifiée');
|
||
header('Location: /settings/notifications?saved=1');
|
||
exit();
|
||
}
|
||
public static function sendNotifications(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$result = NotificationService::send(true);
|
||
AuditService::log(
|
||
'notification_digest',
|
||
'/settings/notifications/send',
|
||
'Envoi manuel du résumé : ' . $result['reason'],
|
||
);
|
||
header('Location: /settings/notifications?sent=' . rawurlencode((string) $result['reason']));
|
||
exit();
|
||
}
|
||
public static function savePublicSite(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$theme = (string) ($_POST['public_site_theme'] ?? 'warm');
|
||
if (!in_array($theme, ['warm', 'nature', 'minimal'], true)) {
|
||
$theme = 'warm';
|
||
}
|
||
$routing = (string) ($_POST['public_site_routing'] ?? 'integrated');
|
||
if (!in_array($routing, ['integrated', 'root'], true)) {
|
||
$routing = 'integrated';
|
||
}
|
||
$values = [
|
||
'public_site_enabled' => isset($_POST['public_site_enabled']) ? '1' : '0',
|
||
'public_site_theme' => $theme,
|
||
'public_site_routing' => $routing,
|
||
'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0',
|
||
];
|
||
foreach (
|
||
[
|
||
'public_site_home_title' => 150,
|
||
'public_site_home_text' => 5000,
|
||
'public_site_about_title' => 150,
|
||
'public_site_about_text' => 10000,
|
||
'public_site_donation_title' => 150,
|
||
'public_site_donation_text' => 10000,
|
||
'public_site_contact_text' => 5000,
|
||
'public_site_legal_text' => 15000,
|
||
'public_site_privacy_text' => 15000,
|
||
]
|
||
as $key => $max
|
||
) {
|
||
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max);
|
||
}
|
||
foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url'] as $key) {
|
||
$url = trim((string) ($_POST[$key] ?? ''));
|
||
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
|
||
if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) {
|
||
header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_url')));
|
||
exit();
|
||
}
|
||
$values[$key] = $url;
|
||
}
|
||
AppSettings::save($values, Auth::id());
|
||
AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée');
|
||
header('Location: /settings/public-site?saved=1');
|
||
exit();
|
||
}
|
||
public static function audit(): void
|
||
{
|
||
self::admin();
|
||
$rows = DB::pdo()
|
||
->query(
|
||
'SELECT al.*,COALESCE(u.display_name,u.username) actor FROM audit_log al LEFT JOIN users u ON u.id=al.user_id ORDER BY al.id DESC LIMIT 500',
|
||
)
|
||
->fetchAll(PDO::FETCH_ASSOC);
|
||
render('settings_audit.php', ['title' => t('audit.title'), 'rows' => $rows]);
|
||
}
|
||
public static function dataTools(): void
|
||
{
|
||
self::admin();
|
||
$db = DB::pdo();
|
||
render('settings_data.php', [
|
||
'title' => t('data.title'),
|
||
'counts' => [
|
||
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
|
||
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
|
||
],
|
||
'status' => (string) ($_GET['status'] ?? ''),
|
||
'backup' => (string) ($_GET['backup'] ?? ''),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
]);
|
||
}
|
||
public static function resetData(): void
|
||
{
|
||
self::dataOperation(false);
|
||
}
|
||
public static function loadDemoData(): void
|
||
{
|
||
self::dataOperation(true);
|
||
}
|
||
private static function dataOperation(bool $demo): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$expected = t($demo ? 'data.confirm_demo_phrase' : 'data.confirm_reset_phrase');
|
||
if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) {
|
||
header(
|
||
'Location: /settings/data?error=' . rawurlencode(t('data.confirm_required', ['phrase' => $expected])),
|
||
);
|
||
exit();
|
||
}
|
||
$db = DB::pdo();
|
||
try {
|
||
$backup = BackupService::create($demo ? 'pre-demo-data' : 'pre-data-reset');
|
||
$db->beginTransaction();
|
||
$removed = DemoDataService::reset($db);
|
||
$demo ? DemoDataService::seed($db, Auth::id()) : null;
|
||
$db->commit();
|
||
DemoDataService::clearFiles();
|
||
if ($demo) {
|
||
DemoDataService::installFiles($db);
|
||
}
|
||
AuditService::log(
|
||
$demo ? 'demo_data_loaded' : 'business_data_reset',
|
||
'/settings/data',
|
||
$demo ? 'Base fictive de démonstration chargée' : 'Données métier remises à zéro',
|
||
null,
|
||
null,
|
||
[
|
||
'removed_animals' => $removed['animals'],
|
||
'removed_contacts' => $removed['contacts'],
|
||
'backup' => $backup['name'],
|
||
],
|
||
);
|
||
header(
|
||
'Location: /settings/data?status=' .
|
||
($demo ? 'demo' : 'reset') .
|
||
'&backup=' .
|
||
rawurlencode($backup['name']),
|
||
);
|
||
} catch (Throwable $e) {
|
||
if ($db->inTransaction()) {
|
||
$db->rollBack();
|
||
}
|
||
header('Location: /settings/data?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function backups(): void
|
||
{
|
||
self::admin();
|
||
render('settings_backups.php', [
|
||
'title' => t('backups.title'),
|
||
'backups' => BackupService::list(),
|
||
'status' => (string) ($_GET['status'] ?? ''),
|
||
'error' => (string) ($_GET['error'] ?? ''),
|
||
]);
|
||
}
|
||
public static function createBackup(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
try {
|
||
$backup = BackupService::create();
|
||
AuditService::log('backup_created', '/settings/backups/create', 'Sauvegarde créée : ' . $backup['name']);
|
||
header('Location: /settings/backups?status=created');
|
||
} catch (Throwable $e) {
|
||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function saveBackupSchedule(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$frequency = (string) ($_POST['backup_schedule_frequency'] ?? 'daily');
|
||
if (!in_array($frequency, ['daily', 'weekly'], true)) {
|
||
$frequency = 'daily';
|
||
}
|
||
AppSettings::save(
|
||
[
|
||
'backup_schedule_enabled' => isset($_POST['backup_schedule_enabled']) ? '1' : '0',
|
||
'backup_schedule_frequency' => $frequency,
|
||
],
|
||
Auth::id(),
|
||
);
|
||
AuditService::log(
|
||
'backup_schedule_saved',
|
||
'/settings/backups/schedule',
|
||
'Planification des sauvegardes modifiée',
|
||
);
|
||
header('Location: /settings/backups?status=schedule');
|
||
exit();
|
||
}
|
||
public static function verifyBackup(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
try {
|
||
$result = BackupService::verify((string) ($_POST['file'] ?? ''));
|
||
AppSettings::save(['backup_last_verified_at' => date('Y-m-d H:i:s')], Auth::id());
|
||
AuditService::log(
|
||
'backup_verified',
|
||
'/settings/backups/verify',
|
||
'Sauvegarde vérifiée : ' . $result['name'],
|
||
);
|
||
header('Location: /settings/backups?status=verified');
|
||
} catch (RuntimeException $e) {
|
||
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
|
||
} catch (Throwable $e) {
|
||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function downloadBackup(): void
|
||
{
|
||
self::admin();
|
||
$path = BackupService::path((string) ($_GET['file'] ?? ''));
|
||
if (!$path) {
|
||
http_response_code(404);
|
||
echo h(t('backups.not_found'));
|
||
return;
|
||
}
|
||
PrivacyService::logAccess('backup', null, null, basename($path));
|
||
AuditService::log('private_file_viewed', '/settings/backups/download', 'Sauvegarde privée téléchargée');
|
||
header('Content-Type: application/zip');
|
||
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
|
||
header('Content-Length: ' . filesize($path));
|
||
header('Cache-Control: private,no-store');
|
||
header('X-Content-Type-Options: nosniff');
|
||
readfile($path);
|
||
}
|
||
public static function deleteBackup(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$name = (string) ($_POST['file'] ?? '');
|
||
try {
|
||
BackupService::delete($name);
|
||
AuditService::log('backup_deleted', '/settings/backups/delete', 'Sauvegarde supprimée : ' . $name);
|
||
header('Location: /settings/backups?status=deleted');
|
||
} catch (RuntimeException $e) {
|
||
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
|
||
} catch (Throwable $e) {
|
||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
public static function restoreBackup(): void
|
||
{
|
||
self::admin();
|
||
self::postOnly();
|
||
$upload = $_FILES['backup'] ?? null;
|
||
if (
|
||
!is_array($upload) ||
|
||
($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK ||
|
||
!is_uploaded_file((string) $upload['tmp_name'])
|
||
) {
|
||
header('Location: /settings/backups?error=' . rawurlencode(t('backups.invalid_file')));
|
||
exit();
|
||
}
|
||
if ((int) ($upload['size'] ?? 0) > 1_000_000_000) {
|
||
header('Location: /settings/backups?error=' . rawurlencode(t('backups.too_large')));
|
||
exit();
|
||
}
|
||
try {
|
||
BackupService::restore((string) $upload['tmp_name']);
|
||
Auth::logout();
|
||
header('Location: /login?restored=1');
|
||
} catch (RuntimeException $e) {
|
||
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
|
||
} catch (Throwable $e) {
|
||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||
}
|
||
exit();
|
||
}
|
||
private static function postOnly(): void
|
||
{
|
||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||
http_response_code(405);
|
||
header('Allow: POST');
|
||
exit();
|
||
}
|
||
}
|
||
private static function nullableNumber(mixed $value): ?float
|
||
{
|
||
$value = str_replace(',', '.', trim((string) $value));
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
if (!is_numeric($value)) {
|
||
throw new RuntimeException(t('lab_reference.invalid'));
|
||
}
|
||
return (float) $value;
|
||
}
|
||
private static function admin(): void
|
||
{
|
||
if (!Auth::is('admin')) {
|
||
http_response_code(403);
|
||
echo h(t('settings.admin_only'));
|
||
exit();
|
||
}
|
||
}
|
||
}
|