Compare commits

..
9 Commits
9 changed files with 178 additions and 62 deletions

No files matched your search

+38 -14
View File
@@ -1802,30 +1802,53 @@ try {
case 'save_company_branding':
$company_name = trim((string)($payload['company_name'] ?? ''));
$company_logo = trim((string)($payload['company_logo'] ?? ''));
$ucp_logo = trim((string)($payload['ucp_logo'] ?? ''));
if ($company_logo !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $company_logo)) {
foreach (['company_logo' => $company_logo, 'ucp_logo' => $ucp_logo] as $fieldKey => $logoVal) {
if (!array_key_exists($fieldKey, $payload)) {
continue;
}
if ($logoVal !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $logoVal)) {
throw new InvalidArgumentException("Format d'image invalide (utilisez PNG, SVG, JPG ou WebP).");
}
if (strlen($company_logo) > 2 * 1024 * 1024) {
if (strlen($logoVal) > 2 * 1024 * 1024) {
throw new InvalidArgumentException("Le logo est trop volumineux (max 1.5 Mo).");
}
}
if (array_key_exists('company_name', $payload) && array_key_exists('company_logo', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cn' => $company_name, ':cl' => $company_logo]);
} elseif (array_key_exists('company_logo', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cl' => $company_logo]);
} elseif (array_key_exists('company_name', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cn' => $company_name]);
$sets = [];
$params = [];
if (array_key_exists('company_name', $payload)) {
$sets[] = 'company_name = :cn';
$params[':cn'] = $company_name;
}
if (array_key_exists('company_logo', $payload)) {
$sets[] = 'company_logo = :cl';
$params[':cl'] = $company_logo;
}
if (array_key_exists('ucp_logo', $payload)) {
$sets[] = 'ucp_logo = :ul';
$params[':ul'] = $ucp_logo;
}
if ($sets) {
$sets[] = 'updated_at = CURRENT_TIMESTAMP';
$stmt = $db->prepare('UPDATE settings SET ' . implode(', ', $sets) . ' WHERE id = 1');
$stmt->execute($params);
}
$msg = 'Logo et identité entreprise mis à jour sur NexusPBX !';
if (array_key_exists('ucp_logo', $payload) && !array_key_exists('company_logo', $payload) && !array_key_exists('company_name', $payload)) {
$msg = $ucp_logo === ''
? 'Logo UCP réinitialisé (hérite du logo principal).'
: 'Logo spécifique UCP mis à jour !';
} elseif (array_key_exists('company_logo', $payload) && $company_logo === '' && !array_key_exists('company_name', $payload) && !array_key_exists('ucp_logo', $payload)) {
$msg = 'Logo entreprise réinitialisé.';
}
echo json_encode([
'success' => true,
'message' => $company_logo === '' && !array_key_exists('company_name', $payload)
? 'Logo entreprise réinitialisé.'
: 'Logo et identité entreprise mis à jour sur NexusPBX !',
'message' => $msg,
]);
break;
@@ -2598,6 +2621,7 @@ try {
break;
case 'run_update':
@set_time_limit(300);
$output = [];
$code = 0;
exec("sudo /var/www/nexus-pbx/scripts/update.sh 2>&1", $output, $code);
+78 -24
View File
@@ -243,6 +243,7 @@ function renderAll() {
function renderCompanyBranding() {
const s = appState.settings || {};
const logo = (s.company_logo || '').trim();
const ucpLogo = (s.ucp_logo || '').trim();
const name = (s.company_name || '').trim();
const emptyBox = document.getElementById('sidebar-company-empty');
@@ -270,6 +271,21 @@ function renderCompanyBranding() {
}
}
const ucpPreviewImg = document.getElementById('settings-ucp-preview-img');
const ucpPreviewEmpty = document.getElementById('settings-ucp-preview-empty');
if (ucpPreviewImg && ucpPreviewEmpty) {
if (ucpLogo) {
ucpPreviewImg.src = ucpLogo;
ucpPreviewImg.classList.remove('hidden');
ucpPreviewEmpty.classList.add('hidden');
} else {
ucpPreviewImg.src = '';
ucpPreviewImg.classList.add('hidden');
ucpPreviewEmpty.textContent = logo ? 'Hérite du logo principal' : 'NexusPBX par défaut';
ucpPreviewEmpty.classList.remove('hidden');
}
}
if (logo || name) {
emptyBox?.classList.add('hidden');
filledBox?.classList.remove('hidden');
@@ -321,7 +337,11 @@ function triggerCompanyLogoPicker() {
document.getElementById('company-logo-file-input')?.click();
}
function handleCompanyLogoUpload(input) {
function triggerUcpLogoPicker() {
document.getElementById('ucp-logo-file-input')?.click();
}
function uploadBrandingLogo(input, fieldName) {
const file = input?.files?.[0];
if (!file) return;
@@ -335,7 +355,7 @@ function handleCompanyLogoUpload(input) {
reader.onload = async (ev) => {
const dataUrl = String(ev.target?.result || '');
try {
const res = await apiCall('save_company_branding', { company_logo: dataUrl });
const res = await apiCall('save_company_branding', { [fieldName]: dataUrl });
showToast(res.message, 'success');
await loadState();
} catch (err) {
@@ -347,9 +367,17 @@ function handleCompanyLogoUpload(input) {
reader.readAsDataURL(file);
}
async function clearCompanyLogo() {
function handleCompanyLogoUpload(input) {
uploadBrandingLogo(input, 'company_logo');
}
function handleUcpLogoUpload(input) {
uploadBrandingLogo(input, 'ucp_logo');
}
async function clearBrandingLogo(fieldName) {
try {
const res = await apiCall('save_company_branding', { company_logo: '' });
const res = await apiCall('save_company_branding', { [fieldName]: '' });
showToast(res.message, 'info');
await loadState();
} catch (err) {
@@ -357,6 +385,14 @@ async function clearCompanyLogo() {
}
}
function clearCompanyLogo() {
clearBrandingLogo('company_logo');
}
function clearUcpLogo() {
clearBrandingLogo('ucp_logo');
}
function renderSummaryCards() {
const exts = appState.extensions || [];
const grps = appState.groups || [];
@@ -6294,7 +6330,12 @@ async function checkSystemUpdate(notify = false) {
if (pingDot) pingDot.classList.remove('hidden');
if (changelogList && Array.isArray(data.changelog) && data.changelog.length > 0) {
changelogList.innerHTML = data.changelog.map(item => `<li>${escapeHtml(item)}</li>`).join('');
changelogList.innerHTML = data.changelog.flatMap(item => {
if (item && typeof item === 'object' && Array.isArray(item.changes)) {
return item.changes.map(c => `<li>${escapeHtml(String(c))}</li>`);
}
return [`<li>${escapeHtml(String(item ?? ''))}</li>`];
}).join('');
if (changelogContainer) changelogContainer.classList.remove('hidden');
}
} else {
@@ -6376,11 +6417,29 @@ async function executeSystemUpdate() {
showToast("Mise à jour en cours d'exécution en arrière-plan...", 'info');
// Pause du polling métriques pour éviter un 502 Nginx pendant le reload php-fpm/nginx
stopMetricsPolling();
// Purge préventive du cache local
localStorage.removeItem('nexus_update_last_check_ts');
localStorage.removeItem('nexus_update_cached_data');
function finalizeUpdateSuccess(newVer) {
async function probeUpdateReady(maxAttempts = 6, delayMs = 1500) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise(r => setTimeout(r, delayMs));
try {
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
if (!probeRes.ok) continue;
const probeData = await probeRes.json();
if (probeData && probeData.success && probeData.current_version) {
return probeData;
}
} catch (_) {}
}
return null;
}
async function finalizeUpdateSuccess(newVer) {
newVer = String(newVer || '').replace(/^v/, '');
localStorage.setItem('nexus_update_last_check_ts', Date.now().toString());
localStorage.setItem('nexus_update_cached_data', JSON.stringify({
@@ -6413,10 +6472,11 @@ async function executeSystemUpdate() {
btnModalConfirm.innerHTML = '✓ Mise à jour terminée ! Rechargement...';
}
setTimeout(() => {
// Attendre que le reload différé php-fpm/nginx soit terminé avant de recharger la page
await new Promise(r => setTimeout(r, 2500));
await probeUpdateReady(5, 1000);
closeUpdateConfirmModal();
window.location.href = window.location.origin + window.location.pathname + '?_t=' + Date.now();
}, 1800);
}
try {
@@ -6440,18 +6500,15 @@ async function executeSystemUpdate() {
}
// Si la réponse n'était pas un JSON valide ou a été interrompue par le rechargement de php-fpm/nginx,
// on sonde le serveur pour vérifier si la mise à jour a en réalité bien été appliquée sur le disque
await new Promise(r => setTimeout(r, 2000));
try {
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
const probeData = await probeRes.json();
if (probeData && probeData.success && probeData.current_version) {
finalizeUpdateSuccess(probeData.current_version);
// on sonde le serveur à plusieurs reprises pour laisser Nginx/PHP-FPM terminer leur reload
const probeData = await probeUpdateReady(6, 1500);
if (probeData) {
await finalizeUpdateSuccess(probeData.current_version);
return;
}
} catch (_) {}
// En cas d'échec réel avéré
startMetricsPolling();
closeUpdateConfirmModal();
const errMsg = (data && (data.output || data.message)) ? (data.output || data.message) : (rawText.slice(0, 150) || "Erreur inconnue");
alert("Erreur lors de la mise à jour :\n\n" + errMsg);
@@ -6467,17 +6524,14 @@ async function executeSystemUpdate() {
} catch (e) {
// En cas d'interruption réseau du socket (Safari WebKit DOMException ou timeout),
// on vérifie avant d'alerter si le serveur a quand même réussi à déployer les fichiers
await new Promise(r => setTimeout(r, 2000));
try {
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
const probeData = await probeRes.json();
if (probeData && probeData.success && probeData.current_version) {
finalizeUpdateSuccess(probeData.current_version);
// on vérifie à plusieurs reprises si le serveur a quand même réussi à déployer les fichiers
const probeData = await probeUpdateReady(6, 1500);
if (probeData) {
await finalizeUpdateSuccess(probeData.current_version);
return;
}
} catch (_) {}
startMetricsPolling();
closeUpdateConfirmModal();
alert("Erreur réseau lors de la mise à jour : " + (e.message || "Connexion interrompue"));
if (btnRun) {
+23
View File
@@ -1648,6 +1648,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<form id="settings-form" class="space-y-4">
<!-- Identité & Logo de l'Exploitant -->
<input type="file" id="company-logo-file-input" accept="image/png,image/svg+xml,image/jpeg,image/webp,image/gif" class="hidden" onchange="handleCompanyLogoUpload(this)">
<input type="file" id="ucp-logo-file-input" accept="image/png,image/svg+xml,image/jpeg,image/webp,image/gif" class="hidden" onchange="handleUcpLogoUpload(this)">
<div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3.5">
<div class="flex items-center justify-between pb-2 border-b border-anthracite-700/60">
<div>
@@ -1675,6 +1676,28 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-sm text-white">
</div>
</div>
<!-- Logo spécifique UCP (optionnel, fond clair) -->
<div class="pt-3 border-t border-anthracite-800 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div id="settings-ucp-preview-slot" class="w-32 h-12 rounded-lg bg-white/95 border border-dashed border-slate-300 flex items-center justify-center p-1.5 shrink-0">
<img id="settings-ucp-preview-img" src="" alt="Aperçu Logo UCP" class="max-h-9 max-w-full object-contain hidden">
<span id="settings-ucp-preview-empty" class="text-[10px] text-slate-500 italic text-center leading-tight">Hérite du logo principal</span>
</div>
<div class="min-w-0">
<div class="text-xs font-semibold text-slate-200">Logo UCP (Portail Utilisateur — optionnel)</div>
<p class="text-[11px] text-slate-400">Si vide, l'UCP réutilise le logo principal (ou NexusPBX). Utile pour importer un logo foncé adapté au thème clair UCP.</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button type="button" onclick="triggerUcpLogoPicker()" class="px-3 py-1.5 rounded-lg bg-slate-700 hover:bg-slate-600 text-xs text-white font-semibold transition">
Importer Logo UCP
</button>
<button type="button" onclick="clearUcpLogo()" class="px-2.5 py-1.5 rounded-lg bg-anthracite-800 hover:bg-red-500/20 border border-anthracite-700 text-xs text-slate-300 hover:text-red-300 transition">
Effacer
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+11 -4
View File
@@ -261,7 +261,7 @@ if [ -t 1 ]; then
systemctl reload nginx 2>/dev/null || true
fail2ban-client reload >/dev/null 2>&1 || true
else
(nohup bash -c "sleep 2 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true; fail2ban-client reload >/dev/null 2>&1 || true" >/dev/null 2>&1 &)
(nohup bash -c "sleep 1 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true; fail2ban-client reload >/dev/null 2>&1 || true" </dev/null >/dev/null 2>&1 &)
fi
NEW_VER="Inconnue"
@@ -279,11 +279,18 @@ echo ""
if [ -f "$APP_DIR/version.json" ]; then
echo -e "${C_CYAN}${C_BOLD}Nouveautés de cette version :${C_RESET}"
php -r '
$v = json_decode(file_get_contents("/var/www/nexus-pbx/version.json"), true);
if (!empty($v["changelog"])) {
foreach ($v["changelog"] as $line) {
$v = json_decode(@file_get_contents("/var/www/nexus-pbx/version.json"), true);
if (!empty($v["changelog"]) && is_array($v["changelog"])) {
$first = $v["changelog"][0];
if (is_array($first) && !empty($first["changes"])) {
foreach ($first["changes"] as $line) {
echo " • " . $line . "\n";
}
} else {
foreach ($v["changelog"] as $line) {
if (is_string($line)) echo " • " . $line . "\n";
}
}
}
'
fi
+4 -1
View File
@@ -48,7 +48,7 @@ class Database
// Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP
// ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises.
try {
self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret FROM settings LIMIT 1");
self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret, ucp_logo FROM settings LIMIT 1");
self::$instance->query("SELECT groundwire_enabled FROM extensions LIMIT 1");
self::$instance->query("SELECT id FROM ucp_users LIMIT 1");
} catch (\Throwable $e) {
@@ -395,6 +395,9 @@ class Database
if (!in_array('company_logo', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN company_logo TEXT NOT NULL DEFAULT ''");
}
if (!in_array('ucp_logo', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN ucp_logo TEXT NOT NULL DEFAULT ''");
}
if (!in_array('default_moh_sound_id', $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN default_moh_sound_id INTEGER DEFAULT 2");
}
+3 -1
View File
@@ -95,12 +95,14 @@ class PhonebookManager
$existing = $findStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$existingEmail = trim((string)($existing['email'] ?? ''));
$finalEmail = $existingEmail !== '' ? $existingEmail : $email;
$updateStmt->execute([
':id' => (int)$existing['id'],
':ext_id' => $extId,
':name' => $extName,
':num' => $extNum,
':email' => $email,
':email' => $finalEmail,
':grp' => $group,
]);
} else {
+6 -2
View File
@@ -98,7 +98,9 @@ try {
switch ($action) {
case 'get_state':
$branding = $db->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(PDO::FETCH_ASSOC) ?: [];
$branding = $db->query('SELECT company_name, company_logo, ucp_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(PDO::FETCH_ASSOC) ?: [];
$compLogo = trim((string)($branding['company_logo'] ?? ''));
$ucpLogo = trim((string)($branding['ucp_logo'] ?? ''));
echo json_encode([
'success' => true,
'data' => [
@@ -111,7 +113,9 @@ try {
],
'branding' => [
'company_name' => (string)($branding['company_name'] ?? ''),
'company_logo' => (string)($branding['company_logo'] ?? ''),
'company_logo' => $compLogo,
'ucp_logo' => $ucpLogo,
'effective_logo' => $ucpLogo !== '' ? $ucpLogo : $compLogo,
],
'csrf_token' => UcpAuth::getCsrfToken(),
'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId),
+9 -7
View File
@@ -13,9 +13,11 @@ $ucpEnabled = UcpAuth::isUcpEnabled($db);
$currentUser = $ucpEnabled ? UcpAuth::getCurrentUser($db) : null;
$isAuth = ($currentUser !== null);
$branding = $db->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(\PDO::FETCH_ASSOC) ?: [];
$branding = $db->query('SELECT company_name, company_logo, ucp_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(\PDO::FETCH_ASSOC) ?: [];
$companyName = trim((string)($branding['company_name'] ?? ''));
$companyLogo = trim((string)($branding['company_logo'] ?? ''));
$ucpLogo = trim((string)($branding['ucp_logo'] ?? ''));
$ucpEffectiveLogo = $ucpLogo !== '' ? $ucpLogo : $companyLogo;
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
$appVersion = $appVerData['version'] ?? '2.5.0';
$csrfToken = UcpAuth::getCsrfToken();
@@ -136,8 +138,8 @@ $csrfToken = UcpAuth::getCsrfToken();
<div class="absolute inset-0 bg-white/10"></div>
<div class="relative z-10 w-full max-w-md bg-white/85 backdrop-blur-xl rounded-3xl p-8 shadow-xl shadow-indigo-900/10 border border-white/80">
<div class="flex flex-col items-center text-center mb-7">
<?php if ($companyLogo !== ''): ?>
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="h-14 w-auto object-contain mb-3 rounded-xl">
<?php if ($ucpEffectiveLogo !== ''): ?>
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="h-14 w-auto object-contain mb-3 rounded-xl">
<?php else: ?>
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-14 w-auto object-contain mb-3">
<?php endif; ?>
@@ -180,8 +182,8 @@ $csrfToken = UcpAuth::getCsrfToken();
<header class="sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200">
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<?php if ($companyLogo !== ''): ?>
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="h-9 w-auto rounded-lg object-contain shrink-0">
<?php if ($ucpEffectiveLogo !== ''): ?>
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="h-9 w-auto rounded-lg object-contain shrink-0">
<?php else: ?>
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-9 w-auto object-contain shrink-0">
<?php endif; ?>
@@ -430,8 +432,8 @@ $csrfToken = UcpAuth::getCsrfToken();
<div class="ucp-print-avoid-break ucp-print-row rounded-2xl bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 text-white p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-4 min-w-0">
<div class="h-14 w-14 sm:h-16 sm:w-16 rounded-2xl bg-white p-2 flex items-center justify-center shrink-0 shadow-md">
<?php if ($companyLogo !== ''): ?>
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="max-h-full max-w-full object-contain">
<?php if ($ucpEffectiveLogo !== ''): ?>
<img src="<?= htmlspecialchars($ucpEffectiveLogo) ?>" alt="Logo" class="max-h-full max-w-full object-contain">
<?php else: ?>
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="max-h-full max-w-full object-contain">
<?php endif; ?>
+6 -9
View File
@@ -1,17 +1,14 @@
{
"version": "2.6.7",
"version": "2.7.3",
"codename": "Pulsar UCP",
"release_date": "2026-11-12",
"min_php_version": "8.2",
"changelog": [
{
"version": "2.6.7",
"date": "2026-11-12",
"changes": [
"UI Admin & UCP : Remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes (style Heroicons), sans modifier le menu latéral Admin",
"Correction d'un problème de syntaxe JS sur le badge d'avertissement TLS dans app.js"
]
},
"Correctif v2.7.3 : conservation de l'adresse email renseignée sur un contact interne de l'Annuaire lors de la synchronisation des postes",
"Correctif v2.7.2 : formatage uniforme du changelog dans version.json (suppression de [object Object] dans la fiche de mise à jour)",
"Correctif v2.7.1 : suppression de l'erreur Nginx 502 transitoire après mise à jour 1-Clic (pause du polling métriques, sonde multi-essais avant rechargement)",
"Nouveauté v2.7.0 : logo spécifique optionnel pour le portail UCP (thème clair), avec repli automatique sur le logo principal puis NexusPBX",
"UI v2.6.7 : remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes sur Admin & UCP (sans modifier le menu latéral Admin)",
"UI v2.6.6 : menu déroulant personnalisé sombre avec icônes SVG Heroicons pour toutes les destinations d'appel (sans emojis natifs)",
"UI v2.6.5 : refonte des intitulés de destinations audio (Musique d'attente & Annonce vocale en boucle continue), nouveau wallpaper clair UCP et placeholder générique",
"Correctif v2.6.4 : prise en charge multi-groupes dans l'annuaire UCP & Admin (un poste appartenant à plusieurs groupes apparaît bien dans chacun des onglets correspondants)",