Files
NexusPBX/public/assets/app.js
T

5612 lines
266 KiB
JavaScript

'use strict';
let appState = {
settings: {},
extensions: [],
groups: [],
paging_groups: [],
extension_groups: [],
trunks: [],
ivrs: [],
sounds: [],
sound_sites: [],
feature_codes: [],
certificates: [],
active_certificate: null,
diagnostics: { healthy: false, checks: [] },
metrics: null,
previews: { pjsip: '', extensions: '', moh: '' }
};
const MAX_HISTORY_POINTS = 24;
const chartHistory = {
cpu: Array(MAX_HISTORY_POINTS).fill(2),
ram: Array(MAX_HISTORY_POINTS).fill(15),
rx: Array(MAX_HISTORY_POINTS).fill(0.5),
tx: Array(MAX_HISTORY_POINTS).fill(0.2)
};
let metricsTimer = null;
// --- Notifications Toast Tailwind ---
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
if (!container) return;
const colors = {
success: 'bg-emerald-600/95 border-emerald-400/40 text-white',
error: 'bg-red-600/95 border-red-400/40 text-white',
info: 'bg-indigo-600/95 border-indigo-400/40 text-white'
};
const toast = document.createElement('div');
toast.className = `pointer-events-auto flex items-start justify-between gap-3 px-4 py-3 rounded-xl border shadow-2xl backdrop-blur text-xs sm:text-sm font-medium transition-all duration-300 transform translate-y-2 opacity-0 ${colors[type] || colors.info}`;
toast.innerHTML = `
<div class="leading-snug">${escapeHtml(message)}</div>
<button type="button" class="text-white/70 hover:text-white font-bold">✕</button>
`;
toast.querySelector('button').addEventListener('click', () => toast.remove());
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.remove('translate-y-2', 'opacity-0');
});
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-x-4');
setTimeout(() => toast.remove(), 300);
}, 4200);
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// --- Bascule propre des écrans Login / Dashboard ---
function showLoginScreen() {
stopMetricsPolling();
const appScreen = document.getElementById('app-screen');
const loginScreen = document.getElementById('login-screen');
if (appScreen) {
appScreen.classList.add('hidden');
appScreen.classList.remove('flex');
}
if (loginScreen) {
loginScreen.classList.remove('hidden');
loginScreen.classList.add('flex');
}
window.scrollTo(0, 0);
}
function showAppScreen() {
const loginScreen = document.getElementById('login-screen');
const appScreen = document.getElementById('app-screen');
if (loginScreen) {
loginScreen.classList.add('hidden');
loginScreen.classList.remove('flex');
}
if (appScreen) {
appScreen.classList.remove('hidden');
appScreen.classList.add('flex');
}
window.scrollTo(0, 0);
}
// --- Client AJAX Fetch API ---
async function apiCall(action, payload = null) {
const options = {
method: payload ? 'POST' : 'GET',
headers: { 'Accept': 'application/json' }
};
if (payload) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(payload);
}
const res = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options);
const cType = res.headers.get('content-type') || '';
let data;
if (cType.includes('application/json')) {
data = await res.json();
} else {
const rawText = await res.text();
if (res.status === 413) {
throw new Error("Le contenu envoyé est trop volumineux pour le serveur (Limite 64 Mo).");
}
throw new Error(`Erreur serveur (${res.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`);
}
if (res.status === 401 && data.unauthenticated) {
showLoginScreen();
throw new Error(data.message || 'Session expirée');
}
if (!res.ok || !data.success) {
throw new Error(data.message || 'Erreur lors de la requête AJAX');
}
return data;
}
// --- Chargement global de l'état ---
async function loadState() {
try {
const data = await apiCall('get_state');
appState = data;
if (data.metrics) {
pushMetricsToHistory(data.metrics);
}
renderAll();
loadBackups();
startMetricsPolling();
} catch (err) {
if (err.message !== 'Session expirée') {
showToast(err.message, 'error');
}
}
}
async function pollMetrics() {
try {
const data = await apiCall('get_metrics');
if (data.metrics) {
appState.metrics = data.metrics;
pushMetricsToHistory(data.metrics);
renderDashboardMetrics();
renderExtensionsTable();
renderTrunksTable();
}
} catch (_) {
// Silencieux en arrière-plan
}
}
function startMetricsPolling() {
if (!metricsTimer) {
metricsTimer = setInterval(pollMetrics, 3000);
}
}
function stopMetricsPolling() {
if (metricsTimer) {
clearInterval(metricsTimer);
metricsTimer = null;
}
}
function pushMetricsToHistory(m) {
chartHistory.cpu.push(Number(m.cpu?.percent || 0));
chartHistory.cpu.shift();
chartHistory.ram.push(Number(m.memory?.percent || 0));
chartHistory.ram.shift();
chartHistory.rx.push(Number(m.network?.rx_kbps || 0));
chartHistory.rx.shift();
chartHistory.tx.push(Number(m.network?.tx_kbps || 0));
chartHistory.tx.shift();
}
function renderAll() {
renderCompanyBranding();
renderSummaryCards();
renderDashboardMetrics();
renderExtensionsTable();
renderGroupsTable();
renderExtensionGroupsTable();
renderPagingTable();
renderPhonebookTable();
renderTrunksTable();
renderOutboundRoutesTable();
renderIvrsTable();
renderSoundsTable();
renderFeatureCodes();
renderSipSettings();
renderCertificates();
renderSettingsAndDiagnostics();
}
function renderCompanyBranding() {
const s = appState.settings || {};
const logo = (s.company_logo || '').trim();
const name = (s.company_name || '').trim();
const emptyBox = document.getElementById('sidebar-company-empty');
const filledBox = document.getElementById('sidebar-company-filled');
const logoImg = document.getElementById('sidebar-company-logo');
const nameEl = document.getElementById('sidebar-company-name');
const slot = document.getElementById('sidebar-company-slot');
const loginBox = document.getElementById('login-company-box');
const loginLogo = document.getElementById('login-company-logo');
const loginName = document.getElementById('login-company-name');
// Aperçu dans l'administration (Onglet Système & Paramètres)
const settingsPreviewImg = document.getElementById('settings-company-preview-img');
const settingsPreviewEmpty = document.getElementById('settings-company-preview-empty');
if (settingsPreviewImg && settingsPreviewEmpty) {
if (logo) {
settingsPreviewImg.src = logo;
settingsPreviewImg.classList.remove('hidden');
settingsPreviewEmpty.classList.add('hidden');
} else {
settingsPreviewImg.src = '';
settingsPreviewImg.classList.add('hidden');
settingsPreviewEmpty.classList.remove('hidden');
}
}
if (logo || name) {
emptyBox?.classList.add('hidden');
filledBox?.classList.remove('hidden');
if (logo && logoImg) {
logoImg.src = logo;
logoImg.classList.remove('hidden');
} else if (logoImg) {
logoImg.src = '';
logoImg.classList.add('hidden');
}
if (name && nameEl) {
nameEl.textContent = name;
nameEl.classList.remove('hidden');
} else if (nameEl) {
nameEl.textContent = '';
nameEl.classList.add('hidden');
}
if (loginBox) {
loginBox.classList.remove('hidden');
if (logo && loginLogo) {
loginLogo.src = logo;
loginLogo.classList.remove('hidden');
} else if (loginLogo) {
loginLogo.classList.add('hidden');
}
if (loginName) loginName.textContent = name;
}
} else {
emptyBox?.classList.remove('hidden');
filledBox?.classList.add('hidden');
if (logoImg) { logoImg.src = ''; logoImg.classList.add('hidden'); }
if (nameEl) { nameEl.textContent = ''; nameEl.classList.add('hidden'); }
loginBox?.classList.add('hidden');
}
}
function triggerCompanyLogoPicker() {
document.getElementById('company-logo-file-input')?.click();
}
function handleCompanyLogoUpload(input) {
const file = input?.files?.[0];
if (!file) return;
if (file.size > 1500000) {
showToast('Le fichier logo ne doit pas dépasser 1.5 Mo.', 'error');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = async (ev) => {
const dataUrl = String(ev.target?.result || '');
try {
const res = await apiCall('save_company_branding', { company_logo: dataUrl });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
input.value = '';
}
};
reader.readAsDataURL(file);
}
async function clearCompanyLogo() {
try {
const res = await apiCall('save_company_branding', { company_logo: '' });
showToast(res.message, 'info');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
function renderSummaryCards() {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const trks = appState.trunks || [];
const ivrs = appState.ivrs || [];
const obRoutes = appState.outbound_routes || [];
const tlsCount = exts.filter(e => e.transport === 'tls').length;
const udpCount = exts.length - tlsCount;
document.getElementById('stat-extensions').textContent = exts.length;
document.getElementById('stat-extensions-sub').textContent = `${udpCount} Interne UDP • ${tlsCount} TLS/SRTP`;
document.getElementById('stat-groups').textContent = grps.length;
document.getElementById('stat-trunks').textContent = trks.length;
document.getElementById('stat-ivrs').textContent = ivrs.length;
// Badges menu gauche
document.getElementById('nav-badge-extensions').textContent = exts.length;
document.getElementById('nav-badge-groups').textContent = grps.length;
const egBadge = document.getElementById('nav-badge-extgroups');
if (egBadge) egBadge.textContent = (appState.extension_groups || []).length;
const pagingBadge = document.getElementById('nav-badge-paging');
if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length;
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length;
document.getElementById('nav-badge-trunks').textContent = trks.length;
const obBadge = document.getElementById('nav-badge-outbound');
if (obBadge) obBadge.textContent = obRoutes.length;
document.getElementById('nav-badge-ivrs').textContent = ivrs.length;
const soundsBadge = document.getElementById('nav-badge-sounds');
if (soundsBadge) soundsBadge.textContent = (appState.sounds || []).length;
const backupsBadge = document.getElementById('nav-badge-backups');
if (backupsBadge && typeof appState.backups_count !== 'undefined') {
backupsBadge.textContent = appState.backups_count;
}
const diag = appState.diagnostics || { healthy: false, checks: [] };
const healthEl = document.getElementById('stat-health');
const amiDot = document.getElementById('ami-dot');
const sideBadge = document.getElementById('sidebar-health-badge');
if (diag.healthy) {
healthEl.textContent = 'AMI & PJSIP OK';
healthEl.className = 'text-[11px] font-bold text-emerald-400';
amiDot.className = 'w-2 h-2 rounded-full bg-emerald-400';
sideBadge.textContent = 'EN LIGNE';
sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
} else {
healthEl.textContent = 'Vérifier Config';
healthEl.className = 'text-[11px] font-bold text-amber-400';
amiDot.className = 'w-2 h-2 rounded-full bg-amber-400';
sideBadge.textContent = 'ATTENTION';
sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
}
const lastApplied = appState.settings?.last_applied_at;
document.getElementById('last-applied-label').textContent = lastApplied
? `Dernière application Asterisk : ${lastApplied} UTC`
: `Cliquez sur « Appliquer les configurations » pour synchroniser Asterisk`;
}
// --- Rendu Dashboard Métriques & Graphes SVG ---
function renderDashboardMetrics() {
const m = appState.metrics;
if (!m) return;
// Sidebar infos
const osVerEl = document.getElementById('sidebar-os-version');
if (osVerEl && m.os_version) osVerEl.textContent = m.os_version;
const astVerEl = document.getElementById('sidebar-ast-version');
if (astVerEl && m.asterisk?.version) astVerEl.textContent = m.asterisk.version;
document.getElementById('sidebar-ip').textContent = m.network?.ip || '10.10.175.204';
document.getElementById('sidebar-uptime').textContent = m.uptime || '—';
document.getElementById('sidebar-online-phones').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`;
// 1. CPU
const cpuPct = Number(m.cpu?.percent || 0);
document.getElementById('metric-cpu-pct').textContent = `${cpuPct.toFixed(1)}%`;
document.getElementById('metric-cpu-cores').textContent = `${m.cpu?.cores || 1} cœur(s)`;
document.getElementById('metric-cpu-model').textContent = m.cpu?.model || 'Processeur Debian';
document.getElementById('metric-cpu-load').textContent = `${m.cpu?.load_1 ?? 0} / ${m.cpu?.load_5 ?? 0} / ${m.cpu?.load_15 ?? 0}`;
document.getElementById('bar-cpu').style.width = `${Math.max(3, Math.min(100, cpuPct))}%`;
// 2. RAM
const ramPct = Number(m.memory?.percent || 0);
document.getElementById('metric-ram-pct').textContent = `${ramPct.toFixed(1)}%`;
document.getElementById('metric-ram-used').textContent = `${m.memory?.used_mb || 0} / ${m.memory?.total_mb || 0} Mo`;
document.getElementById('metric-ram-free').textContent = `Disponible : ${m.memory?.free_mb || 0} Mo`;
document.getElementById('metric-swap').textContent = `${m.memory?.swap_used_mb || 0} / ${m.memory?.swap_total_mb || 0} Mo`;
document.getElementById('bar-ram').style.width = `${Math.max(3, Math.min(100, ramPct))}%`;
// 3. Réseau
document.getElementById('metric-net-rx').textContent = `${Number(m.network?.rx_kbps || 0).toFixed(1)} Ko/s`;
document.getElementById('metric-net-tx').textContent = `${Number(m.network?.tx_kbps || 0).toFixed(1)} Ko/s`;
document.getElementById('metric-net-iface').textContent = `${m.network?.interface || 'eth0'} (${m.network?.ip || ''})`;
document.getElementById('metric-net-totals').textContent = `Total : ↓ ${m.network?.rx_total_mb || 0} Mo • ↑ ${m.network?.tx_total_mb || 0} Mo`;
// 4. Disque HDD
const diskPct = Number(m.disk?.percent || 0);
document.getElementById('metric-disk-pct').textContent = `${diskPct.toFixed(1)}%`;
document.getElementById('metric-disk-used').textContent = `${m.disk?.used_gb || 0} / ${m.disk?.total_gb || 0} Go`;
document.getElementById('metric-disk-free').textContent = `Espace libre sur / : ${m.disk?.free_gb || 0} Go`;
document.getElementById('bar-disk').style.width = `${Math.max(3, Math.min(100, diskPct))}%`;
// 5. Jauges Anneaux (Donuts)
updateDonut('donut-cpu', 'donut-cpu-label', cpuPct);
updateDonut('donut-ram', 'donut-ram-label', ramPct);
updateDonut('donut-disk', 'donut-disk-label', diskPct);
document.getElementById('dash-active-calls').textContent = m.asterisk?.active_calls ?? 0;
document.getElementById('dash-online-contacts').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`;
// 6. Graphes SVG Temps Réel
drawDualAreaChart('chart-cpu-ram', chartHistory.cpu, chartHistory.ram, 100, '#818cf8', '#34d399', '%');
const maxNet = Math.max(10, ...chartHistory.rx, ...chartHistory.tx) * 1.25;
drawDualAreaChart('chart-network', chartHistory.rx, chartHistory.tx, maxNet, '#22d3ee', '#c084fc', 'Ko/s');
// 7. Table des téléphones SIP en direct sur le Dashboard
renderDashboardPhonesTable();
// 8. Sécurité, Pare-feu, Fail2ban & IPs bloquées
if (m.security) {
const sec = m.security;
const fwDot = document.getElementById('dash-fw-dot');
const fwStatus = document.getElementById('dash-fw-status');
const fwDetail = document.getElementById('dash-fw-detail');
const isFwActive = Boolean(sec.firewall_enabled);
if (isFwActive) {
if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-500/50';
if (fwStatus) {
fwStatus.textContent = 'Actif';
fwStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1';
}
if (fwDetail) fwDetail.textContent = sec.strict_port80 ? 'Mode Strict (Port 80 Certbot)' : `Port HTTPS : ${sec.admin_port || 8443}`;
} else {
if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-slate-500';
if (fwStatus) {
fwStatus.textContent = 'Inactif';
fwStatus.className = 'text-sm font-extrabold text-slate-400 mt-1';
}
if (fwDetail) fwDetail.textContent = 'Tout le trafic autorisé';
}
const f2bDot = document.getElementById('dash-f2b-dot');
const f2bStatus = document.getElementById('dash-f2b-status');
const f2bDetail = document.getElementById('dash-f2b-detail');
if (sec.fail2ban_running) {
if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
if (f2bStatus) {
f2bStatus.textContent = 'En ligne';
f2bStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1';
}
const activeJailCount = Object.keys(sec.jails || {}).length;
if (f2bDetail) f2bDetail.textContent = activeJailCount > 0 ? `${activeJailCount} prison(s) active(s)` : 'Surveillance active';
} else {
if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-rose-500';
if (f2bStatus) {
f2bStatus.textContent = 'Arrêté';
f2bStatus.className = 'text-sm font-extrabold text-rose-400 mt-1';
}
if (f2bDetail) f2bDetail.textContent = 'Service non démarré';
}
const bannedCountEl = document.getElementById('dash-banned-count');
if (bannedCountEl) bannedCountEl.textContent = sec.total_banned || 0;
let totalFailedAll = 0;
let totalBannedAll = 0;
Object.values(sec.jails || {}).forEach(j => {
totalFailedAll += (j.total_failed || 0);
totalBannedAll += (j.total_banned || 0);
});
const blockedBadge = document.getElementById('dash-total-blocked-badge');
if (blockedBadge) {
blockedBadge.textContent = `${totalFailedAll} attaque(s) contrée(s)`;
if (totalFailedAll > 0) {
blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30 font-mono';
} else {
blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 font-mono';
}
}
const latestBannedEl = document.getElementById('dash-latest-banned');
if (latestBannedEl) {
if (sec.banned_ips && sec.banned_ips.length > 0) {
latestBannedEl.textContent = `Bannie : ${sec.banned_ips[0].ip}`;
latestBannedEl.className = 'text-[10px] text-rose-400 mt-1 truncate font-mono font-bold';
} else {
latestBannedEl.textContent = 'Aucun ban actif';
latestBannedEl.className = 'text-[10px] text-emerald-400 mt-1 truncate';
}
}
const clientIpEl = document.getElementById('dash-client-ip');
if (clientIpEl && m.network?.ip) {
clientIpEl.textContent = `IP locale PBX : ${m.network.ip}`;
}
// Graphique Répartition Jails
const astJail = sec.jails?.asterisk || {};
const webJail = sec.jails?.['nexus-web'] || {};
const sshJail = sec.jails?.sshd || {};
const astFailed = astJail.total_failed || 0;
const webFailed = webJail.total_failed || 0;
const sshFailed = sshJail.total_failed || 0;
const totalThreats = astFailed + webFailed + sshFailed;
const astPct = totalThreats > 0 ? Math.round((astFailed / totalThreats) * 100) : 0;
const webPct = totalThreats > 0 ? Math.round((webFailed / totalThreats) * 100) : 0;
const sshPct = totalThreats > 0 ? Math.round((sshFailed / totalThreats) * 100) : 0;
const astStats = document.getElementById('dash-jail-ast-stats');
const astBar = document.getElementById('dash-jail-ast-bar');
if (astStats) astStats.textContent = `${astFailed} contrée(s) (${astPct}%) • ${astJail.currently_banned || 0} ban actif`;
if (astBar) astBar.style.width = `${astPct}%`;
const webStats = document.getElementById('dash-jail-web-stats');
const webBar = document.getElementById('dash-jail-web-bar');
if (webStats) webStats.textContent = `${webFailed} contrée(s) (${webPct}%) • ${webJail.currently_banned || 0} ban actif`;
if (webBar) webBar.style.width = `${webPct}%`;
const sshStats = document.getElementById('dash-jail-ssh-stats');
const sshBar = document.getElementById('dash-jail-ssh-bar');
if (sshStats) sshStats.textContent = `${sshFailed} contrée(s) (${sshPct}%) • ${sshJail.currently_banned || 0} ban actif`;
if (sshBar) sshBar.style.width = `${sshPct}%`;
}
// 9. Rendu de la liste des Services Système
if (m.services && Array.isArray(m.services)) {
renderDashboardServicesList(m.services);
}
}
function renderDashboardServicesList(services) {
const container = document.getElementById('dash-services-list');
if (!container) return;
let allOk = true;
const html = services.map(s => {
if (!s.active && s.id !== 'firewall') allOk = false;
const dotClass = s.active
? 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-400/50'
: 'w-2 h-2 rounded-full bg-rose-500';
const badgeClass = s.active
? 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
: 'px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30';
return `
<div class="py-2.5 flex items-center justify-between text-xs">
<div class="flex items-center gap-2.5">
<span class="${dotClass}"></span>
<div>
<div class="font-bold text-white flex items-center gap-2">
<span>${escapeHtml(s.name)}</span>
<span class="text-[10px] font-mono text-slate-400 font-normal">(${escapeHtml(s.port)})</span>
</div>
<div class="text-[11px] text-slate-400">${escapeHtml(s.desc)}</div>
</div>
</div>
<div class="text-right">
<span class="${badgeClass}">${escapeHtml(s.status_text)}</span>
<div class="text-[10px] font-mono text-slate-400 mt-0.5">${escapeHtml(s.uptime)}</div>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
const allBadge = document.getElementById('dash-services-all-badge');
if (allBadge) {
if (allOk) {
allBadge.textContent = 'TOUS OPÉRATIONNELS';
allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
} else {
allBadge.textContent = 'ATTENTION DÉMON';
allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
}
}
}
function updateDonut(circleId, labelId, pct) {
const circle = document.getElementById(circleId);
const label = document.getElementById(labelId);
const clamped = Math.max(0, Math.min(100, Math.round(pct)));
if (circle) {
circle.setAttribute('stroke-dasharray', `${clamped}, 100`);
}
if (label) {
label.textContent = `${clamped}%`;
}
}
function drawDualAreaChart(svgId, seriesA, seriesB, maxVal, colorA, colorB, unitLabel) {
const svg = document.getElementById(svgId);
if (!svg) return;
const width = 500;
const height = 180;
const padTop = 14;
const padBottom = 18;
const plotH = height - padTop - padBottom;
const safeMax = maxVal > 0 ? maxVal : 100;
const toPoints = (arr) => arr.map((val, idx) => {
const x = (idx / (arr.length - 1)) * width;
const ratio = Math.max(0, Math.min(1, val / safeMax));
const y = padTop + plotH - (ratio * plotH);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const ptsA = toPoints(seriesA);
const ptsB = toPoints(seriesB);
const areaA = `0,${padTop + plotH} ` + ptsA.join(' ') + ` ${width},${padTop + plotH}`;
const areaB = `0,${padTop + plotH} ` + ptsB.join(' ') + ` ${width},${padTop + plotH}`;
const gradIdA = `${svgId}-grad-a`;
const gradIdB = `${svgId}-grad-b`;
svg.innerHTML = `
<defs>
<linearGradient id="${gradIdA}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${colorA}" stop-opacity="0.38"/>
<stop offset="100%" stop-color="${colorA}" stop-opacity="0.02"/>
</linearGradient>
<linearGradient id="${gradIdB}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${colorB}" stop-opacity="0.32"/>
<stop offset="100%" stop-color="${colorB}" stop-opacity="0.02"/>
</linearGradient>
</defs>
<!-- Lignes de grille horizontales -->
<line x1="0" y1="${padTop}" x2="${width}" y2="${padTop}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
<line x1="0" y1="${padTop + plotH / 2}" x2="${width}" y2="${padTop + plotH / 2}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
<line x1="0" y1="${padTop + plotH}" x2="${width}" y2="${padTop + plotH}" stroke="#3d4657" stroke-width="1"/>
<text x="6" y="${padTop + 10}" fill="#94a3b8" font-size="10" font-family="monospace">${Math.round(safeMax)} ${unitLabel}</text>
<text x="6" y="${padTop + plotH / 2 - 4}" fill="#64748b" font-size="10" font-family="monospace">${Math.round(safeMax / 2)} ${unitLabel}</text>
<!-- Courbe B -->
<polygon points="${areaB}" fill="url(#${gradIdB})"/>
<polyline fill="none" stroke="${colorB}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" points="${ptsB.join(' ')}"/>
<!-- Courbe A -->
<polygon points="${areaA}" fill="url(#${gradIdA})"/>
<polyline fill="none" stroke="${colorA}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" points="${ptsA.join(' ')}"/>
`;
}
function getContactMap() {
const map = {};
const contacts = appState.metrics?.asterisk?.contacts || [];
contacts.forEach(c => {
map[String(c.extension)] = c;
});
return map;
}
function extractCleanIp(uri) {
if (!uri) return '';
const m = uri.match(/@([0-9a-zA-Z\.\-]+(?::\d+)?)/);
if (m) return m[1];
const m2 = uri.match(/^sip:([0-9a-zA-Z\.\-]+(?::\d+)?)/);
if (m2) return m2[1];
return uri.split(';')[0].replace(/^sip:/, '');
}
function renderDashboardPhonesTable() {
const tbody = document.getElementById('dashboard-phones-tbody');
if (!tbody) return;
const exts = appState.extensions || [];
const contactMap = getContactMap();
if (exts.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="py-8 text-center text-slate-400 text-xs">Aucun poste SIP configuré.</td></tr>`;
return;
}
tbody.innerHTML = exts.map(ext => {
const c = contactMap[String(ext.extension)];
const isTls = ext.transport === 'tls';
const st = c ? String(c.status).toLowerCase() : '';
const isUnavail = c && st.includes('unavail');
const isOnline = c && !isUnavail && (st.startsWith('avail') || st.includes('nonqual'));
// Chiffrement Voix (SRTP) configuré pour l'extension
const encMode = Number(ext.encryption ?? (isTls ? 1 : 0));
let srtpBadge = '';
if (encMode === 2) {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30" title="Chiffrement média SRTP obligatoire (AES-128)">🛡️ SRTP Forcé</span>`;
} else if (encMode === 1) {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Chiffrement média SRTP négociable si supporté par le poste">🛡️ SRTP Négociable</span>`;
} else {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-medium bg-anthracite-900 text-slate-400 border border-anthracite-700" title="Voix en clair (RTP standard)">🔈 RTP Clair</span>`;
}
// Détection dynamique du mode réel négocié en direct par le téléphone
let modeBadge = '';
if (c) {
const actTr = (c.active_transport || 'udp').toLowerCase();
let trPill = '';
let profileNote = '';
if (actTr === 'tls') {
trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm shadow-emerald-500/10">
🔒 TLS Actif (Chiffré)
</span>
`;
profileNote = `<span class="${isTls ? 'text-emerald-300 font-semibold' : 'text-slate-300'}">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
} else if (actTr === 'wss') {
trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">
🔒 WSS WebRTC
</span>
`;
profileNote = `<span class="text-cyan-300 font-semibold">WebRTC</span>`;
} else if (actTr === 'tcp') {
trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/35">
🔓 TCP Actif (Clair)
</span>
`;
profileNote = `<span class="text-slate-300">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
} else {
// UDP
trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${isTls ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40' : 'bg-anthracite-750 text-slate-200 border border-anthracite-600'}">
🔓 UDP Actif (Clair)
</span>
`;
profileNote = isTls ? '<span class="text-amber-400 font-semibold">⚠️ Exige TLS</span>' : '<span class="text-slate-400">UDP Interne</span>';
}
modeBadge = `
<div class="space-y-1">
<div>${trPill}</div>
<div class="flex items-center gap-1.5 flex-wrap pl-0.5">
${srtpBadge}
<span class="text-[10px] text-slate-400 font-mono">Profil: ${profileNote}</span>
</div>
</div>
`;
} else {
modeBadge = `
<div class="space-y-1">
<div>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">
Inactif
</span>
</div>
<div class="flex items-center gap-1.5 flex-wrap pl-0.5">
${srtpBadge}
<span class="text-[10px] text-slate-500 font-mono">Profil: ${isTls ? 'TLS Strict' : 'UDP Interne'}</span>
</div>
</div>
`;
}
// Statut SIP
let statusBadge = '';
if (isOnline) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></i>En ligne (${escapeHtml(c.status)})</span>`;
} else if (isUnavail) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30" title="Contact injoignable (rejet de transport ou délai d'attente dépassé)"><i class="w-2 h-2 rounded-full bg-rose-400"></i>Injoignable (${escapeHtml(c.status)})</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700"><i class="w-2 h-2 rounded-full bg-slate-500"></i>Hors-ligne</span>`;
}
// Contact / IP & Modèle Téléphone (User-Agent)
let contactHtml = '';
if (c) {
const cleanIp = extractCleanIp(c.uri);
const ua = c.user_agent ? escapeHtml(c.user_agent) : '';
contactHtml = `
<div>
<div class="font-mono text-xs text-slate-200 font-semibold">${escapeHtml(cleanIp)}</div>
${ua ? `<div class="text-[11px] text-indigo-300 font-medium truncate max-w-[240px]" title="${ua}">📱 ${ua}</div>` : ''}
</div>
`;
} else {
contactHtml = `<span class="text-xs text-slate-500 italic">Non enregistré</span>`;
}
let rttText = '—';
let rttColor = 'text-slate-500';
if (isOnline && c && c.rtt_ms !== null && !isNaN(c.rtt_ms)) {
rttText = `${c.rtt_ms} ms`;
rttColor = c.rtt_ms < 30 ? 'text-emerald-300 font-bold' : (c.rtt_ms < 100 ? 'text-cyan-300' : 'text-amber-300');
} else if (isUnavail) {
rttText = 'Inaccessible';
rttColor = 'text-rose-400/90 font-semibold italic';
}
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
<td class="py-3 px-4 font-medium text-white">${escapeHtml(ext.name)}</td>
<td class="py-3 px-4">${modeBadge}</td>
<td class="py-3 px-4">${statusBadge}</td>
<td class="py-3 px-4">${contactHtml}</td>
<td class="py-3 px-4 text-right font-mono text-xs ${rttColor}">${rttText}</td>
</tr>
`;
}).join('');
}
// --- 1. Rendu & CRUD Extensions ---
function renderExtensionsTable() {
const tbody = document.getElementById('extensions-tbody');
if (!tbody) return;
const rows = appState.extensions || [];
const contactMap = getContactMap();
const groups = appState.groups || [];
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="py-8 text-center text-slate-400 text-xs">Aucun poste configuré. Cliquez sur « + Nouveau Poste SIP ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(ext => {
const c = contactMap[String(ext.extension)];
const isTls = ext.transport === 'tls';
const encMode = Number(ext.encryption ?? (isTls ? 1 : 0));
let encLabel = '';
if (encMode === 2) {
encLabel = '<span class="text-[10px] font-bold text-emerald-400">SRTP Forcé</span>';
} else if (encMode === 1) {
encLabel = '<span class="text-[10px] font-bold text-cyan-400">SRTP Négociable</span>';
} else {
encLabel = '<span class="text-[10px] text-slate-400">RTP Clair</span>';
}
const badge = isTls
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">🔒 TLS (${encLabel})</span>`
: (encMode > 0
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">🛡️ UDP (${encLabel})</span>`
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-anthracite-750 text-slate-200 border border-anthracite-600">🏢 UDP (RTP Clair)</span>`);
const actTr = c ? (c.active_transport || 'udp').toUpperCase() : '';
const stLower = c ? String(c.status).toLowerCase() : '';
const isCUnavail = c && stLower.includes('unavail');
const isCOnline = c && !isCUnavail && (stLower.startsWith('avail') || stLower.includes('nonqual'));
const regBadge = c
? (isCOnline
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30" title="${escapeHtml(c.user_agent || '')}"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400"></i>Connecté (${actTr}${c.rtt_ms !== null && !isNaN(c.rtt_ms) ? ` • ${c.rtt_ms}ms` : ''})</span>`
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30" title="Contact rejeté ou injoignable (rejet de transport ou délai dépassé)"><i class="w-1.5 h-1.5 rounded-full bg-rose-400"></i>Injoignable (${actTr})</span>`)
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Non connecté</span>`;
// Trouve à quels groupes d'extensions (Sites/Droits) ce poste appartient (ex: Nice, Paris)
const extGroups = appState.extension_groups || [];
const myExtGroups = extGroups.filter(eg => {
const mList = Array.isArray(eg.members) ? eg.members.map(String) : [];
return mList.includes(String(ext.extension));
});
const groupBadges = myExtGroups.length > 0
? myExtGroups.map(eg => `<span class="px-2 py-0.5 rounded-md bg-cyan-500/15 text-cyan-300 border border-cyan-500/30 text-[11px] font-semibold">${escapeHtml(eg.name)}</span>`).join(' ')
: `<span class="text-slate-500 text-xs italic">Aucun</span>`;
const vmBadge = Number(ext.voicemail_enabled) === 1
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30" title="Boîte vocale active (${ext.ring_time || 20}s)">📟 Boîte</span>`
: '';
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
<td class="py-3.5 px-4 font-medium text-white">
<div class="flex items-center gap-2">
<span>${escapeHtml(ext.name)}</span>
${vmBadge}
</div>
</td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${groupBadges}</div></td>
<td class="py-3.5 px-4">${regBadge}</td>
<td class="py-3.5 px-4">${badge}</td>
<td class="py-3.5 px-4 font-mono text-xs text-slate-200">${escapeHtml(ext.codecs)}</td>
<td class="py-3.5 px-4">
<code class="px-2 py-0.5 rounded bg-anthracite-950 border border-anthracite-700 text-xs font-mono text-slate-300">${escapeHtml(ext.secret)}</code>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
function switchExtModalTab(tab) {
const isGeneral = (tab === 'general');
const btnGen = document.getElementById('tab-btn-ext-general');
const btnVm = document.getElementById('tab-btn-ext-voicemail');
const panGen = document.getElementById('tab-panel-ext-general');
const panVm = document.getElementById('tab-panel-ext-voicemail');
if (btnGen) {
btnGen.className = isGeneral
? 'px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white'
: 'px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700';
}
if (btnVm) {
btnVm.className = !isGeneral
? 'px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white flex items-center gap-1.5'
: 'px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700 flex items-center gap-1.5';
}
if (panGen) panGen.classList.toggle('hidden', !isGeneral);
if (panVm) panVm.classList.toggle('hidden', isGeneral);
}
function syncVoicemailToggle(enabled) {
const details = document.getElementById('voicemail-details-container');
if (details) {
details.style.opacity = enabled ? '1' : '0.4';
details.style.pointerEvents = enabled ? 'auto' : 'none';
}
const badge = document.getElementById('badge-ext-vm-status');
if (badge) {
badge.className = `w-2 h-2 rounded-full ${enabled ? 'bg-amber-400' : 'bg-slate-500'}`;
}
}
function syncPinMode(mode) {
const isManual = (mode === 'manual');
const pinBox = document.getElementById('pin-input-box');
const forceInput = document.getElementById('ext-force-pin-change');
if (pinBox) {
pinBox.style.display = isManual ? 'block' : 'none';
}
if (forceInput) {
forceInput.value = isManual ? '0' : '1';
}
}
function openExtensionModal(ext = null) {
const form = document.getElementById('form-extension');
form.reset();
document.getElementById('modal-extension-title').textContent = ext ? `Modifier le poste ${ext.extension}` : 'Nouveau Poste SIP';
form.elements['id'].value = ext ? ext.id : '';
form.elements['extension'].value = ext ? ext.extension : '';
form.elements['name'].value = ext ? ext.name : '';
form.elements['secret'].value = ext ? ext.secret : generateSecretString();
form.elements['codecs'].value = ext ? ext.codecs : 'alaw,ulaw,g722,opus';
form.elements['max_contacts'].value = ext ? ext.max_contacts : 2;
const transport = ext ? ext.transport : 'udp';
form.querySelectorAll('input[name="transport"]').forEach(r => {
r.checked = (r.value === transport);
});
syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0));
// Réinitialisation de l'onglet actif vers "Général"
switchExtModalTab('general');
// Initialisation des champs Messagerie Vocale
const vmEnabled = ext ? (Number(ext.voicemail_enabled) === 1) : false;
const vmCheckbox = document.getElementById('ext-voicemail-enabled');
if (vmCheckbox) vmCheckbox.checked = vmEnabled;
const isFirstCall = ext ? (Number(ext.force_pin_change) === 1) : false;
form.querySelectorAll('input[name="pin_mode"]').forEach(r => {
r.checked = isFirstCall ? (r.value === 'first_call') : (r.value === 'manual');
});
syncPinMode(isFirstCall ? 'first_call' : 'manual');
if (form.elements['voicemail_pin']) {
form.elements['voicemail_pin'].value = ext ? (ext.voicemail_pin || '') : '';
}
if (form.elements['ring_time']) {
form.elements['ring_time'].value = ext ? (ext.ring_time || 20) : 20;
}
const skipPinCb = document.getElementById('ext-skip-pin');
if (skipPinCb) {
skipPinCb.checked = ext ? (Number(ext.voicemail_skip_pin) === 1) : true;
}
if (form.elements['voicemail_email']) {
form.elements['voicemail_email'].value = ext ? (ext.voicemail_email || '') : '';
}
const vmAttachCb = document.getElementById('ext-vm-attach');
if (vmAttachCb) {
vmAttachCb.checked = ext ? (Number(ext.voicemail_attach) === 1) : true;
}
const vmDeleteCb = document.getElementById('ext-vm-delete');
if (vmDeleteCb) {
vmDeleteCb.checked = ext ? (Number(ext.voicemail_delete) === 1) : false;
}
syncVoicemailToggle(vmEnabled);
// Rendu des cases d'appartenance aux Groupes d'Extensions (Sites: Nice, Paris...)
const grpBox = document.getElementById('extension-groups-checkboxes');
if (grpBox) {
const extGroups = appState.extension_groups || [];
if (extGroups.length === 0) {
grpBox.innerHTML = `<span class="text-slate-500 text-xs col-span-2">Aucun groupe d'extensions créé. Vous pouvez en créer dans « Groupes » → « Groupes d'extensions ».</span>`;
} else {
const currentExt = ext ? String(ext.extension) : '';
grpBox.innerHTML = extGroups.map(eg => {
const isMember = currentExt && Array.isArray(eg.members) && eg.members.map(String).includes(currentExt);
return `
<label class="flex items-center gap-2 p-2 rounded-lg bg-anthracite-900 border border-anthracite-700 hover:border-cyan-500/50 cursor-pointer">
<input type="checkbox" name="member_extension_groups[]" value="${Number(eg.id)}" ${isMember ? 'checked' : ''} class="rounded bg-anthracite-950 border-anthracite-600">
<div class="leading-tight">
<span class="text-xs font-bold text-white">${escapeHtml(eg.name)}</span>
${eg.description ? `<span class="text-[10px] text-slate-400 block">${escapeHtml(eg.description)}</span>` : ''}
</div>
</label>
`;
}).join('');
}
}
openModal('modal-extension');
}
function editExtension(id) {
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
if (ext) openExtensionModal(ext);
}
async function deleteExtension(id, optNum = null) {
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
const num = optNum || (ext ? ext.extension : 'ce poste');
if (!confirm(`Supprimer définitivement le poste SIP ${num} ?`)) return;
try {
const res = await apiCall('delete_extension', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
function syncEncryptionToggle(transport, currentVal = null) {
const sel = document.getElementById('ext-encryption');
if (!sel) return;
if (currentVal !== null) {
sel.value = String(currentVal);
} else if (transport === 'tls' && sel.value === '0') {
sel.value = '1';
}
}
function generateSecretString() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
let out = '';
for (let i = 0; i < 14; i++) {
out += chars.charAt(Math.floor(Math.random() * chars.length));
}
return out;
}
function generateRandomSecret() {
const form = document.getElementById('form-extension');
form.elements['secret'].value = generateSecretString();
}
// --- 2A. Rendu & CRUD Groupes de Sonnerie (Ring Groups) ---
function renderGroupsTable() {
const tbody = document.getElementById('groups-tbody');
if (!tbody) return;
const rows = appState.groups || [];
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="py-8 text-center text-slate-400 text-xs">Aucun groupe de sonnerie configuré. Cliquez sur « + Nouveau Groupe de Sonnerie ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(grp => {
const members = Array.isArray(grp.members) ? grp.members : [];
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(grp.extension)}</td>
<td class="py-3.5 px-4 font-bold text-white">${escapeHtml(grp.name)}</td>
<td class="py-3.5 px-4"><code class="text-xs text-cyan-300 font-mono">${escapeHtml(grp.strategy)}</code></td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → <code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code></td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editGroup(${Number(grp.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteGroup(${Number(grp.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
let activeGroupSelectedExts = new Set();
function openGroupModal(grp = null) {
const form = document.getElementById('form-group');
form.reset();
document.getElementById('modal-group-title').textContent = grp ? `Modifier le groupe de sonnerie ${grp.name} (${grp.extension})` : "Nouveau Groupe de Sonnerie";
form.elements['id'].value = grp ? grp.id : '';
form.elements['extension'].value = grp ? grp.extension : '';
form.elements['name'].value = grp ? grp.name : '';
form.elements['strategy'].value = grp ? grp.strategy : 'ringall';
form.elements['ring_time'].value = grp ? grp.ring_time : 20;
// Failover destinations
const failoverSel = document.getElementById('group-failover-select');
if (failoverSel) {
failoverSel.innerHTML = buildDestinationOptionsHtml(grp ? grp.failover_dest : 'hangup');
}
activeGroupSelectedExts = new Set(grp && Array.isArray(grp.members) ? grp.members.map(String) : []);
renderGroupInteractiveBoxes();
openModal('modal-group');
}
function renderGroupInteractiveBoxes() {
const allowedBox = document.getElementById('group-members-allowed-box');
const blockedBox = document.getElementById('group-members-blocked-box');
const hiddenBox = document.getElementById('group-members-checkboxes');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
let hiddenHtml = '';
let allowedCount = 0;
let blockedCount = 0;
allExts.forEach(ext => {
const extStr = String(ext.extension);
const isSel = activeGroupSelectedExts.has(extStr);
if (isSel) {
allowedCount++;
allowedHtml += `
<button type="button" onclick="toggleGroupMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/20 hover:bg-red-500/20 border border-emerald-500/40 hover:border-red-500/40 text-xs font-semibold text-emerald-300 hover:text-red-300 transition cursor-pointer">
<span class="font-mono">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
</button>
`;
hiddenHtml += `<input type="checkbox" name="member_ext" value="${escapeHtml(extStr)}" checked>`;
} else {
blockedCount++;
blockedHtml += `
<button type="button" onclick="toggleGroupMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-500/20 border border-anthracite-700 hover:border-emerald-500/40 text-xs font-medium text-slate-300 hover:text-emerald-300 transition cursor-pointer">
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-500 group-hover:text-emerald-300 ml-1 font-bold">+</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste qui sonne. Cliquez sur un poste à droite pour l'ajouter.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes sonnent dans ce groupe !</div>`;
if (hiddenBox) hiddenBox.innerHTML = hiddenHtml;
const countA = document.getElementById('group-selected-count');
const countB = document.getElementById('group-unselected-count');
if (countA) countA.textContent = String(allowedCount);
if (countB) countB.textContent = String(blockedCount);
}
function toggleGroupMemberExt(extStr) {
if (activeGroupSelectedExts.has(extStr)) {
activeGroupSelectedExts.delete(extStr);
} else {
activeGroupSelectedExts.add(extStr);
}
renderGroupInteractiveBoxes();
}
function groupTransferAllExtensions(includeAll) {
const allExts = appState.extensions || [];
if (includeAll) {
allExts.forEach(e => activeGroupSelectedExts.add(String(e.extension)));
} else {
activeGroupSelectedExts.clear();
}
renderGroupInteractiveBoxes();
}
function editGroup(id) {
const grp = (appState.groups || []).find(g => Number(g.id) === Number(id));
if (grp) openGroupModal(grp);
}
async function deleteGroup(id, optNum = null) {
const grp = (appState.groups || []).find(g => Number(g.id) === Number(id));
const num = optNum || (grp ? grp.extension : 'ce groupe');
if (!confirm(`Supprimer le groupe de sonnerie ${num} ?`)) return;
try {
const res = await apiCall('delete_group', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 2B. Rendu & CRUD Groupes d'Extensions (Sites & Droits Routes Sortantes) ---
function renderExtensionGroupsTable() {
const tbody = document.getElementById('extgroups-tbody');
if (!tbody) return;
const rows = appState.extension_groups || [];
const obRoutes = appState.outbound_routes || [];
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="py-8 text-center text-slate-400 text-xs">Aucun groupe d'extensions configuré. Cliquez sur « + Nouveau Groupe d'Extensions ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(eg => {
const members = Array.isArray(eg.members) ? eg.members : [];
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-200 border border-cyan-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucune extension</span>';
// Routes sortantes où ce groupe d'extensions a accès
const egIdStr = String(eg.id);
const egNameStr = String(eg.name);
const accessibleRoutes = obRoutes.filter(r => {
if (r.permission_mode === 'all') return true;
const ag = Array.isArray(r.allowed_groups) ? r.allowed_groups.map(String) : [];
return ag.includes(egIdStr) || ag.includes(egNameStr);
});
const routeBadges = accessibleRoutes.length > 0
? accessibleRoutes.map(r => `<span class="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 text-[11px] font-semibold">${escapeHtml(r.name)}</span>`).join(' ')
: `<span class="px-2 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[11px]">Aucune (Verrouillé)</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-bold text-white text-sm">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-cyan-400"></span>
<span>${escapeHtml(eg.name)}</span>
</div>
</td>
<td class="py-3.5 px-4 text-xs text-slate-300">${escapeHtml(eg.description || '—')}</td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${routeBadges}</div></td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editExtGroup(${Number(eg.id)})" class="text-xs font-semibold text-cyan-300 hover:text-cyan-200">Modifier</button>
<button type="button" onclick="deleteExtGroup(${Number(eg.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
let activeExtGroupSelectedExts = new Set();
function openExtGroupModal(eg = null) {
const form = document.getElementById('form-extgroup');
form.reset();
document.getElementById('modal-extgroup-title').textContent = eg ? `Modifier le groupe d'extensions ${eg.name}` : "Nouveau Groupe d'Extensions";
form.elements['id'].value = eg ? eg.id : '';
form.elements['name'].value = eg ? eg.name : '';
form.elements['description'].value = eg ? eg.description : '';
activeExtGroupSelectedExts = new Set(eg && Array.isArray(eg.members) ? eg.members.map(String) : []);
renderExtGroupInteractiveBoxes();
openModal('modal-extgroup');
}
function renderExtGroupInteractiveBoxes() {
const allowedBox = document.getElementById('extgroup-members-allowed-box');
const blockedBox = document.getElementById('extgroup-members-blocked-box');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
let allowedCount = 0;
let blockedCount = 0;
allExts.forEach(ext => {
const extStr = String(ext.extension);
const isSel = activeExtGroupSelectedExts.has(extStr);
if (isSel) {
allowedCount++;
allowedHtml += `
<button type="button" onclick="toggleExtGroupMember('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/20 hover:bg-red-500/20 border border-cyan-500/40 hover:border-red-500/40 text-xs font-semibold text-cyan-300 hover:text-red-300 transition cursor-pointer">
<span class="font-mono">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
</button>
`;
} else {
blockedCount++;
blockedHtml += `
<button type="button" onclick="toggleExtGroupMember('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-cyan-500/20 border border-anthracite-700 hover:border-cyan-500/40 text-xs font-medium text-slate-300 hover:text-cyan-300 transition cursor-pointer">
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-500 group-hover:text-cyan-300 ml-1 font-bold">+</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucune extension rattachée. Cliquez à droite pour en ajouter.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Toutes les extensions sont dans ce groupe !</div>`;
const countA = document.getElementById('extgroup-selected-count');
const countB = document.getElementById('extgroup-unselected-count');
if (countA) countA.textContent = String(allowedCount);
if (countB) countB.textContent = String(blockedCount);
}
function toggleExtGroupMember(extStr) {
if (activeExtGroupSelectedExts.has(extStr)) {
activeExtGroupSelectedExts.delete(extStr);
} else {
activeExtGroupSelectedExts.add(extStr);
}
renderExtGroupInteractiveBoxes();
}
function extGroupTransferAll(includeAll) {
const allExts = appState.extensions || [];
if (includeAll) {
allExts.forEach(e => activeExtGroupSelectedExts.add(String(e.extension)));
} else {
activeExtGroupSelectedExts.clear();
}
renderExtGroupInteractiveBoxes();
}
function editExtGroup(id) {
const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id));
if (eg) openExtGroupModal(eg);
}
async function deleteExtGroup(id, optName = null) {
const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id));
const name = optName || (eg ? eg.name : 'ce groupe');
if (!confirm(`Supprimer définitivement le groupe d'extensions « ${name} » ?`)) return;
try {
const res = await apiCall('delete_extension_group', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 2C. Rendu & CRUD Groupes de Téléavertissement & Interphonie (Paging) ---
function renderPagingTable() {
const tbody = document.getElementById('paging-tbody');
if (!tbody) return;
const rows = appState.paging_groups || [];
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="py-8 text-center text-slate-400 text-xs">Aucun groupe de téléavertissement configuré. Cliquez sur « + Nouveau Groupe de Téléavertissement ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(pg => {
const members = Array.isArray(pg.members) ? pg.members : [];
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-purple-500/20 text-purple-200 border border-purple-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
const isDuplex = Number(pg.duplex) === 1;
const typeBadge = isDuplex
? `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">Duplex (2 voies)</span>`
: `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/30">Simplex (Haut-parleur)</span>`;
const busyLabel = pg.busy_mode === 'force'
? `<span class="text-rose-300 text-xs font-semibold">Forcer</span>`
: `<span class="text-emerald-300 text-xs font-semibold">Ignorer si occupé</span>`;
const beepLabel = Number(pg.play_beep) === 1
? `<span class="text-emerald-400 text-xs font-semibold">✓ Oui</span>`
: `<span class="text-slate-400 text-xs">Non</span>`;
const statusBadge = Number(pg.enabled) === 1
? `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Actif</span>`
: `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300 border border-slate-600">Inactif</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-purple-300 text-base">${escapeHtml(pg.extension)}</td>
<td class="py-3.5 px-4">
<div class="font-bold text-white">${escapeHtml(pg.name)}</div>
${pg.description ? `<div class="text-xs text-slate-400">${escapeHtml(pg.description)}</div>` : ''}
</td>
<td class="py-3.5 px-4">${typeBadge}</td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4">${busyLabel}</td>
<td class="py-3.5 px-4">${beepLabel}</td>
<td class="py-3.5 px-4">${statusBadge}</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editPagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-purple-300 hover:text-purple-200">Modifier</button>
<button type="button" onclick="deletePagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
let activePagingSelectedExts = new Set();
function openPagingModal(pg = null) {
const form = document.getElementById('form-paging');
if (!form) return;
form.reset();
document.getElementById('modal-paging-title').textContent = pg
? `Modifier le téléavertissement ${pg.name} (${pg.extension})`
: "Nouveau Groupe de Téléavertissement";
form.elements['id'].value = pg ? pg.id : '';
form.elements['extension'].value = pg ? pg.extension : '';
form.elements['name'].value = pg ? pg.name : '';
form.elements['description'].value = pg ? (pg.description || '') : '';
form.elements['duplex'].value = pg ? String(pg.duplex) : '0';
form.elements['busy_mode'].value = pg ? (pg.busy_mode || 'skip') : 'skip';
form.elements['timeout'].value = pg ? (pg.timeout || 120) : 120;
if (form.elements['play_beep']) {
form.elements['play_beep'].checked = pg ? Number(pg.play_beep) === 1 : true;
}
if (form.elements['enabled']) {
form.elements['enabled'].checked = pg ? Number(pg.enabled) === 1 : true;
}
activePagingSelectedExts = new Set(pg && Array.isArray(pg.members) ? pg.members.map(String) : []);
renderPagingInteractiveBoxes();
openModal('modal-paging');
}
function renderPagingInteractiveBoxes() {
const allowedBox = document.getElementById('paging-members-allowed-box');
const blockedBox = document.getElementById('paging-members-blocked-box');
const hiddenBox = document.getElementById('paging-members-checkboxes');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
let hiddenHtml = '';
let allowedCount = 0;
let blockedCount = 0;
allExts.forEach(ext => {
const extStr = String(ext.extension);
const isSel = activePagingSelectedExts.has(extStr);
if (isSel) {
allowedCount++;
allowedHtml += `
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-purple-500/20 hover:bg-red-500/20 border border-purple-500/40 hover:border-red-500/40 text-xs font-semibold text-purple-300 hover:text-red-300 transition cursor-pointer">
<span class="font-mono font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
</button>
`;
hiddenHtml += `<input type="checkbox" name="members[]" value="${escapeHtml(extStr)}" checked>`;
} else {
blockedCount++;
blockedHtml += `
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-purple-500/20 border border-anthracite-700 hover:border-purple-500/40 text-xs font-medium text-slate-300 hover:text-purple-300 transition cursor-pointer">
<span class="font-mono text-purple-300 font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-500 group-hover:text-purple-300 ml-1 font-bold">+</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste qui reçoit l'annonce. Cliquez sur un poste à droite pour l'ajouter.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes reçoivent l'annonce !</div>`;
if (hiddenBox) hiddenBox.innerHTML = hiddenHtml;
const countA = document.getElementById('paging-selected-count');
const countB = document.getElementById('paging-unselected-count');
if (countA) countA.textContent = String(allowedCount);
if (countB) countB.textContent = String(blockedCount);
}
function togglePagingMemberExt(extStr) {
if (activePagingSelectedExts.has(extStr)) {
activePagingSelectedExts.delete(extStr);
} else {
activePagingSelectedExts.add(extStr);
}
renderPagingInteractiveBoxes();
}
function pagingTransferAllExtensions(includeAll) {
const allExts = appState.extensions || [];
if (includeAll) {
allExts.forEach(e => activePagingSelectedExts.add(String(e.extension)));
} else {
activePagingSelectedExts.clear();
}
renderPagingInteractiveBoxes();
}
function editPagingGroup(id) {
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
if (pg) openPagingModal(pg);
}
async function deletePagingGroup(id) {
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
const num = pg ? pg.extension : 'ce groupe';
if (!confirm(`Supprimer le groupe de téléavertissement ${num} ?`)) return;
try {
const res = await apiCall('delete_paging_group', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 3. Rendu & CRUD Trunks SIP (Configuration PJSIP Avancée) ---
function slugifyTrunkName(text) {
return String(text || '')
.toLowerCase()
.trim()
.replace(/^ivr-/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'default';
}
function renderTrunksTable() {
const tbody = document.getElementById('trunks-tbody');
if (!tbody) return;
const rows = appState.trunks || [];
const regs = appState.metrics?.asterisk?.registrations || {};
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun Trunk SIP opérateur configuré. Cliquez sur « + Nouveau Trunk SIP ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(t => {
const slug = `trunk-${slugifyTrunkName(t.name)}`;
const regInfo = regs[slug];
const regMode = t.registration_mode || (Number(t.register) === 1 ? 'send' : 'none');
const isIpAuth = t.auth_mode === 'ip';
const wantsReg = regMode === 'send' && !isIpAuth;
let statusBadge = '';
if (Number(t.enabled ?? 1) === 0) {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Désactivé</span>`;
} else if (regMode === 'receive') {
const contacts = appState.metrics?.asterisk?.contacts || [];
const isOnline = contacts.some(c =>
(c.extension === slug || c.extension === t.username || (t.username && c.uri && c.uri.includes(t.username)))
&& String(c.status || '').toLowerCase().includes('avail')
);
if (isOnline) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Passerelle En ligne</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">En attente passerelle (Receive)</span>`;
}
} else if (isIpAuth || regMode === 'none') {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/30">Peering IP direct</span>`;
} else if (regInfo && /Registered/i.test(regInfo.status)) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Registered</span>`;
} else if (regInfo) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30">${escapeHtml(regInfo.status)}</span>`;
} else {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">En attente Apply</span>`;
}
const routesArr = Array.isArray(t.did_routes) ? t.did_routes : Object.entries(t.did_routes || {}).map(([did, destination]) => ({ cid: 'Any', did, description: '', destination }));
const didCount = routesArr.length;
const blockCount = routesArr.filter(r => String(r.destination || '').startsWith('terminate-')).length;
const tcCount = routesArr.filter(r => Number(r.time_condition_enabled) === 1).length;
const paiBadges = [
Number(t.send_pai ?? 1) === 1 ? 'PAI' : null,
Number(t.send_rpid ?? 0) === 1 ? 'RPID' : null,
t.media_encryption && t.media_encryption !== 'no' ? t.media_encryption.toUpperCase() : null
].filter(Boolean).map(b => `<span class="px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono">${b}</span>`).join(' ');
const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id));
const mohBadge = mohSound
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[10px] font-semibold">🎵 ${escapeHtml(mohSound.name)}</span>`
: `<span class="text-[10px] text-slate-500">MoH : Globale</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="font-semibold text-white">${escapeHtml(t.name)}</div>
<div class="text-[11px] font-mono text-slate-400">[${escapeHtml(slug)}]</div>
</td>
<td class="py-3.5 px-4">${statusBadge}</td>
<td class="py-3.5 px-4">
<div class="font-mono text-xs ${t.host ? 'text-indigo-300' : 'text-emerald-300'}">
${t.host ? escapeHtml(t.host) + ':' + Number(t.port || 5060) : 'Passerelle locale (Dynamique)'}
</div>
${t.outbound_proxy ? `<div class="text-[11px] font-mono text-slate-400 truncate max-w-[180px]">Proxy: ${escapeHtml(t.outbound_proxy)}</div>` : ''}
</td>
<td class="py-3.5 px-4">
<div class="font-mono text-xs text-slate-200">${escapeHtml(isIpAuth ? 'Auth par IP' : (t.username || '—'))}</div>
<div class="text-[11px] text-slate-400">${escapeHtml(t.from_domain || t.host)} ${paiBadges}</div>
</td>
<td class="py-3.5 px-4 text-xs">
<span class="px-2 py-0.5 rounded bg-anthracite-950 text-slate-200 uppercase font-mono border border-anthracite-700">${escapeHtml(t.transport)}</span>
<span class="ml-1 font-mono text-slate-300">${escapeHtml(t.codecs)}</span>
</td>
<td class="py-3.5 px-4">
<div class="font-mono text-xs text-emerald-300">→ ${escapeHtml(t.inbound_dest)}</div>
<div class="text-[11px] text-slate-400">
${t.cid_name_prefix ? `<span class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 text-[10px] font-mono mr-1 font-semibold">CID: ${escapeHtml(t.cid_name_prefix)}</span>` : ''}
${didCount > 0 ? `<span class="text-indigo-300 font-semibold">${didCount} règle(s) entrante(s)</span>` : 'Tous numéros (Any)'}
${blockCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">${blockCount} blocage(s) CID</span>` : ''}
${tcCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">⏱️ ${tcCount} horaire(s)</span>` : ''}
</div>
<div class="mt-1">${mohBadge}</div>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
${wantsReg ? `<button type="button" onclick="forceRegisterTrunk('${escapeHtml(slug)}-reg')" title="Forcer l'envoi immédiat d'un SIP REGISTER vers l'opérateur pour reprendre la ligne" class="px-2 py-1 rounded-lg bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-xs font-semibold text-cyan-300 transition">🔄 Re-Register</button>` : ''}
<button type="button" onclick="editTrunk(${Number(t.id)}, 'inbound')" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Routes Entrantes</button>
<button type="button" onclick="editTrunk(${Number(t.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteTrunk(${Number(t.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
async function forceRegisterTrunk(regName = '*all') {
try {
const res = await apiCall('force_register_trunk', { reg_name: regName });
showToast(res.message, 'success');
await loadState(true);
} catch (err) {
showToast(err.message, 'error');
}
}
function switchTrunkModalTab(subTab) {
document.querySelectorAll('.trunk-subpanel').forEach(p => p.classList.add('hidden'));
const target = document.getElementById(`trunk-panel-${subTab}`);
if (target) target.classList.remove('hidden');
document.querySelectorAll('.trunk-tab-btn').forEach(btn => {
if (btn.dataset.trunkTab === subTab) {
btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5';
} else {
btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5';
}
});
}
function toggleTrunkAuthFields(mode) {
const container = document.getElementById('trunk-auth-fields');
const form = document.getElementById('form-trunk');
if (!container || !form) return;
if (mode === 'ip') {
container.classList.add('opacity-40', 'pointer-events-none');
form.elements['register'].checked = false;
} else {
container.classList.remove('opacity-40', 'pointer-events-none');
}
}
function normalizeDestinationValue(rawVal) {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const paging = appState.paging_groups || [];
let normVal = String(rawVal || '').trim();
if (!normVal) return 'ext-101';
if (normVal === 'busy') return 'terminate-busy';
if (normVal === 'congestion') return 'terminate-congestion';
if (normVal === 'zapateller') return 'terminate-zapateller';
if (normVal === 'hangup') return 'terminate-hangup';
if (/^\d+$/.test(normVal)) {
if (exts.some(e => String(e.extension) === normVal)) return `ext-${normVal}`;
if (grps.some(g => String(g.extension) === normVal)) return `grp-${normVal}`;
if (paging.some(p => String(p.extension) === normVal)) return `page-${normVal}`;
}
return normVal;
}
function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const paging = appState.paging_groups || [];
const ivrs = appState.ivrs || [];
const sounds = appState.sounds || [];
const normVal = normalizeDestinationValue(selectedValue);
let html = '';
if (exts.length > 0) {
html += `<optgroup label="📱 Postes SIP (Extensions)">`;
exts.forEach(e => {
const val = `ext-${e.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Poste: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
});
html += `</optgroup>`;
}
if (grps.length > 0) {
html += `<optgroup label="👥 Ring Groups (Groupes d'appel)">`;
grps.forEach(g => {
const val = `grp-${g.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Ring Group: ${escapeHtml(g.extension)} — ${escapeHtml(g.name)}</option>`;
});
html += `</optgroup>`;
}
if (paging.length > 0) {
html += `<optgroup label="📢 Téléavertissement & Interphonie (Paging)">`;
paging.forEach(pg => {
const val = `page-${pg.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Paging: ${escapeHtml(pg.extension)} — ${escapeHtml(pg.name)}</option>`;
});
html += `</optgroup>`;
}
if (ivrs.length > 0) {
html += `<optgroup label="🎙️ Serveurs Vocaux (SVI / IVR)">`;
ivrs.forEach(i => {
const val = i.name;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>SVI / IVR: ${escapeHtml(i.name)} ${i.description ? '(' + escapeHtml(i.description) + ')' : ''}</option>`;
});
html += `</optgroup>`;
}
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`;
html += `<option value="moh-default" ${normVal === 'moh-default' ? 'selected' : ''}>🎵 Musique MoH générale par défaut (en boucle infinie)</option>`;
mohSounds.forEach(s => {
const val = `moh-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>🎵 Musique MoH dédiée : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
html += `</optgroup>`;
const announcements = sounds.filter(s => s.usage_type !== 'moh');
if (announcements.length > 0) {
html += `<optgroup label="📢 Annonces Vocales (Diffuser 1 fois puis raccrocher)">`;
announcements.forEach(s => {
const val = `sound-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Annonce : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
html += `</optgroup>`;
html += `<optgroup label="👵 Piège Scammer / Fausse Voix en boucle infinie (Anti-Spam Tarpit)">`;
announcements.forEach(s => {
const val = `sound-loop-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>👵 Voix en boucle infinie (ex: vieille dame) : ${escapeHtml(s.name)}</option>`;
});
html += `</optgroup>`;
}
html += `
<optgroup label="🚫 Terminer l'appel (Terminate Call)">
<option value="terminate-hangup" ${normVal === 'terminate-hangup' || normVal === 'hangup' ? 'selected' : ''}>Raccrocher immédiatement (Hangup)</option>
<option value="terminate-busy" ${normVal === 'terminate-busy' || normVal === 'busy' ? 'selected' : ''}>Tonalité d'occupation (Busy)</option>
<option value="terminate-congestion" ${normVal === 'terminate-congestion' || normVal === 'congestion' ? 'selected' : ''}>Tonalité de congestion / réseau saturé (Congestion)</option>
<option value="terminate-zapateller" ${normVal === 'terminate-zapateller' || normVal === 'zapateller' ? 'selected' : ''}>Tonalité SIT Numéro non attribué (Anti-Spam / Robot)</option>
<option value="terminate-ringforever" ${normVal === 'terminate-ringforever' || normVal === 'ringforever' ? 'selected' : ''}>Faire sonner indéfiniment (Wait Ringing)</option>
</optgroup>
`;
return html;
}
function updateInboundRoutesBadge() {
const count = document.querySelectorAll('#trunk-did-container .trunk-did-row').length;
const badge = document.getElementById('trunk-inbound-rules-badge');
if (badge) badge.textContent = String(count);
}
function styleDestinationSelect(selectEl) {
if (!selectEl) return;
const val = selectEl.value || '';
if (val.startsWith('terminate-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/40 text-xs text-amber-300 font-semibold';
} else if (val.startsWith('moh-') || val.startsWith('sound-loop-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold';
} else if (val.startsWith('grp-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold';
} else {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold';
}
}
function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', dest = 'ext-101', prepend = false, timeOpts = {}) {
const container = document.getElementById('trunk-did-container');
if (!container) return;
const tcEnabled = Boolean(timeOpts && timeOpts.time_condition_enabled);
const tcStart = (timeOpts && timeOpts.time_start) ? timeOpts.time_start : '08:30';
const tcFinish = (timeOpts && timeOpts.time_finish) ? timeOpts.time_finish : '18:00';
const tcWeekdays = (timeOpts && timeOpts.weekdays) ? timeOpts.weekdays : 'mon-fri';
const tcClosed = (timeOpts && timeOpts.time_dest_closed) ? timeOpts.time_dest_closed : 'terminate-hangup';
const tcCidPrefix = (timeOpts && timeOpts.cid_prefix) ? timeOpts.cid_prefix : '';
const row = document.createElement('div');
row.className = 'trunk-did-row border-b border-anthracite-700/60 transition hover:bg-anthracite-800/40';
row.innerHTML = `
<div class="grid grid-cols-12 gap-3 items-center px-4 py-2.5">
<div class="col-span-3">
<input type="text" placeholder="Any ou _0162XXXXXX" value="${escapeHtml(cid || 'Any')}"
class="trunk-did-cid w-full px-3 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
<input type="hidden" value="${escapeHtml(did || 'Any')}" class="trunk-did-num">
</div>
<div class="col-span-3">
<input type="text" placeholder="ex: Ligne Standard" value="${escapeHtml(description)}"
class="trunk-did-desc w-full px-3 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white">
<div class="time-badge-container mt-1"></div>
</div>
<div class="col-span-4">
<select class="trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white"
onchange="styleDestinationSelect(this)">
${buildDestinationOptionsHtml(dest)}
</select>
</div>
<div class="col-span-2 flex items-center justify-end gap-1.5">
<button type="button" class="btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold border transition" title="Configurer les horaires d'ouverture (Mode Jour / Nuit)">
⏱️ <span class="hidden sm:inline">Horaires</span>
</button>
<button type="button" title="Dupliquer cette règle" class="btn-dup-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-indigo-500/20 text-slate-300 hover:text-indigo-300 border border-anthracite-700 text-xs">⧉</button>
<button type="button" title="Supprimer cette règle" class="btn-del-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-300 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
</div>
</div>
<!-- Tiroir rétractable des conditions horaires (Mode Jour / Nuit) -->
<div class="time-drawer hidden px-4 pb-3.5 pt-2 border-t border-anthracite-700/50 bg-anthracite-950/80 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="trunk-tc-enabled rounded bg-anthracite-900 border-anthracite-600 text-amber-500 focus:ring-0" ${tcEnabled ? 'checked' : ''}>
<span class="text-xs font-bold text-amber-300">Activer la condition horaire (Mode Jour / Nuit)</span>
</label>
<span class="text-[11px] text-slate-400">Si actif, l'appel bascule automatiquement selon l'heure de réception</span>
</div>
<div class="time-fields-grid grid grid-cols-1 sm:grid-cols-12 gap-3 items-center ${tcEnabled ? '' : 'opacity-50 pointer-events-none'}">
<div class="sm:col-span-3">
<label class="block text-[10px] uppercase font-bold text-slate-400 mb-1">Heures d'ouverture</label>
<div class="flex items-center gap-1.5">
<input type="time" class="trunk-tc-start px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white" value="${escapeHtml(tcStart)}">
<span class="text-slate-400 text-xs">à</span>
<input type="time" class="trunk-tc-finish px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white" value="${escapeHtml(tcFinish)}">
</div>
</div>
<div class="sm:col-span-4">
<label class="block text-[10px] uppercase font-bold text-slate-400 mb-1">Jours actifs</label>
<select class="trunk-tc-weekdays w-full px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white">
<option value="mon-fri" ${tcWeekdays === 'mon-fri' ? 'selected' : ''}>Du Lundi au Vendredi (Semaine)</option>
<option value="mon-sat" ${tcWeekdays === 'mon-sat' ? 'selected' : ''}>Du Lundi au Samedi</option>
<option value="*" ${tcWeekdays === '*' ? 'selected' : ''}>Tous les jours (7j/7)</option>
<option value="sat-sun" ${tcWeekdays === 'sat-sun' ? 'selected' : ''}>Week-end uniquement (Sam-Dim)</option>
</select>
</div>
<div class="sm:col-span-5">
<label class="block text-[10px] uppercase font-bold text-red-400 mb-1">Destination si FERMÉ (Hors horaires)</label>
<select class="trunk-tc-closed-dest w-full px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white">
${buildDestinationOptionsHtml(tcClosed)}
</select>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 pt-2 border-t border-anthracite-800 text-xs">
<label class="text-[11px] font-semibold text-slate-300 flex items-center gap-1">
<span>🏷️ Préfixe CID spécifique à cette règle :</span>
</label>
<input type="text" placeholder="ex: [Livebox] (vide = hériter du Trunk)" value="${escapeHtml(tcCidPrefix)}"
class="trunk-did-prefix px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono w-56 focus:outline-none focus:border-indigo-500">
<span class="text-[10px] text-slate-400">Ajouté devant le nom de l'appelant sur le téléphone (laisser vide pour utiliser le préfixe général du Trunk).</span>
</div>
</div>
`;
const selectEl = row.querySelector('.trunk-did-dest');
styleDestinationSelect(selectEl);
const tcCheck = row.querySelector('.trunk-tc-enabled');
const timeDrawer = row.querySelector('.time-drawer');
const timeFields = row.querySelector('.time-fields-grid');
const toggleBtn = row.querySelector('.btn-toggle-time');
const badgeContainer = row.querySelector('.time-badge-container');
const tcStartInput = row.querySelector('.trunk-tc-start');
const tcFinishInput = row.querySelector('.trunk-tc-finish');
const tcWeekdaysSelect = row.querySelector('.trunk-tc-weekdays');
function updateTimeUi() {
const isEnabled = tcCheck.checked;
timeFields.classList.toggle('opacity-50', !isEnabled);
timeFields.classList.toggle('pointer-events-none', !isEnabled);
if (isEnabled) {
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40 transition shadow-sm';
const daysLabel = tcWeekdaysSelect.value === 'mon-fri' ? 'Lun-Ven' : (tcWeekdaysSelect.value === 'mon-sat' ? 'Lun-Sam' : (tcWeekdaysSelect.value === '*' ? '7j/7' : 'Sam-Dim'));
badgeContainer.innerHTML = `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">⏱️ ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})</span>`;
} else {
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-anthracite-950 hover:bg-anthracite-900 text-slate-400 hover:text-white border border-anthracite-700 transition';
badgeContainer.innerHTML = '';
}
}
tcCheck.addEventListener('change', updateTimeUi);
tcStartInput.addEventListener('change', updateTimeUi);
tcFinishInput.addEventListener('change', updateTimeUi);
tcWeekdaysSelect.addEventListener('change', updateTimeUi);
toggleBtn.addEventListener('click', () => {
timeDrawer.classList.toggle('hidden');
});
updateTimeUi();
row.querySelector('.btn-del-route').addEventListener('click', () => {
row.remove();
updateInboundRoutesBadge();
});
row.querySelector('.btn-dup-route').addEventListener('click', () => {
const curCid = row.querySelector('.trunk-did-cid').value;
const curDid = row.querySelector('.trunk-did-num').value;
const curDesc = row.querySelector('.trunk-did-desc').value + ' (Copie)';
const curDest = row.querySelector('.trunk-did-dest').value;
const curTcOpts = {
time_condition_enabled: tcCheck.checked,
time_start: tcStartInput.value,
time_finish: tcFinishInput.value,
weekdays: tcWeekdaysSelect.value,
time_dest_closed: row.querySelector('.trunk-tc-closed-dest').value
};
addTrunkInboundRouteRow(curCid, curDid, curDesc, curDest, false, curTcOpts);
});
if (prepend && container.firstChild) {
container.insertBefore(row, container.firstChild);
} else {
container.appendChild(row);
}
updateInboundRoutesBadge();
}
function addArcepSpamBlockPreset() {
const spamRanges = [
{ cid: '_0162XXXXXX', desc: 'Block 0162 Démarchage' },
{ cid: '_0163XXXXXX', desc: 'Block 0163 Démarchage' },
{ cid: '_0270XXXXXX', desc: 'Block 0270 Démarchage' },
{ cid: '_0271XXXXXX', desc: 'Block 0271 Démarchage' },
{ cid: '_0377XXXXXX', desc: 'Block 0377 Démarchage' },
{ cid: '_0378XXXXXX', desc: 'Block 0378 Démarchage' },
{ cid: '_0424XXXXXX', desc: 'Block 0424 Démarchage' },
{ cid: '_0425XXXXXX', desc: 'Block 0425 Démarchage' },
{ cid: '_0568XXXXXX', desc: 'Block 0568 Démarchage' },
{ cid: '_0569XXXXXX', desc: 'Block 0569 Démarchage' },
{ cid: '_0948XXXXXX', desc: 'Block 0948 Démarchage' },
];
const existingCids = new Set(
Array.from(document.querySelectorAll('#trunk-did-container .trunk-did-cid')).map(el => el.value.trim())
);
let added = 0;
spamRanges.forEach(item => {
if (!existingCids.has(item.cid)) {
addTrunkInboundRouteRow(item.cid, 'Any', item.desc, 'terminate-busy', false);
added++;
}
});
if (added > 0) {
showToast(`${added} plages de démarchage ajoutées en mode « Terminate Call: Occupation » !`, 'success');
} else {
showToast('Toutes les plages anti-démarchage sont déjà présentes dans la liste.', 'info');
}
}
function filterInboundRoutesRows(query) {
const q = String(query || '').toLowerCase().trim();
document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => {
const cid = row.querySelector('.trunk-did-cid')?.value.toLowerCase() || '';
const did = row.querySelector('.trunk-did-num')?.value.toLowerCase() || '';
const desc = row.querySelector('.trunk-did-desc')?.value.toLowerCase() || '';
const dest = row.querySelector('.trunk-did-dest')?.selectedOptions?.[0]?.textContent?.toLowerCase() || '';
const match = !q || cid.includes(q) || did.includes(q) || desc.includes(q) || dest.includes(q);
row.classList.toggle('hidden', !match);
});
}
function syncTrunkHostDomain(hostVal) {
const form = document.getElementById('form-trunk');
if (!form) return;
// Laisse from_domain vide pour qu'il prenne automatiquement la valeur exacte de host
form.elements['from_domain'].value = '';
}
function populateInboundDestDropdown(selectedValue = '101') {
const select = document.getElementById('trunk-inbound-select');
const form = document.getElementById('form-trunk');
if (!select || !form) return;
select.innerHTML = buildDestinationOptionsHtml(selectedValue);
form.elements['inbound_dest'].value = select.value;
}
function syncInboundDestSelect(val) {
const form = document.getElementById('form-trunk');
if (form) {
form.elements['inbound_dest'].value = val;
}
}
function toggleFreePbxImporter() {
const box = document.getElementById('freepbx-importer-box');
if (box) box.classList.toggle('hidden');
}
function parseFreePbxConfig() {
const raw = (document.getElementById('freepbx-paste-input')?.value || '').trim();
const form = document.getElementById('form-trunk');
if (!raw || !form) return;
// 1. Test format Register String : user:secret@host:port/contact
const regMatch = raw.match(/^(?:sip:)?([^:@\s]+):([^@\s]+)@([^:\/\s]+)(?::(\d+))?(?:\/(\S+))?$/i);
if (regMatch) {
const [, user, secret, host, port, contact] = regMatch;
if (!form.elements['name'].value) form.elements['name'].value = host.split('.')[0] + '-trunk';
form.elements['host'].value = host;
form.elements['port'].value = port || 5060;
form.elements['username'].value = user;
form.elements['secret'].value = secret;
form.elements['from_domain'].value = '';
if (contact) form.elements['contact_user'].value = contact;
form.elements['register'].checked = true;
toggleFreePbxImporter();
showToast('Register String FreePBX convertie et appliquée !', 'success');
return;
}
// 2. Format PEER Details (clé=valeur)
const lines = raw.split(/\r?\n/);
let found = 0;
lines.forEach(line => {
const m = line.match(/^\s*([a-zA-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (!m) return;
const key = m[1].toLowerCase();
const val = m[2].trim();
found++;
if (key === 'host') form.elements['host'].value = val;
else if (key === 'port') form.elements['port'].value = val;
else if (key === 'username' || key === 'defaultuser') form.elements['username'].value = val;
else if (key === 'authuser' || key === 'auth') form.elements['auth_username'].value = val;
else if (key === 'secret' || key === 'password') form.elements['secret'].value = val;
else if (key === 'fromuser') form.elements['from_user'].value = val;
else if (key === 'fromdomain') form.elements['from_domain'].value = val;
else if (key === 'context') {
if (val.includes('toheader')) form.elements['context'].value = 'from-pstn-toheader';
else form.elements['context'].value = 'from-external';
}
else if (key === 'outboundproxy') form.elements['outbound_proxy'].value = val;
});
if (found > 0) {
if (!form.elements['name'].value && form.elements['host'].value) {
form.elements['name'].value = form.elements['host'].value.split('.')[0] + '-trunk';
}
toggleFreePbxImporter();
showToast(`${found} paramètres FreePBX convertis vers PJSIP !`, 'success');
} else {
showToast('Format non reconnu. Collez user:pass@host ou host=...', 'error');
}
}
function onTrunkRegistrationModeChange(mode) {
const form = document.getElementById('form-trunk');
if (!form) return;
const hostInput = form.elements['host'];
const hostLabel = document.getElementById('trunk-host-label');
const hostHint = document.getElementById('trunk-host-hint');
const receiveGuide = document.getElementById('trunk-receive-guide');
const regNotice = document.getElementById('trunk-reg-outbound-notice');
if (mode === 'receive') {
if (hostInput) {
hostInput.required = false;
hostInput.placeholder = 'Optionnel (vide = IP découverte dynamiquement au REGISTER du HT813)';
}
if (hostLabel) {
hostLabel.innerHTML = 'Serveur SIP / IP Passerelle <span class="text-emerald-400 font-semibold">(Optionnel en mode Réception)</span>';
}
hostHint?.classList.remove('hidden');
receiveGuide?.classList.remove('hidden');
regNotice?.classList.remove('hidden');
if (form.elements['register']) form.elements['register'].checked = false;
if (form.elements['line_support']) form.elements['line_support'].checked = false;
} else if (mode === 'none') {
if (hostInput) {
hostInput.required = true;
hostInput.placeholder = 'ex: 192.168.1.250 ou sbc.direct.net';
}
if (hostLabel) {
hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * <span class="text-slate-400">(Host / IP)</span>';
}
hostHint?.classList.add('hidden');
receiveGuide?.classList.add('hidden');
regNotice?.classList.remove('hidden');
if (form.elements['register']) form.elements['register'].checked = false;
} else {
// mode === 'send'
if (hostInput) {
hostInput.required = true;
hostInput.placeholder = 'ex: sbc.operateur.net ou siptrunk.ovh.net';
}
if (hostLabel) {
hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * <span class="text-slate-400">(Host / IP)</span>';
}
hostHint?.classList.add('hidden');
receiveGuide?.classList.add('hidden');
regNotice?.classList.add('hidden');
if (form.elements['register']) form.elements['register'].checked = true;
}
}
function applyTrunkArchitecture(arch) {
const form = document.getElementById('form-trunk');
if (!form) return;
const architectures = {
register_auth: {
label: 'Trunk avec Enregistrement (REGISTER + Auth)',
port: 5060,
transport: 'udp',
auth_mode: 'userpass',
registration_mode: 'send',
register: true,
line_support: true,
media_encryption: 'no',
nat_symmetric: true,
qualify_frequency: 60
},
gateway_receive: {
label: 'Passerelle FXO / Analogique (Grandstream HT813, Livebox, ATA)',
port: 5060,
transport: 'udp',
auth_mode: 'userpass',
registration_mode: 'receive',
register: false,
line_support: false,
media_encryption: 'no',
nat_symmetric: true,
qualify_frequency: 60
},
ip_peering: {
label: 'Peering IP / SBC / Passerelle (Sans REGISTER)',
port: 5060,
transport: 'udp',
auth_mode: 'ip',
registration_mode: 'none',
register: false,
line_support: false,
media_encryption: 'no',
nat_symmetric: true,
qualify_frequency: 30
},
tls_srtp: {
label: 'Trunk Sécurisé (TLS 5061 + SRTP SDES)',
port: 5061,
transport: 'tls',
auth_mode: 'userpass',
registration_mode: 'send',
register: true,
line_support: true,
media_encryption: 'sdes',
nat_symmetric: true,
qualify_frequency: 60
}
};
const a = architectures[arch];
if (!a) return;
// Ne jamais écraser le nom, l'hôte, les identifiants ou le from_domain de l'utilisateur !
form.elements['port'].value = a.port;
form.elements['transport'].value = a.transport;
form.querySelectorAll('input[name="auth_mode"]').forEach(r => { r.checked = (r.value === a.auth_mode); });
toggleTrunkAuthFields(a.auth_mode);
if (a.registration_mode) {
form.querySelectorAll('input[name="registration_mode"]').forEach(r => {
r.checked = (r.value === a.registration_mode);
});
onTrunkRegistrationModeChange(a.registration_mode);
}
form.elements['register'].checked = a.register;
form.elements['line_support'].checked = a.line_support;
form.elements['media_encryption'].value = a.media_encryption;
form.elements['nat_symmetric'].checked = a.nat_symmetric;
form.elements['qualify_frequency'].value = a.qualify_frequency;
showToast(`Architecture « ${a.label} » appliquée sans écraser votre serveur ni vos identifiants.`, 'info');
}
function openTrunkModal(trunk = null) {
const form = document.getElementById('form-trunk');
form.reset();
switchTrunkModalTab('general');
document.getElementById('freepbx-importer-box')?.classList.add('hidden');
document.getElementById('modal-trunk-title').textContent = trunk ? `Modifier le Trunk ${trunk.name}` : 'Nouveau Trunk SIP / PJSIP Universel';
form.elements['id'].value = trunk ? trunk.id : '';
form.elements['name'].value = trunk ? trunk.name : '';
form.elements['host'].value = trunk ? (trunk.host ?? '') : '';
form.elements['port'].value = trunk ? (trunk.port ?? 5060) : 5060;
form.elements['outbound_proxy'].value = trunk ? (trunk.outbound_proxy ?? '') : '';
form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp';
const regMode = trunk ? (trunk.registration_mode || (Number(trunk.register) === 1 ? 'send' : 'none')) : 'send';
form.querySelectorAll('input[name="registration_mode"]').forEach(r => {
r.checked = (r.value === regMode);
});
onTrunkRegistrationModeChange(regMode);
form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp';
const authMode = trunk ? (trunk.auth_mode || 'userpass') : 'userpass';
form.querySelectorAll('input[name="auth_mode"]').forEach(r => {
r.checked = (r.value === authMode);
});
toggleTrunkAuthFields(authMode);
form.elements['username'].value = trunk ? (trunk.username ?? '') : '';
form.elements['auth_username'].value = trunk ? (trunk.auth_username ?? '') : '';
form.elements['secret'].value = trunk ? (trunk.secret ?? '') : '';
form.elements['enabled'].checked = trunk ? Number(trunk.enabled ?? 1) === 1 : true;
const uInput = form.elements['username'];
const updateGuide = () => {
const val = uInput.value.trim() || 'Même valeur que Username';
const gUser = document.getElementById('ht813-guide-user');
const gAuth = document.getElementById('ht813-guide-auth');
if (gUser) gUser.textContent = val;
if (gAuth) gAuth.textContent = val;
};
uInput.oninput = updateGuide;
updateGuide();
// Onglet 2 : Registration & Identité SIP
form.elements['register'].checked = trunk ? Number(trunk.register ?? 1) === 1 : true;
form.elements['line_support'].checked = trunk ? Number(trunk.line_support ?? 1) === 1 : true;
form.elements['contact_user'].value = trunk ? (trunk.contact_user ?? '') : '';
form.elements['expiration'].value = trunk ? (trunk.expiration ?? 3600) : 3600;
form.elements['retry_interval'].value = trunk ? (trunk.retry_interval ?? 60) : 60;
form.elements['max_retries'].value = trunk ? (trunk.max_retries ?? 10000) : 10000;
form.elements['forbidden_retry_interval'].value = trunk ? (trunk.forbidden_retry_interval ?? 600) : 600;
form.elements['fatal_retry_interval'].value = trunk ? (trunk.fatal_retry_interval ?? 0) : 0;
form.elements['server_uri'].value = trunk ? (trunk.server_uri ?? '') : '';
form.elements['client_uri'].value = trunk ? (trunk.client_uri ?? '') : '';
form.elements['from_user'].value = trunk ? (trunk.from_user ?? '') : '';
form.elements['from_domain'].value = trunk ? (trunk.from_domain ?? '') : '';
form.elements['outbound_callerid'].value = trunk ? (trunk.outbound_callerid ?? '') : '';
form.elements['send_pai'].checked = trunk ? Number(trunk.send_pai ?? 1) === 1 : true;
form.elements['send_rpid'].checked = trunk ? Number(trunk.send_rpid ?? 0) === 1 : false;
form.elements['send_diversion'].checked = trunk ? Number(trunk.send_diversion ?? 1) === 1 : true;
form.elements['trust_id_inbound'].checked = trunk ? Number(trunk.trust_id_inbound ?? 1) === 1 : true;
// Onglet 3 : Codecs, NAT, Timers, T.38 & Identify
form.elements['codecs'].value = trunk ? (trunk.codecs || 'alaw,ulaw,g722') : 'alaw,ulaw,g722';
form.elements['dtmf_mode'].value = trunk ? (trunk.dtmf_mode || 'rfc4733') : 'rfc4733';
form.elements['media_encryption'].value = trunk ? (trunk.media_encryption || 'no') : 'no';
form.elements['qualify_frequency'].value = trunk ? (trunk.qualify_frequency ?? 60) : 60;
form.elements['rtp_keepalive'].value = trunk ? (trunk.rtp_keepalive ?? 15) : 15;
form.elements['max_channels'].value = trunk ? (trunk.max_channels ?? 0) : 0;
form.elements['timers'].value = trunk ? (trunk.timers || 'yes') : 'yes';
form.elements['t38_udptl'].value = trunk ? (trunk.t38_udptl || 'no') : 'no';
form.elements['language'].value = trunk ? (trunk.language || 'fr') : 'fr';
form.elements['nat_symmetric'].checked = trunk ? Number(trunk.nat_symmetric ?? 1) === 1 : true;
form.elements['direct_media'].checked = trunk ? Number(trunk.direct_media ?? 0) === 1 : false;
form.elements['inband_progress'].checked = trunk ? Number(trunk.inband_progress ?? 0) === 1 : false;
form.elements['fax_detect'].checked = trunk ? Number(trunk.fax_detect ?? 0) === 1 : false;
form.elements['match_ips'].value = trunk ? (trunk.match_ips ?? '') : '';
// Contexte & Destination entrante (Totalement libres + assistés)
form.elements['context'].value = trunk ? (trunk.context || 'from-pstn-toheader') : 'from-pstn-toheader';
if (form.elements['cid_name_prefix']) {
form.elements['cid_name_prefix'].value = trunk ? (trunk.cid_name_prefix || '') : '';
}
populateInboundDestDropdown(trunk ? (trunk.inbound_dest || '101') : '101');
// Musique d'attente (MoH) spécifique à ce Trunk
const mohSelect = document.getElementById('trunk-moh-sound-id');
if (mohSelect) {
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
const curMohId = trunk ? Number(trunk.moh_sound_id || 0) : 0;
let opts = `<option value="0" ${curMohId === 0 ? 'selected' : ''}>🌐 Hériter de la musique générale par défaut</option>`;
mohSounds.forEach(s => {
const isSel = (Number(s.id) === curMohId) ? 'selected' : '';
opts += `<option value="${s.id}" ${isSel}>🎵 ${escapeHtml(s.name)} (${escapeHtml(s.site_category || 'Global')})</option>`;
});
mohSelect.innerHTML = opts;
}
// Onglet 4 : Routage SDA & Directives PJSIP Natives ([endpoint], [registration], [aor])
form.elements['outbound_prefix'].value = trunk ? (trunk.outbound_prefix ?? '') : '';
form.elements['custom_endpoint_params'].value = trunk ? (trunk.custom_endpoint_params ?? '') : '';
form.elements['custom_reg_params'].value = trunk ? (trunk.custom_reg_params ?? '') : '';
form.elements['custom_aor_params'].value = trunk ? (trunk.custom_aor_params ?? '') : '';
const filterInput = document.getElementById('inbound-routes-filter');
if (filterInput) filterInput.value = '';
const didContainer = document.getElementById('trunk-did-container');
if (didContainer) {
didContainer.innerHTML = '';
const rawRoutes = trunk && trunk.did_routes ? trunk.did_routes : [];
let routesList = [];
if (Array.isArray(rawRoutes)) {
routesList = rawRoutes;
} else if (typeof rawRoutes === 'object' && rawRoutes !== null) {
routesList = Object.entries(rawRoutes).map(([did, destination]) => ({
cid: 'Any',
did,
description: `SDA ${did}`,
destination
}));
}
if (routesList.length === 0) {
const defDest = trunk ? (trunk.inbound_dest || 'ext-101') : 'ext-101';
const defDesc = trunk ? `Ligne ${trunk.name}` : 'Route Principale';
addTrunkInboundRouteRow('Any', 'Any', defDesc, defDest, false);
} else {
routesList.forEach(r => {
addTrunkInboundRouteRow(
r.cid || 'Any',
r.did || 'Any',
r.description || '',
r.destination || 'ext-101',
false,
{
time_condition_enabled: Boolean(Number(r.time_condition_enabled)),
time_start: r.time_start || '08:30',
time_finish: r.time_finish || '18:00',
weekdays: r.weekdays || 'mon-fri',
time_dest_closed: r.time_dest_closed || 'terminate-hangup',
cid_prefix: r.cid_prefix || ''
}
);
});
}
}
openModal('modal-trunk');
}
function editTrunk(id, initialTab = 'general') {
const trunk = (appState.trunks || []).find(t => Number(t.id) === Number(id));
if (trunk) {
openTrunkModal(trunk);
if (initialTab && initialTab !== 'general') {
switchTrunkModalTab(initialTab);
}
}
}
async function deleteTrunk(id, optName = null) {
const t = (appState.trunks || []).find(tr => Number(tr.id) === Number(id));
const name = optName || (t ? t.name : 'ce trunk');
if (!confirm(`Supprimer le Trunk SIP ${name} ?`)) return;
try {
const res = await apiCall('delete_trunk', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 3B. Rendu & CRUD Routes Sortantes (Outbound Routes & Failover Trunks) ---
function renderOutboundRoutesTable() {
const tbody = document.getElementById('outbound-routes-tbody');
if (!tbody) return;
const routes = appState.outbound_routes || [];
if (routes.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucune route sortante configurée. Cliquez sur « + Ajout route sortante ».</td></tr>`;
return;
}
tbody.innerHTML = routes.map((r, idx) => {
const trunkSeq = Array.isArray(r.trunk_sequence) ? r.trunk_sequence : [];
const trunkBadges = trunkSeq.length > 0
? trunkSeq.map((t, tIdx) => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg ${tIdx === 0 ? 'bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-bold' : 'bg-anthracite-950 text-slate-300 border border-anthracite-700'} text-xs font-mono">
<span class="text-[10px] text-slate-400">${tIdx + 1}.</span>${escapeHtml(t)}
</span>
`).join(' <span class="text-slate-500 text-xs">→</span> ')
: `<span class="text-amber-400 text-xs font-semibold">⚠️ Aucun Trunk associé</span>`;
const isRestricted = r.permission_mode === 'restricted';
const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : [];
const allowedExts = Array.isArray(r.allowed_extensions) ? r.allowed_extensions : [];
let permBadge = '';
if (!isRestricted) {
permBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">✓ Tous Postes & Groupes</span>`;
} else {
const grpTags = allowedGrps.map(g => `<span class="px-1.5 py-0.2 rounded bg-cyan-500/20 text-cyan-300 text-[11px] font-bold">Grp ${escapeHtml(g)}</span>`).join(' ');
const extTags = allowedExts.length > 0 ? `<span class="text-[11px] text-indigo-300 font-mono">${allowedExts.length} poste(s)</span>` : '';
permBadge = `
<div class="space-y-1">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">Restreint</span>
<div class="flex flex-wrap gap-1">${grpTags} ${extTags}</div>
</div>
`;
}
const patterns = Array.isArray(r.dial_patterns) ? r.dial_patterns : [];
const patCount = patterns.length;
const patPreview = patterns.slice(0, 2).map(p => escapeHtml(p.match_pattern || '')).filter(Boolean).join(', ');
const rTypeBadge = r.route_type === 'emergency'
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">🚑 Urgence</span>`
: (r.route_type === 'intracompany'
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Intra</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">Standard</span>`);
const isFirst = idx === 0;
const isLast = idx === routes.length - 1;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="flex items-center gap-1">
<span class="w-6 h-6 rounded-lg bg-indigo-600/30 border border-indigo-500/40 text-indigo-300 font-mono font-bold text-xs flex items-center justify-center">#${Number(r.priority)}</span>
<div class="flex flex-col gap-0.5">
<button type="button" onclick="reorderOutboundRoute(${Number(r.id)}, 'up')" ${isFirst ? 'disabled class="opacity-20 cursor-not-allowed"' : 'class="hover:text-white text-slate-400"'} title="Monter">▲</button>
<button type="button" onclick="reorderOutboundRoute(${Number(r.id)}, 'down')" ${isLast ? 'disabled class="opacity-20 cursor-not-allowed"' : 'class="hover:text-white text-slate-400"'} title="Descendre">▼</button>
</div>
</div>
</td>
<td class="py-3.5 px-4">
<div class="font-bold text-white text-sm flex items-center gap-2">
<span>${escapeHtml(r.name)}</span>
${rTypeBadge}
</div>
</td>
<td class="py-3.5 px-4 font-mono text-xs text-indigo-300">
${r.outbound_callerid ? escapeHtml(r.outbound_callerid) : '<span class="text-slate-500 italic">CID du poste ou du trunk</span>'}
${Number(r.override_ext_cid) === 1 ? '<span class="ml-1 text-[10px] text-amber-300 font-bold">(Force)</span>' : ''}
</td>
<td class="py-3.5 px-4">
<div class="flex flex-wrap items-center gap-1">${trunkBadges}</div>
</td>
<td class="py-3.5 px-4">${permBadge}</td>
<td class="py-3.5 px-4">
<div class="text-xs font-semibold text-slate-200">${patCount} masque(s)</div>
<div class="text-[11px] font-mono text-slate-400 truncate max-w-[160px]">${patPreview}${patCount > 2 ? '...' : ''}</div>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editOutboundRoute(${Number(r.id)})" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Éditer</button>
<button type="button" onclick="deleteOutboundRoute(${Number(r.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
let activeObAllowedExts = new Set();
let activeObBlockedExts = new Set();
function switchOutboundModalTab(tabKey) {
document.querySelectorAll('.ob-subpanel').forEach(p => p.classList.add('hidden'));
const target = document.getElementById(`ob-panel-${tabKey}`);
if (target) target.classList.remove('hidden');
document.querySelectorAll('.ob-tab-btn').forEach(btn => {
if (btn.dataset.obTab === tabKey) {
btn.className = 'ob-tab-btn px-4 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5';
} else {
btn.className = 'ob-tab-btn px-4 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5';
}
});
}
function openOutboundRouteModal(route = null) {
const form = document.getElementById('form-outbound');
form.reset();
document.getElementById('modal-outbound-title').textContent = route ? `Éditer la Route Sortante : ${route.name}` : 'Ajout Route Sortante';
form.elements['id'].value = route ? route.id : '';
form.elements['name'].value = route ? route.name : '';
form.elements['outbound_callerid'].value = route ? route.outbound_callerid : '';
form.elements['priority'].value = route ? route.priority : ((appState.outbound_routes || []).length + 1);
form.elements['route_password'].value = route ? route.route_password : '';
form.elements['enabled'].checked = route ? Number(route.enabled) === 1 : true;
form.elements['continue_next_route'].checked = route ? Number(route.continue_next_route) === 1 : true;
// Radios
const overrideCid = route ? String(route.override_ext_cid) : '0';
form.querySelectorAll('input[name="override_ext_cid"]').forEach(r => r.checked = (r.value === overrideCid));
const routeType = route ? route.route_type : 'normal';
form.querySelectorAll('input[name="route_type"]').forEach(r => r.checked = (r.value === routeType));
const permMode = route ? route.permission_mode : 'all';
form.querySelectorAll('input[name="permission_mode"]').forEach(r => r.checked = (r.value === permMode));
toggleOutboundPermissionMode(permMode);
const recMode = route ? route.record_mode : 'dontcare';
form.querySelectorAll('input[name="record_mode"]').forEach(r => r.checked = (r.value === recMode));
// Destination sur congestion
const congSel = document.getElementById('ob-congestion-dest-select');
if (congSel) {
congSel.innerHTML = `
<option value="terminate-congestion" ${(!route || route.congestion_dest === 'terminate-congestion') ? 'selected' : ''}>Congestion (Tonalité réseau saturé)</option>
<option value="terminate-busy" ${route?.congestion_dest === 'terminate-busy' ? 'selected' : ''}>Busy (Occupation définitive)</option>
<option value="terminate-hangup" ${route?.congestion_dest === 'terminate-hangup' ? 'selected' : ''}>Hangup (Raccrocher)</option>
`;
}
// Musique d'attente (MOH)
const mohSel = document.getElementById('ob-musicclass-select');
if (mohSel) {
const curMoh = route ? (route.moh_class || 'default') : 'default';
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
let mohOpts = `<option value="default" ${curMoh === 'default' ? 'selected' : ''}>Défaut (Standard)</option>`;
mohSounds.forEach(s => {
const sSlug = slugifyTrunkName(s.site_category || 'global');
const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
if (cName !== 'default') {
mohOpts += `<option value="${cName}" ${curMoh === cName ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
}
});
mohSel.innerHTML = mohOpts;
}
// Séquence Trunks
const tSeqContainer = document.getElementById('ob-trunk-seq-container');
tSeqContainer.innerHTML = '';
const initialTrunks = route && Array.isArray(route.trunk_sequence) && route.trunk_sequence.length > 0
? route.trunk_sequence
: [(appState.trunks?.[0]?.name || '')];
initialTrunks.forEach(tName => addOutboundTrunkSeqRow(tName));
// Dial Patterns
const pContainer = document.getElementById('ob-patterns-container');
pContainer.innerHTML = '';
const initialPatterns = route && Array.isArray(route.dial_patterns) && route.dial_patterns.length > 0
? route.dial_patterns
: [
{ prepend: '', prefix: '', match_pattern: '0[1-59]XXXXXXXX', callerid: '' },
{ prepend: '', prefix: '', match_pattern: '0[67]XXXXXXXX', callerid: '' }
];
initialPatterns.forEach(p => addOutboundPatternRow(p.prepend, p.prefix, p.match_pattern, p.callerid));
updateOutboundPatternsBadge();
// Groupes d'extensions autorisés (Sites & Droits Sortants)
const grpsContainer = document.getElementById('ob-allowed-groups-container');
const allowedGrpsSet = new Set(route && Array.isArray(route.allowed_groups) ? route.allowed_groups.map(String) : []);
const groups = appState.extension_groups || [];
if (groups.length === 0) {
grpsContainer.innerHTML = `<span class="text-slate-500 text-xs col-span-3">Aucun groupe d'extensions configuré. Allez dans « Groupes » > « Groupes d'extensions » pour créer le Groupe Nice, Groupe Paris, etc.</span>`;
} else {
grpsContainer.innerHTML = groups.map(g => {
const isChecked = allowedGrpsSet.has(String(g.id)) || allowedGrpsSet.has(String(g.name));
const mCount = Array.isArray(g.members) ? g.members.length : 0;
return `
<label class="flex items-center gap-2.5 p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700 hover:border-cyan-400 cursor-pointer">
<input type="checkbox" name="ob_allowed_groups[]" value="${escapeHtml(g.name)}" ${isChecked ? 'checked' : ''} class="rounded bg-anthracite-950 border-anthracite-600">
<div class="leading-tight">
<div class="text-xs font-bold text-white">${escapeHtml(g.name)}</div>
<div class="text-[10px] text-cyan-300 font-mono">${mCount} poste(s)${g.description ? ` • ${escapeHtml(g.description)}` : ''}</div>
</div>
</label>
`;
}).join('');
}
// Extension Routing (Allowed Extensions <-> Blocked Extensions)
const allExts = appState.extensions || [];
const routeAllowedExts = route && Array.isArray(route.allowed_extensions) ? route.allowed_extensions.map(String) : null;
activeObAllowedExts = new Set();
activeObBlockedExts = new Set();
allExts.forEach(e => {
const eStr = String(e.extension);
if (routeAllowedExts === null) {
// Par défaut, tous autorisés
activeObAllowedExts.add(eStr);
} else if (routeAllowedExts.includes(eStr)) {
activeObAllowedExts.add(eStr);
} else {
activeObBlockedExts.add(eStr);
}
});
renderOutboundExtensionBoxes();
switchOutboundModalTab('settings');
openModal('modal-outbound');
}
function editOutboundRoute(id) {
const route = (appState.outbound_routes || []).find(r => Number(r.id) === Number(id));
if (route) openOutboundRouteModal(route);
}
async function deleteOutboundRoute(id, optName = null) {
const r = (appState.outbound_routes || []).find(rt => Number(rt.id) === Number(id));
const name = optName || (r ? r.name : 'cette route');
if (!confirm(`Supprimer définitivement la Route Sortante « ${name} » ?`)) return;
try {
const res = await apiCall('delete_outbound_route', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
async function reorderOutboundRoute(id, direction) {
try {
const res = await apiCall('reorder_outbound_route', { id, direction });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
function addOutboundTrunkSeqRow(selectedTrunk = '') {
const container = document.getElementById('ob-trunk-seq-container');
if (!container) return;
const trunks = appState.trunks || [];
const row = document.createElement('div');
row.className = 'flex items-center gap-2 ob-trunk-seq-row';
const optsHtml = trunks.map(t => {
const isSel = (t.name === selectedTrunk) || (!selectedTrunk && trunks.indexOf(t) === 0);
return `<option value="${escapeHtml(t.name)}" ${isSel ? 'selected' : ''}>${escapeHtml(t.name)} (${escapeHtml(t.host)}:${Number(t.port)})</option>`;
}).join('');
const currentCount = container.querySelectorAll('.ob-trunk-seq-row').length + 1;
row.innerHTML = `
<span class="w-7 h-7 rounded-lg bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 font-bold text-xs flex items-center justify-center shrink-0">
${currentCount}
</span>
<select class="ob-trunk-select flex-1 px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-medium">
${optsHtml || '<option value="">Aucun Trunk disponible (Créez un Trunk)</option>'}
</select>
<button type="button" class="btn-del-trunk-row p-2 rounded-lg bg-anthracite-900 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
`;
row.querySelector('.btn-del-trunk-row').addEventListener('click', () => {
if (container.querySelectorAll('.ob-trunk-seq-row').length <= 1) {
showToast('Une route sortante doit avoir au moins un Trunk.', 'error');
return;
}
row.remove();
container.querySelectorAll('.ob-trunk-seq-row').forEach((r, idx) => {
r.querySelector('span').textContent = String(idx + 1);
});
});
container.appendChild(row);
}
function updateOutboundPatternsBadge() {
const count = document.querySelectorAll('#ob-patterns-container .ob-pat-row').length;
const badge = document.getElementById('ob-patterns-count-badge');
if (badge) badge.textContent = String(count);
}
function addOutboundPatternRow(prepend = '', prefix = '', match = '', cid = '') {
const container = document.getElementById('ob-patterns-container');
if (!container) return;
const row = document.createElement('div');
row.className = 'grid grid-cols-12 gap-2 px-4 py-2 items-center hover:bg-anthracite-800/60 transition ob-pat-row';
row.innerHTML = `
<div class="col-span-2">
<input type="text" placeholder="prepend" value="${escapeHtml(prepend)}"
class="ob-pat-prepend w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
</div>
<div class="col-span-2">
<input type="text" placeholder="préfixe |" value="${escapeHtml(prefix)}"
class="ob-pat-prefix w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
</div>
<div class="col-span-5">
<input type="text" placeholder="0[1-59]XXXXXXXX ou X." value="${escapeHtml(match)}" required
class="ob-pat-match w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-emerald-300 font-mono font-semibold">
</div>
<div class="col-span-2">
<input type="text" placeholder="/ CallerID" value="${escapeHtml(cid)}"
class="ob-pat-cid w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-indigo-300 font-mono">
</div>
<div class="col-span-1 text-right">
<button type="button" class="btn-del-pat p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
</div>
`;
row.querySelector('.btn-del-pat').addEventListener('click', () => {
row.remove();
updateOutboundPatternsBadge();
});
container.appendChild(row);
updateOutboundPatternsBadge();
}
function applyOutboundPatternPreset(presetKey) {
if (presetKey === 'fr_national') {
addOutboundPatternRow('', '', '0[1-59]XXXXXXXX', '');
addOutboundPatternRow('', '', '0[67]XXXXXXXX', '');
} else if (presetKey === 'fr_free') {
addOutboundPatternRow('', '', '080[0-9]XXXXXX', '');
} else if (presetKey === 'fr_emergency') {
['15', '17', '18', '112', '114'].forEach(num => addOutboundPatternRow('', '', num, ''));
} else if (presetKey === 'international') {
addOutboundPatternRow('', '', '00.', '');
addOutboundPatternRow('', '', '+X.', '');
} else if (presetKey === 'all') {
addOutboundPatternRow('', '', 'X.', '');
}
}
function toggleOutboundPermissionMode(mode) {
const details = document.getElementById('ob-permissions-details');
if (!details) return;
if (mode === 'restricted') {
details.classList.remove('opacity-40', 'pointer-events-none');
} else {
details.classList.add('opacity-40', 'pointer-events-none');
}
}
function renderOutboundExtensionBoxes() {
const allowedBox = document.getElementById('ob-allowed-exts-box');
const blockedBox = document.getElementById('ob-blocked-exts-box');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
allExts.forEach(e => {
const eStr = String(e.extension);
if (activeObAllowedExts.has(eStr)) {
allowedHtml += `
<button type="button" onclick="obToggleSingleExt('${escapeHtml(eStr)}')"
class="group px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-amber-500/20 border border-emerald-500/30 hover:border-amber-500/40 text-xs font-semibold text-emerald-300 hover:text-amber-300 transition cursor-pointer flex items-center gap-1.5">
<span class="font-mono">${escapeHtml(eStr)}</span>
<span class="text-slate-200">(${escapeHtml(e.name)})</span>
<span class="text-slate-400 group-hover:text-amber-300 ml-1">→</span>
</button>
`;
} else {
blockedHtml += `
<button type="button" onclick="obToggleSingleExt('${escapeHtml(eStr)}')"
class="group px-3 py-1.5 rounded-xl bg-anthracite-950 hover:bg-emerald-500/20 border border-anthracite-700 hover:border-emerald-500/40 text-xs font-medium text-slate-300 hover:text-emerald-300 transition cursor-pointer flex items-center gap-1.5">
<span class="text-slate-400 group-hover:text-emerald-300 mr-1">←</span>
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(eStr)}</span>
<span class="text-slate-300">(${escapeHtml(e.name)})</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste individuel autorisé.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes sont autorisés sur cette route !</div>`;
document.getElementById('ob-allowed-exts-count').textContent = String(activeObAllowedExts.size);
document.getElementById('ob-blocked-exts-count').textContent = String(activeObBlockedExts.size);
}
function obToggleSingleExt(eStr) {
if (activeObAllowedExts.has(eStr)) {
activeObAllowedExts.delete(eStr);
activeObBlockedExts.add(eStr);
} else {
activeObBlockedExts.delete(eStr);
activeObAllowedExts.add(eStr);
}
renderOutboundExtensionBoxes();
}
function obTransferExtensions(action) {
const allExts = appState.extensions || [];
if (action === 'all_allow') {
allExts.forEach(e => {
activeObAllowedExts.add(String(e.extension));
activeObBlockedExts.delete(String(e.extension));
});
} else if (action === 'all_block') {
allExts.forEach(e => {
activeObBlockedExts.add(String(e.extension));
activeObAllowedExts.delete(String(e.extension));
});
} else if (action === 'invert') {
allExts.forEach(e => {
const eStr = String(e.extension);
if (activeObAllowedExts.has(eStr)) {
activeObAllowedExts.delete(eStr);
activeObBlockedExts.add(eStr);
} else {
activeObBlockedExts.delete(eStr);
activeObAllowedExts.add(eStr);
}
});
}
renderOutboundExtensionBoxes();
}
// --- 4. Rendu & CRUD SVI / IVR ---
function renderIvrsTable() {
const tbody = document.getElementById('ivrs-tbody');
const rows = appState.ivrs || [];
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun serveur vocal interactif configuré. Cliquez sur « + Nouveau SVI / IVR » pour en créer un.</td></tr>`;
return;
}
tbody.innerHTML = rows.map(ivr => {
const opts = ivr.options || {};
const optEntries = Object.entries(opts);
const badges = optEntries.map(([k, v]) => {
let label = v;
if (v.startsWith('ext-')) label = `Poste ${v.replace('ext-', '')}`;
else if (v.startsWith('grp-')) label = `Groupe ${v.replace('grp-', '')}`;
else if (v.startsWith('sound-')) label = `Annonce`;
else if (v.startsWith('terminate-')) label = v.replace('terminate-', '');
return `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs font-mono shadow-sm">
<span class="w-4 h-4 rounded bg-indigo-500/20 text-indigo-300 font-bold flex items-center justify-center text-[10px]">${escapeHtml(k)}</span>
<span class="text-slate-400">→</span>
<span class="text-slate-200 font-semibold">${escapeHtml(label)}</span>
</span>`;
}).join(' ');
const directDialBadge = Number(ivr.direct_dial) !== 0
? `<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 text-[11px] font-semibold">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Activée
</span>`
: `<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-slate-500/10 text-slate-400 border border-slate-500/30 text-[11px] font-semibold">Désactivée</span>`;
const testCode = '*8' + String(ivr.id).padStart(2, '0');
// Résolution du libellé de l'annonce audio
let audioName = escapeHtml(ivr.audio_file || 'demo-instruct');
const foundSound = (appState.sounds || []).find(s => `custom/${s.slug}` === ivr.audio_file || s.slug === ivr.audio_file);
if (foundSound) {
audioName = `${escapeHtml(foundSound.name)} <span class="text-[10px] text-slate-400">(${escapeHtml(foundSound.site_category)})</span>`;
}
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="font-bold text-white flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-indigo-400"></span>
<span>${escapeHtml(ivr.name)}</span>
</div>
${ivr.description ? `<div class="text-[11px] text-slate-400 mt-0.5">${escapeHtml(ivr.description)}</div>` : ''}
</td>
<td class="py-3.5 px-4 text-xs font-mono text-slate-200">
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>
</svg>
<span>${audioName}</span>
</div>
</td>
<td class="py-3.5 px-4">${directDialBadge}</td>
<td class="py-3.5 px-4">
<div class="flex flex-wrap gap-1.5 max-w-xs">
${badges || '<span class="text-xs text-slate-400 italic">Aucune touche</span>'}
</div>
</td>
<td class="py-3.5 px-4 text-xs text-slate-300">
<div>⏱️ Attente : <strong class="text-white">${Number(ivr.timeout)}s</strong> <span class="text-slate-400">(${Number(ivr.timeout_retries || 2)} rép)</span></div>
<div class="text-[11px] text-slate-400 mt-0.5">⚠️ Erreur : max <strong class="text-slate-200">${Number(ivr.invalid_retries || 3)}</strong> essais</div>
</td>
<td class="py-3.5 px-4">
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-indigo-500/10 text-indigo-300 border border-indigo-500/30 text-xs font-mono font-bold" title="Composer ce code depuis un poste interne pour tester le SVI">
<svg class="w-3 h-3 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
${testCode}
</span>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editIvr(${Number(ivr.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200 transition">Modifier</button>
<button type="button" onclick="deleteIvr(${Number(ivr.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300 transition">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
function addIvrOptionRow(digit = '', dest = '') {
const container = document.getElementById('ivr-options-container');
const row = document.createElement('div');
row.className = 'flex items-center gap-2.5 p-2 rounded-xl bg-anthracite-950/80 border border-anthracite-700/80 ivr-opt-row transition hover:border-indigo-500/50';
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '*', '#'];
let digitOptions = digits.map(d => `<option value="${d}" ${String(digit) === d ? 'selected' : ''}>Touche ${d}</option>`).join('');
row.innerHTML = `
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-xs text-slate-400 font-mono">Touche :</span>
<select class="ivr-digit px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs font-bold text-indigo-300 font-mono focus:outline-none focus:border-indigo-500">
${digitOptions}
</select>
</div>
<span class="text-slate-400 font-bold">→</span>
<div class="flex-1 min-w-0">
<select class="ivr-dest w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-indigo-500">
${buildDestinationOptionsHtml(dest || 'ext-101')}
</select>
</div>
<button type="button" class="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition" title="Supprimer cette touche">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
`;
row.querySelector('button').addEventListener('click', () => row.remove());
container.appendChild(row);
}
function openIvrModal(ivr = null) {
const form = document.getElementById('form-ivr');
form.reset();
document.getElementById('modal-ivr-title').textContent = ivr ? `Modifier le SVI « ${ivr.name} »` : 'Nouveau Serveur Vocal Interactif (SVI / IVR)';
form.elements['id'].value = ivr ? ivr.id : '';
form.elements['name'].value = ivr ? ivr.name : 'ivr-accueil';
form.elements['description'].value = ivr && ivr.description ? ivr.description : '';
form.elements['timeout'].value = ivr ? Number(ivr.timeout) : 5;
form.elements['timeout_retries'].value = ivr ? Number(ivr.timeout_retries || 2) : 2;
form.elements['invalid_retries'].value = ivr ? Number(ivr.invalid_retries || 3) : 3;
// Numérotation directe (défaut activé si nouveau ou si direct_dial == 1)
const directDialCheck = document.getElementById('ivr-direct-dial-checkbox');
if (directDialCheck) {
directDialCheck.checked = ivr ? Boolean(Number(ivr.direct_dial)) : true;
}
// Sélecteur audio d'accueil (audio_file)
const audioSel = document.getElementById('ivr-audio-select');
if (audioSel) {
const curAudio = ivr ? String(ivr.audio_file || '') : '';
const sounds = appState.sounds || [];
let audioHtml = '';
if (sounds.length > 0) {
audioHtml += `<optgroup label="📂 Fichiers sons de la médiathèque">`;
sounds.forEach(s => {
const val = `custom/${s.slug}`;
const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id);
audioHtml += `<option value="${escapeHtml(val)}" ${isSel ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)} • ${escapeHtml(s.usage_type)}]</option>`;
});
audioHtml += `</optgroup>`;
}
audioHtml += `
<optgroup label="🔔 Messages système Asterisk par défaut">
<option value="demo-instruct" ${curAudio === 'demo-instruct' || !curAudio ? 'selected' : ''}>demo-instruct (Instructions d'accueil)</option>
<option value="hello-world" ${curAudio === 'hello-world' ? 'selected' : ''}>hello-world (Message d'accueil basique)</option>
<option value="beep" ${curAudio === 'beep' ? 'selected' : ''}>beep (Simple bip sonore)</option>
</optgroup>
`;
audioSel.innerHTML = audioHtml;
}
// Sélecteur message d'erreur invalide (invalid_sound_id)
const invalidSoundSel = document.getElementById('ivr-invalid-sound-select');
if (invalidSoundSel) {
const curInvId = ivr ? Number(ivr.invalid_sound_id || 0) : 0;
let invSoundHtml = `<option value="0" ${curInvId === 0 ? 'selected' : ''}>Message par défaut (pbx-invalid : « Choix invalide »)</option>`;
(appState.sounds || []).forEach(s => {
invSoundHtml += `<option value="${s.id}" ${curInvId === Number(s.id) ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
invalidSoundSel.innerHTML = invSoundHtml;
}
// Sélecteur destination d'échec invalide (invalid_dest)
const invalidDestSel = document.getElementById('ivr-invalid-dest-select');
if (invalidDestSel) {
invalidDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.invalid_dest || 'terminate-hangup') : 'terminate-hangup');
}
// Sélecteur message timeout (timeout_sound_id)
const timeoutSoundSel = document.getElementById('ivr-timeout-sound-select');
if (timeoutSoundSel) {
const curToutId = ivr ? Number(ivr.timeout_sound_id || 0) : 0;
let toutSoundHtml = `<option value="0" ${curToutId === 0 ? 'selected' : ''}>Aucun message supplémentaire (rejouer annonce)</option>`;
(appState.sounds || []).forEach(s => {
toutSoundHtml += `<option value="${s.id}" ${curToutId === Number(s.id) ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
timeoutSoundSel.innerHTML = toutSoundHtml;
}
// Sélecteur destination d'échec timeout (timeout_dest)
const timeoutDestSel = document.getElementById('ivr-timeout-dest-select');
if (timeoutDestSel) {
timeoutDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.timeout_dest || 'terminate-hangup') : 'terminate-hangup');
}
// Conteneur des touches DTMF
const container = document.getElementById('ivr-options-container');
container.innerHTML = '';
const opts = ivr && ivr.options && Object.keys(ivr.options).length > 0
? ivr.options
: { '1': 'ext-101', '2': 'grp-600' };
Object.entries(opts).forEach(([d, dest]) => addIvrOptionRow(d, dest));
openModal('modal-ivr');
}
function editIvr(id) {
const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id));
if (ivr) openIvrModal(ivr);
}
async function deleteIvr(id, optName = null) {
const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id));
const name = optName || (ivr ? ivr.name : 'ce SVI');
if (!confirm(`Supprimer le SVI ${name} ?`)) return;
try {
const res = await apiCall('delete_ivr', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 4B. Rendu & CRUD Médiathèque & Fichiers Sons (MoH, Annonces, IVR, Congestion) ---
const USAGE_LABELS = {
moh: { label: "Musique d'attente (MoH)", color: "amber" },
announcement: { label: "Annonce d'accueil / Info", color: "indigo" },
ivr: { label: "Menu SVI / Guide interactif", color: "cyan" },
congestion: { label: "Ligne occupée / Congestion", color: "rose" },
custom: { label: "Autre / Personnalisé", color: "slate" }
};
function formatDurationSec(sec) {
const s = Math.max(0, parseInt(sec, 10) || 0);
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}:${rem.toString().padStart(2, '0')}`;
}
function formatBytes(bytes) {
const b = parseInt(bytes, 10) || 0;
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
return `${(b / (1024 * 1024)).toFixed(1)} Mo`;
}
function renderSoundsTable() {
const tbody = document.getElementById('sounds-tbody');
if (!tbody) return;
const allSounds = appState.sounds || [];
const soundSites = appState.sound_sites || [];
// Met à jour la liste des sites dans le filtre (sans icône)
const siteFilterEl = document.getElementById('filter-sound-site');
if (siteFilterEl) {
const curVal = siteFilterEl.value || 'all';
let siteOpts = `<option value="all">Tous les sites</option>`;
soundSites.forEach(st => {
const isSel = (st.name === curVal) ? 'selected' : '';
siteOpts += `<option value="${escapeHtml(st.name)}" ${isSel}>${escapeHtml(st.name)}</option>`;
});
siteFilterEl.innerHTML = siteOpts;
}
// Met à jour le sélecteur de la Musique d'attente générale par défaut (MoH)
const defMohSelect = document.getElementById('global-default-moh-select');
if (defMohSelect) {
const mohSounds = allSounds.filter(s => s.usage_type === 'moh');
const curDefaultId = Number(appState.settings?.default_moh_sound_id || 0);
if (mohSounds.length === 0) {
defMohSelect.innerHTML = `<option value="">Aucune musique d'attente disponible</option>`;
} else {
defMohSelect.innerHTML = mohSounds.map(s => {
const isSel = (Number(s.id) === curDefaultId) ? 'selected' : '';
return `<option value="${s.id}" ${isSel}>${escapeHtml(s.name)} [${escapeHtml(s.site_category || 'Global')}]</option>`;
}).join('');
}
}
// Récupération des filtres actifs
const selSite = siteFilterEl ? siteFilterEl.value : 'all';
const selUsage = document.getElementById('filter-sound-usage')?.value || 'all';
const searchTxt = (document.getElementById('filter-sound-search')?.value || '').toLowerCase().trim();
const filtered = allSounds.filter(s => {
if (selSite !== 'all' && s.site_category !== selSite) return false;
if (selUsage !== 'all' && s.usage_type !== selUsage) return false;
if (searchTxt) {
const hay = `${s.name} ${s.description} ${s.slug} ${s.site_category} ${s.usage_type}`.toLowerCase();
if (!hay.includes(searchTxt)) return false;
}
return true;
});
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun fichier son trouvé pour les filtres sélectionnés. Cliquez sur « + Téléverser un Fichier Son ».</td></tr>`;
return;
}
const curDefaultMohId = Number(appState.settings?.default_moh_sound_id || 0);
tbody.innerHTML = filtered.map(snd => {
const usage = USAGE_LABELS[snd.usage_type] || USAGE_LABELS.custom;
const siteBadge = `<span class="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">${escapeHtml(snd.site_category || 'Global')}</span>`;
const usageBadge = `<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-anthracite-950 border border-anthracite-700 text-slate-200">${escapeHtml(usage.label || snd.usage_type)}</span>`;
const defaultBadge = (Number(snd.id) === curDefaultMohId)
? `<span class="ml-2 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">Par défaut</span>`
: '';
let astRef = `custom/${escapeHtml(snd.slug)}`;
if (snd.usage_type === 'moh') {
const sSlug = slugifyTrunkName(snd.site_category || 'global');
astRef = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
}
const audioUrl = `/api.php?action=stream_sound&id=${Number(snd.id)}`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="font-bold text-white text-sm flex items-center">
<span>${escapeHtml(snd.name)}</span>
${defaultBadge}
</div>
<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(snd.description || '—')}</div>
</td>
<td class="py-3.5 px-4">${siteBadge}</td>
<td class="py-3.5 px-4">${usageBadge}</td>
<td class="py-3.5 px-4">
<audio controls preload="none" class="h-8 w-48 sm:w-56 rounded bg-anthracite-900 border border-anthracite-700" src="${audioUrl}"></audio>
</td>
<td class="py-3.5 px-4 font-mono text-xs text-slate-300 whitespace-nowrap">
<div>${formatDurationSec(snd.duration_sec)}</div>
<div class="text-[10px] text-slate-500">${formatBytes(snd.file_size)} • ${escapeHtml((snd.format || 'wav').toUpperCase())}</div>
</td>
<td class="py-3.5 px-4">
<div class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-anthracite-950 border border-anthracite-700 font-mono text-xs text-amber-300">
<span>${astRef}</span>
<button type="button" onclick="navigator.clipboard.writeText('${astRef}'); showToast('Code copié : ${astRef}', 'info');" title="Copier la référence Asterisk" class="text-slate-400 hover:text-white text-[11px] font-sans underline">Copier</button>
</div>
</td>
<td class="py-3.5 px-4 text-right space-x-2 whitespace-nowrap">
<button type="button" onclick="editSound(${Number(snd.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300">Modifier</button>
<button type="button" onclick="deleteSound(${Number(snd.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
async function applyDefaultMohFromSelect() {
const sel = document.getElementById('global-default-moh-select');
if (!sel || !sel.value) {
showToast("Veuillez sélectionner un fichier son pour la musique d'attente par défaut.", 'error');
return;
}
try {
const res = await apiCall('set_default_moh', { sound_id: Number(sel.value) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- Gestion des catégories de sites (Création, Modification, Suppression) ---
function openSoundSitesModal() {
const form = document.getElementById('form-sound-site');
if (form) form.reset();
const idInput = document.getElementById('sound-site-id');
if (idInput) idInput.value = '';
const btn = document.getElementById('btn-save-sound-site');
if (btn) btn.textContent = 'Enregistrer';
renderSoundSitesTable();
openModal('modal-sound-sites');
}
function renderSoundSitesTable() {
const tbody = document.getElementById('sound-sites-tbody');
if (!tbody) return;
const sites = appState.sound_sites || [];
const sounds = appState.sounds || [];
if (sites.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" class="py-6 text-center text-slate-400">Aucune catégorie de site définie.</td></tr>`;
return;
}
tbody.innerHTML = sites.map(st => {
const count = sounds.filter(s => (s.site_category || '').toLowerCase() === (st.name || '').toLowerCase()).length;
const isGlobal = (st.name || '').toLowerCase() === 'global';
const deleteBtn = isGlobal
? `<span class="text-slate-600 text-xs italic" title="Socle standard du système, non supprimable">Protégé</span>`
: `<button type="button" onclick="deleteSoundSite(${Number(st.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>`;
return `
<tr class="hover:bg-anthracite-900/50 transition">
<td class="py-2.5 px-4 font-semibold text-white">
${escapeHtml(st.name)}
${isGlobal ? '<span class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700">Système</span>' : ''}
</td>
<td class="py-2.5 px-4 text-slate-300">${escapeHtml(st.description || '—')}</td>
<td class="py-2.5 px-4 text-slate-300">
<span class="px-2 py-0.5 rounded-full text-xs font-mono bg-anthracite-900 border border-anthracite-700">${count} fichier${count > 1 ? 's' : ''}</span>
</td>
<td class="py-2.5 px-4 text-right space-x-2 whitespace-nowrap">
<button type="button" onclick="editSoundSite(${Number(st.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300">Modifier</button>
${deleteBtn}
</td>
</tr>
`;
}).join('');
}
function editSoundSite(id) {
const site = (appState.sound_sites || []).find(s => Number(s.id) === Number(id));
if (!site) return;
const idInput = document.getElementById('sound-site-id');
const nameInput = document.getElementById('sound-site-name');
const descInput = document.getElementById('sound-site-desc');
const btn = document.getElementById('btn-save-sound-site');
if (idInput) idInput.value = site.id;
if (nameInput) {
nameInput.value = site.name;
nameInput.focus();
}
if (descInput) descInput.value = site.description || '';
if (btn) btn.textContent = 'Mettre à jour';
}
async function deleteSoundSite(id, optName = null) {
const st = (appState.sound_sites || []).find(s => Number(s.id) === Number(id));
const name = optName || (st ? st.name : 'ce site');
if (!confirm(`Supprimer la catégorie de site « ${name} » ?\nTous les sons associés seront automatiquement réassignés à « Global ».`)) return;
try {
const res = await apiCall('delete_sound_site', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
renderSoundSitesTable();
} catch (err) {
showToast(err.message, 'error');
}
}
function handleSoundFileSelected(input) {
const file = input.files?.[0];
const placeEl = document.getElementById('sound-upload-placeholder');
const prevEl = document.getElementById('sound-upload-preview');
const nameEl = document.getElementById('sound-upload-filename');
const sizeEl = document.getElementById('sound-upload-filesize');
const nameInput = document.getElementById('sound-input-name');
if (file) {
placeEl?.classList.add('hidden');
prevEl?.classList.remove('hidden');
if (nameEl) nameEl.textContent = file.name;
if (sizeEl) sizeEl.textContent = formatBytes(file.size);
if (nameInput && !nameInput.value.trim()) {
const cleanName = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]+/g, ' ');
nameInput.value = cleanName.charAt(0).toUpperCase() + cleanName.slice(1);
}
} else {
placeEl?.classList.remove('hidden');
prevEl?.classList.add('hidden');
}
}
function openSoundModal(snd = null) {
const form = document.getElementById('form-sound');
form.reset();
document.getElementById('modal-sound-title').textContent = snd ? `Modifier le fichier « ${snd.name} »` : "Téléverser un Fichier Son";
document.getElementById('btn-submit-sound').textContent = snd ? "Enregistrer les Modifications" : "Téléverser & Enregistrer";
form.elements['id'].value = snd ? snd.id : '';
form.elements['name'].value = snd ? snd.name : '';
form.elements['usage_type'].value = snd ? snd.usage_type : 'announcement';
form.elements['description'].value = snd ? snd.description : '';
// Remplit la liste déroulante des catégories de sites
const siteSelect = document.getElementById('sound-input-site');
if (siteSelect) {
const sites = appState.sound_sites || [];
const curSite = snd ? snd.site_category : 'Global';
siteSelect.innerHTML = sites.map(st => {
const isSel = (st.name === curSite) ? 'selected' : '';
return `<option value="${escapeHtml(st.name)}" ${isSel}>${escapeHtml(st.name)}</option>`;
}).join('');
if (curSite && !sites.some(st => st.name === curSite)) {
siteSelect.insertAdjacentHTML('afterbegin', `<option value="${escapeHtml(curSite)}" selected>${escapeHtml(curSite)}</option>`);
}
}
const uploadBlock = document.getElementById('sound-file-upload-block');
const fileInput = document.getElementById('sound-audio-file-input');
const placeEl = document.getElementById('sound-upload-placeholder');
const prevEl = document.getElementById('sound-upload-preview');
if (snd) {
if (uploadBlock) uploadBlock.classList.add('hidden');
if (fileInput) fileInput.removeAttribute('required');
} else {
if (uploadBlock) uploadBlock.classList.remove('hidden');
if (fileInput) fileInput.setAttribute('required', 'required');
placeEl?.classList.remove('hidden');
prevEl?.classList.add('hidden');
}
openModal('modal-sound');
}
function editSound(id) {
const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id));
if (snd) openSoundModal(snd);
}
async function deleteSound(id, optName = null) {
const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id));
const name = optName || (snd ? snd.name : 'ce fichier son');
if (!confirm(`Supprimer définitivement le fichier son « ${name} » et ses encodages Asterisk ?`)) return;
try {
const res = await apiCall('delete_sound', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 5. Rendu Paramètres, Diagnostic Debian & Prévisualisations .conf ---
function renderSettingsAndDiagnostics() {
const s = appState.settings || {};
const form = document.getElementById('settings-form');
if (form) {
if (form.elements['company_name']) {
form.elements['company_name'].value = s.company_name || '';
}
if (form.elements['system_language']) {
form.elements['system_language'].value = s.system_language || 'fr';
}
form.elements['domain'].value = s.domain || 'pbx.local';
form.elements['letsencrypt_domain'].value = s.letsencrypt_domain || '';
form.elements['local_net'].value = s.local_net || '10.10.0.0/16';
form.elements['external_ip'].value = s.external_ip || '';
form.elements['udp_port'].value = s.udp_port || 5060;
form.elements['tls_port'].value = s.tls_port || 5061;
form.elements['ami_host'].value = s.ami_host || '127.0.0.1';
form.elements['ami_port'].value = s.ami_port || 5038;
form.elements['ami_user'].value = s.ami_user || 'pbxgui';
form.elements['ami_secret'].value = s.ami_secret || '';
form.elements['new_admin_password'].value = '';
}
const diagList = document.getElementById('diagnostics-list');
const checks = appState.diagnostics?.checks || [];
diagList.innerHTML = checks.map(c => `
<div class="flex items-start gap-3 p-3 rounded-xl bg-anthracite-950 border ${c.ok ? 'border-anthracite-700' : 'border-amber-500/40'}">
<span class="mt-0.5 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${c.ok ? 'bg-emerald-500/20 text-emerald-300' : 'bg-amber-500/20 text-amber-300'}">
${c.ok ? '✓' : '!'}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-white">${escapeHtml(c.label)}</div>
<div class="text-[11px] text-slate-300 break-words mt-0.5">${escapeHtml(c.detail)}</div>
</div>
</div>
`).join('');
document.getElementById('preview-pjsip').textContent = appState.previews?.pjsip || '';
document.getElementById('preview-extensions').textContent = appState.previews?.extensions || '';
const prevVm = document.getElementById('preview-voicemail');
if (prevVm) {
prevVm.textContent = appState.previews?.voicemail || '';
}
}
// --- Navigation Sidebar & Modales ---
const PAGE_TITLES = {
dashboard: 'Tableau de Bord & Supervision Temps Réel',
extensions: 'Postes Téléphoniques SIP (PJSIP)',
groups: "Groupes de Sonnerie (Ring Groups)",
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
paging: 'Groupes de Téléavertissement & Interphonie (Paging)',
trunks: 'Trunks Opérateurs SIP',
outbound: 'Routes Sortantes (Outbound Routes & Séquence Trunks)',
ivrs: 'Serveurs Vocaux Interactifs (SVI / IVR)',
sounds: 'Fichiers sons',
featurecodes: 'Codes de Fonctions & Services Téléphoniques Asterisk',
sipsettings: 'Paramètres SIP Globaux & Configuration NAT (PJSIP & RTP)',
certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)',
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)'
};
function switchTab(tabId) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
const target = document.getElementById(`tab-${tabId}`);
if (target) target.classList.remove('hidden');
const titleEl = document.getElementById('page-header-title');
if (titleEl && PAGE_TITLES[tabId]) {
titleEl.textContent = PAGE_TITLES[tabId];
}
document.querySelectorAll('.tab-btn').forEach(btn => {
const isSubBtn = btn.parentElement?.id === 'submenu-operators' || btn.parentElement?.id === 'submenu-groups' || btn.parentElement?.id === 'submenu-system';
if (btn.dataset.tab === tabId) {
if (isSubBtn) {
btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-bold bg-indigo-600 text-white shadow-sm transition text-left';
} else {
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-semibold bg-indigo-600 text-white shadow-md shadow-indigo-600/20 transition';
}
} else {
if (isSubBtn) {
btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left';
} else {
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition';
}
}
});
// Si on clique sur un sous-menu Opérateurs, s'assurer que le menu parent est bien déplié
if (tabId === 'trunks' || tabId === 'outbound') {
const submenu = document.getElementById('submenu-operators');
const arrow = document.getElementById('arrow-menu-operators');
if (submenu && submenu.classList.contains('hidden')) {
submenu.classList.remove('hidden');
arrow?.classList.remove('-rotate-90');
}
}
// Si on clique sur un sous-menu Groupes, s'assurer que le menu parent est bien déplié
if (tabId === 'groups' || tabId === 'extgroups') {
const submenu = document.getElementById('submenu-groups');
const arrow = document.getElementById('arrow-menu-groups');
if (submenu && submenu.classList.contains('hidden')) {
submenu.classList.remove('hidden');
arrow?.classList.remove('-rotate-90');
}
}
// Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall') {
const submenu = document.getElementById('submenu-system');
const arrow = document.getElementById('arrow-menu-system');
if (submenu && submenu.classList.contains('hidden')) {
submenu.classList.remove('hidden');
arrow?.classList.remove('-rotate-90');
}
}
// Si onglet Sauvegardes, charger la liste des sauvegardes et la configuration
if (tabId === 'backups') {
loadBackups();
}
// Si onglet Pare-feu, charger le statut en direct
if (tabId === 'firewall') {
loadFirewallStatus();
}
toggleMobileSidebar(false);
}
function toggleSystemMenu() {
const submenu = document.getElementById('submenu-system');
const arrow = document.getElementById('arrow-menu-system');
if (!submenu) return;
const isHidden = submenu.classList.toggle('hidden');
if (arrow) {
if (isHidden) {
arrow.classList.add('-rotate-90');
} else {
arrow.classList.remove('-rotate-90');
}
}
}
function toggleGroupsMenu() {
const submenu = document.getElementById('submenu-groups');
const arrow = document.getElementById('arrow-menu-groups');
if (!submenu) return;
const isHidden = submenu.classList.toggle('hidden');
if (arrow) {
if (isHidden) {
arrow.classList.add('-rotate-90');
} else {
arrow.classList.remove('-rotate-90');
}
}
}
function toggleOperatorsMenu() {
const submenu = document.getElementById('submenu-operators');
const arrow = document.getElementById('arrow-menu-operators');
if (!submenu) return;
const isHidden = submenu.classList.toggle('hidden');
if (arrow) {
if (isHidden) {
arrow.classList.add('-rotate-90');
} else {
arrow.classList.remove('-rotate-90');
}
}
}
function toggleMobileSidebar(open) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
if (!sidebar || !backdrop) return;
if (open) {
sidebar.classList.remove('-translate-x-full');
backdrop.classList.remove('hidden');
} else {
sidebar.classList.add('-translate-x-full');
backdrop.classList.add('hidden');
}
}
function openModal(id) {
const el = document.getElementById(id);
if (el) {
el.classList.remove('hidden');
el.classList.add('flex');
}
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) {
el.classList.add('hidden');
el.classList.remove('flex');
}
}
// --- Écouteurs d'événements DOM ---
document.addEventListener('DOMContentLoaded', () => {
// Login AJAX
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = Object.fromEntries(new FormData(e.target).entries());
try {
const res = await apiCall('login', formData);
showToast(res.message, 'success');
showAppScreen();
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Logout AJAX
document.getElementById('btn-logout')?.addEventListener('click', async () => {
await apiCall('logout', {});
showLoginScreen();
showToast('Vous êtes déconnecté.', 'info');
});
// Form Extension
document.getElementById('form-extension')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.encryption = Number(document.getElementById('ext-encryption')?.value ?? 1);
payload.member_extension_groups = Array.from(e.target.querySelectorAll('input[name="member_extension_groups[]"]:checked')).map(c => c.value);
payload.voicemail_enabled = document.getElementById('ext-voicemail-enabled')?.checked ? 1 : 0;
payload.force_pin_change = (payload.pin_mode === 'first_call') ? 1 : 0;
payload.voicemail_skip_pin = document.getElementById('ext-skip-pin')?.checked ? 1 : 0;
payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0;
payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0;
payload.ring_time = Number(payload.ring_time || 20);
try {
const res = await apiCall('save_extension', payload);
showToast(res.message, 'success');
closeModal('modal-extension');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Groupe de Sonnerie (Ring Groups)
document.getElementById('form-group')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.members = Array.from(e.target.querySelectorAll('input[name="member_ext"]:checked')).map(c => c.value);
try {
const res = await apiCall('save_group', payload);
showToast(res.message, 'success');
closeModal('modal-group');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Groupe d'Extensions (Sites & Droits Sortants)
document.getElementById('form-extgroup')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.members = Array.from(activeExtGroupSelectedExts);
try {
const res = await apiCall('save_extension_group', payload);
showToast(res.message, 'success');
closeModal('modal-extgroup');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Groupe de Téléavertissement (Paging)
document.getElementById('form-paging')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.members = Array.from(activePagingSelectedExts);
payload.duplex = Number(payload.duplex || 0);
payload.timeout = Number(payload.timeout || 120);
payload.play_beep = e.target.querySelector('input[name="play_beep"]')?.checked ? 1 : 0;
payload.enabled = e.target.querySelector('input[name="enabled"]')?.checked ? 1 : 0;
try {
const res = await apiCall('save_paging_group', payload);
showToast(res.message, 'success');
closeModal('modal-paging');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Trunk (Configuration PJSIP complète)
document.getElementById('form-trunk')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const payload = Object.fromEntries(fd.entries());
payload.registration_mode = form.querySelector('input[name="registration_mode"]:checked')?.value || 'send';
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
payload.register = form.elements['register'].checked ? 1 : 0;
payload.line_support = form.elements['line_support'].checked ? 1 : 0;
payload.send_pai = form.elements['send_pai'].checked ? 1 : 0;
payload.send_rpid = form.elements['send_rpid'].checked ? 1 : 0;
payload.send_diversion = form.elements['send_diversion'].checked ? 1 : 0;
payload.trust_id_inbound = form.elements['trust_id_inbound'].checked ? 1 : 0;
payload.nat_symmetric = form.elements['nat_symmetric'].checked ? 1 : 0;
payload.direct_media = form.elements['direct_media'].checked ? 1 : 0;
payload.inband_progress = form.elements['inband_progress'].checked ? 1 : 0;
payload.fax_detect = form.elements['fax_detect'].checked ? 1 : 0;
const didRoutes = [];
document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => {
const cid = row.querySelector('.trunk-did-cid')?.value.trim() || 'Any';
const did = row.querySelector('.trunk-did-num')?.value.trim() || 'Any';
const desc = row.querySelector('.trunk-did-desc')?.value.trim() || '';
const dest = row.querySelector('.trunk-did-dest')?.value.trim() || '';
const tcEnabled = row.querySelector('.trunk-tc-enabled')?.checked ? 1 : 0;
const tcStart = row.querySelector('.trunk-tc-start')?.value || '08:30';
const tcFinish = row.querySelector('.trunk-tc-finish')?.value || '18:00';
const weekdays = row.querySelector('.trunk-tc-weekdays')?.value || 'mon-fri';
const tcClosed = row.querySelector('.trunk-tc-closed-dest')?.value || 'terminate-hangup';
const cidPrefix = row.querySelector('.trunk-did-prefix')?.value.trim() || '';
if (dest) {
didRoutes.push({
cid,
did,
description: desc,
destination: dest,
cid_prefix: cidPrefix,
time_condition_enabled: tcEnabled,
time_start: tcStart,
time_finish: tcFinish,
weekdays: weekdays,
time_dest_closed: tcClosed
});
}
});
payload.did_routes = didRoutes;
try {
const res = await apiCall('save_trunk', payload);
showToast(res.message, 'success');
closeModal('modal-trunk');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Outbound Route
document.getElementById('form-outbound')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const payload = Object.fromEntries(fd.entries());
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
payload.continue_next_route = form.elements['continue_next_route'].checked ? 1 : 0;
payload.override_ext_cid = form.querySelector('input[name="override_ext_cid"]:checked')?.value === '1' ? 1 : 0;
payload.route_type = form.querySelector('input[name="route_type"]:checked')?.value || 'normal';
payload.permission_mode = form.querySelector('input[name="permission_mode"]:checked')?.value || 'all';
payload.record_mode = form.querySelector('input[name="record_mode"]:checked')?.value || 'dontcare';
// Séquence de Trunks
const trunks = [];
document.querySelectorAll('#ob-trunk-seq-container .ob-trunk-select').forEach(sel => {
const val = sel.value.trim();
if (val && !trunks.includes(val)) trunks.push(val);
});
payload.trunk_sequence = trunks;
// Dial Patterns
const patterns = [];
document.querySelectorAll('#ob-patterns-container .ob-pat-row').forEach(row => {
const prep = row.querySelector('.ob-pat-prepend')?.value.trim() || '';
const pfx = row.querySelector('.ob-pat-prefix')?.value.trim() || '';
const mtch = row.querySelector('.ob-pat-match')?.value.trim() || '';
const cid = row.querySelector('.ob-pat-cid')?.value.trim() || '';
if (mtch) {
patterns.push({
prepend: prep,
prefix: pfx,
match_pattern: mtch,
callerid: cid
});
}
});
payload.dial_patterns = patterns;
// Groupes autorisés
payload.allowed_groups = Array.from(form.querySelectorAll('input[name="ob_allowed_groups[]"]:checked')).map(c => c.value);
// Extensions autorisées (depuis le drag/transfert interactif)
payload.allowed_extensions = Array.from(activeObAllowedExts);
try {
const res = await apiCall('save_outbound_route', payload);
showToast(res.message, 'success');
closeModal('modal-outbound');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form IVR
document.getElementById('form-ivr')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.direct_dial = document.getElementById('ivr-direct-dial-checkbox')?.checked ? 1 : 0;
const options = {};
document.querySelectorAll('#ivr-options-container .ivr-opt-row').forEach(row => {
const d = (row.querySelector('.ivr-digit')?.value || '').trim();
const dest = (row.querySelector('.ivr-dest')?.value || '').trim();
if (d && dest) options[d] = dest;
});
payload.options = options;
try {
const res = await apiCall('save_ivr', payload);
showToast(res.message, 'success');
closeModal('modal-ivr');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Fichier Son (Upload multipart / Update)
document.getElementById('form-sound')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const id = form.elements['id'].value;
const submitBtn = document.getElementById('btn-submit-sound');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = "Traitement audio en cours...";
}
try {
let res;
if (id) {
const payload = Object.fromEntries(fd.entries());
res = await apiCall('update_sound', payload);
} else {
const response = await fetch('/api.php?action=upload_sound', {
method: 'POST',
body: fd
});
const cType = response.headers.get('content-type') || '';
if (cType.includes('application/json')) {
res = await response.json();
} else {
const rawText = await response.text();
if (response.status === 413) {
throw new Error("Le fichier audio est trop volumineux pour le serveur (Limite 64 Mo).");
}
throw new Error(`Erreur serveur (${response.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`);
}
if (!response.ok || !res.success) {
throw new Error(res.message || 'Erreur lors du téléversement du fichier audio.');
}
}
showToast(res.message, 'success');
closeModal('modal-sound');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = id ? "Enregistrer les Modifications" : "Téléverser & Enregistrer";
}
}
});
// Form Catégories de sites
document.getElementById('form-sound-site')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const payload = Object.fromEntries(new FormData(form).entries());
try {
const res = await apiCall('save_sound_site', payload);
showToast(res.message, 'success');
form.reset();
const idInput = document.getElementById('sound-site-id');
if (idInput) idInput.value = '';
const btn = document.getElementById('btn-save-sound-site');
if (btn) btn.textContent = 'Enregistrer';
await loadState();
renderSoundSitesTable();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Settings
document.getElementById('settings-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const payload = Object.fromEntries(new FormData(e.target).entries());
payload.wizard_completed = 1;
try {
const res = await apiCall('save_settings', payload);
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Bouton Tester AMI
document.getElementById('btn-test-ami')?.addEventListener('click', async () => {
try {
const res = await apiCall('test_ami');
showToast(res.message, 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
// Bouton Appliquer les configurations (Écriture /etc/asterisk + Reload AMI)
document.getElementById('btn-apply-config')?.addEventListener('click', async () => {
const icon = document.getElementById('apply-icon');
icon?.classList.add('animate-spin');
try {
const res = await apiCall('apply_config', {});
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
icon?.classList.remove('animate-spin');
}
});
// Formulaire Let's Encrypt
document.getElementById('form-cert-letsencrypt')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-submit-letsencrypt');
const spinner = document.getElementById('spinner-letsencrypt');
const btnText = document.getElementById('btn-text-letsencrypt');
const msgBox = document.getElementById('letsencrypt-msg-box');
const formData = new FormData(f);
const payload = {
name: (formData.get('name') || '').trim(),
domain: (formData.get('domain') || '').trim(),
email: (formData.get('email') || '').trim(),
is_default: formData.get('is_default') === '1' ? 1 : 0,
staging: formData.get('staging') === '1' ? 1 : 0
};
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
if (btnText) btnText.textContent = "Communication avec Let's Encrypt...";
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
try {
const res = await apiCall('request_letsencrypt_cert', payload);
showToast(res.message, 'success');
closeModal('modal-cert-letsencrypt');
await loadState();
} catch (err) {
if (msgBox) {
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
msgBox.textContent = "Erreur Let's Encrypt :\n" + err.message;
}
showToast("Échec de l'émission Let's Encrypt", 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
if (btnText) btnText.textContent = "Lancer l'émission ACME";
}
});
// Formulaire Import Certificat Custom (supporte FormData multipart pour fichiers .crt, .key)
document.getElementById('form-cert-custom')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-submit-custom-cert');
const spinner = document.getElementById('spinner-custom-cert');
const msgBox = document.getElementById('custom-cert-msg-box');
const formData = new FormData(f);
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
try {
const resp = await fetch('/api.php?action=save_custom_cert', {
method: 'POST',
body: formData
});
const data = await resp.json();
if (!data.success) {
throw new Error(data.message || 'Erreur lors de l\'enregistrement du certificat');
}
showToast(data.message, 'success');
closeModal('modal-cert-custom');
await loadState();
} catch (err) {
if (msgBox) {
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
msgBox.textContent = "Erreur de validation du certificat :\n" + err.message;
}
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
});
// Lecture automatique des fichiers .crt, .key dans les textareas lors de la sélection
const bindFileReader = (fileInputId, textareaId) => {
const input = document.getElementById(fileInputId);
const textarea = document.getElementById(textareaId);
if (input && textarea) {
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (re) => { textarea.value = re.target.result; };
reader.readAsText(file);
}
});
}
};
bindFileReader('file-cert-custom', 'textarea-cert-pem');
bindFileReader('file-key-custom', 'textarea-key-pem');
bindFileReader('file-chain-custom', 'textarea-chain-pem');
// Formulaire Paramètres SIP & NAT
document.getElementById('form-sip-settings')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-save-sip-settings');
const spinner = document.getElementById('spinner-save-sip-settings');
const netInputs = f.querySelectorAll('input[name="local_networks[]"]');
const localNets = Array.from(netInputs).map(i => i.value.trim()).filter(v => v !== '');
const payload = {
external_ip: (document.getElementById('sip-external-ip')?.value || '').trim(),
external_udp_port: Number(document.getElementById('sip-external-udp-port')?.value || 0),
external_tls_port: Number(document.getElementById('sip-external-tls-port')?.value || 0),
udp_port: Number(document.getElementById('sip-udp-port')?.value || 5060),
tls_port: Number(document.getElementById('sip-tls-port')?.value || 5061),
rtp_start: Number(document.getElementById('sip-rtp-start')?.value || 10000),
rtp_end: Number(document.getElementById('sip-rtp-end')?.value || 20000),
rtp_strict: Number(document.getElementById('sip-rtp-strict')?.value || 1),
rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1),
rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15),
rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30),
allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0),
local_networks: localNets
};
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
try {
const res = await apiCall('save_sip_settings', payload);
showToast(res.message, 'success');
const container = document.getElementById('local-networks-container');
if (container) delete container.dataset.initialized;
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
});
// Formulaire Édition d'un Code de Fonction
document.getElementById('form-edit-feature-code')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('btn-submit-edit-fc');
const spinner = document.getElementById('spinner-edit-fc');
const id = Number(document.getElementById('edit-fc-id')?.value || 0);
const code = (document.getElementById('edit-fc-code')?.value || '').trim();
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
try {
const res = await apiCall('update_feature_code', { id, code });
showToast(res.message, 'success');
closeModal('modal-edit-feature-code');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
});
// Formulaire Paramètres de Sauvegarde & SSH
document.getElementById('backup-settings-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const autoEnabled = f.elements['backup_auto_enabled']?.checked ? 1 : 0;
const frequency = f.elements['backup_frequency']?.value || 'daily';
const retention = Number(f.elements['backup_retention']?.value || 7);
const storageType = f.querySelector('input[name="backup_storage_type"]:checked')?.value || 'local';
const sshHost = (f.elements['backup_ssh_host']?.value || '').trim();
const sshPort = Number(f.elements['backup_ssh_port']?.value || 22);
const sshUser = (f.elements['backup_ssh_user']?.value || '').trim();
const sshPassword = f.elements['backup_ssh_password']?.value || '';
const sshRemotePath = (f.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim();
const payload = {
backup_auto_enabled: autoEnabled,
backup_frequency: frequency,
backup_retention: retention,
backup_storage_type: storageType,
backup_ssh_host: sshHost,
backup_ssh_port: sshPort,
backup_ssh_user: sshUser,
backup_ssh_auth_type: 'password',
backup_ssh_password: sshPassword,
backup_ssh_remote_path: sshRemotePath
};
try {
const res = await apiCall('save_backup_settings', payload);
showToast(res.message, 'success');
await loadBackups();
} catch (err) {
showToast("Erreur enregistrement : " + err.message, 'error');
}
});
// Formulaire Paramètres Pare-feu & Fail2ban
document.getElementById('firewall-settings-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-save-firewall-settings');
if (btn) btn.disabled = true;
const payload = {
firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0,
admin_https_port: Number(f.elements['admin_https_port']?.value || 8443),
certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0,
fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3),
fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600),
fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim()
};
try {
const res = await apiCall('save_firewall_settings', payload);
showToast(res.message, 'success');
await loadFirewallStatus();
} catch (err) {
showToast("Erreur application sécurité : " + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
});
// Formulaire Bannissement Manuel Rapide
document.getElementById('form-manual-ban')?.addEventListener('submit', async (e) => {
e.preventDefault();
const ip = (document.getElementById('manual-ban-ip')?.value || '').trim();
const jail = document.getElementById('manual-ban-jail')?.value || 'asterisk';
if (!ip) return;
try {
const res = await apiCall('ban_ip', { ip, jail });
showToast(res.message, res.success ? 'success' : 'error');
document.getElementById('manual-ban-ip').value = '';
await loadFirewallStatus();
} catch (err) {
showToast("Erreur lors du bannissement : " + err.message, 'error');
}
});
// Si déjà authentifié au chargement initial
if (!document.getElementById('app-screen').classList.contains('hidden')) {
loadState();
}
});
// =========================================================================
// GESTIONNAIRE DE CERTIFICATS SSL / TLS (FONCTIONS GLOBALES)
// =========================================================================
function renderCertificates() {
const certs = appState.certificates || [];
const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1) || certs[0];
// Badge sidebar
const badgeSidebar = document.getElementById('nav-badge-certificates');
if (badgeSidebar) {
badgeSidebar.textContent = certs.length;
}
const badgeTable = document.getElementById('certs-count-badge');
if (badgeTable) {
badgeTable.textContent = `${certs.length} certificat${certs.length > 1 ? 's' : ''}`;
}
// Carte Résumé Actif
if (activeCert) {
const nameEl = document.getElementById('active-cert-name');
const domEl = document.getElementById('active-cert-domain');
const issEl = document.getElementById('active-cert-issuer');
const typeEl = document.getElementById('active-cert-type-badge');
const daysEl = document.getElementById('active-cert-days');
const renewWrap = document.getElementById('active-cert-renew-wrapper');
if (nameEl) nameEl.textContent = activeCert.name || 'Certificat Actif';
if (domEl) domEl.textContent = activeCert.primary_domain || 'pbx.local';
if (issEl) issEl.textContent = activeCert.issuer || 'Inconnu';
if (typeEl) {
if (activeCert.type === 'letsencrypt') {
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40';
typeEl.textContent = "Let's Encrypt (ACME Auto)";
} else if (activeCert.type === 'custom') {
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40';
typeEl.textContent = "Certificat Entreprise (Import)";
} else {
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
typeEl.textContent = "Auto-signé Système";
}
}
if (daysEl) {
const days = Number(activeCert.days_remaining ?? 0);
if (days < 0) {
daysEl.className = 'text-xs font-bold text-rose-400 mt-0.5';
daysEl.textContent = `Expiré (${Math.abs(days)}j)`;
} else if (days <= 15) {
daysEl.className = 'text-xs font-bold text-amber-400 mt-0.5';
daysEl.textContent = `${days} j restants (À renouveler)`;
} else {
daysEl.className = 'text-xs font-bold text-emerald-400 mt-0.5';
daysEl.textContent = `${days} j restants`;
}
}
if (renewWrap) {
if (activeCert.type === 'letsencrypt') {
renewWrap.classList.remove('hidden');
} else {
renewWrap.classList.add('hidden');
}
}
}
// Statut Mutual TLS (mTLS) verify_client
const settings = appState.settings || {};
const mtlsVal = Number(settings.tls_verify_client ?? 0);
const selMtls = document.getElementById('select-tls-verify-client');
const badgeMtls = document.getElementById('badge-mtls-status');
if (selMtls) {
selMtls.value = String(mtlsVal);
}
if (badgeMtls) {
if (mtlsVal === 1) {
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40';
badgeMtls.textContent = 'Activé (Strict / mTLS)';
} else {
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
badgeMtls.textContent = 'Désactivé (Standard)';
}
}
// Tableau des certificats
const tbody = document.getElementById('certificates-tbody');
if (!tbody) return;
if (certs.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="py-8 text-center text-slate-400">
Aucun certificat enregistré. Cliquez sur « Générer via Let's Encrypt » ou « Importer un Certificat ».
</td>
</tr>
`;
return;
}
tbody.innerHTML = certs.map(c => {
const isDefault = Number(c.is_default) === 1;
const days = Number(c.days_remaining ?? 0);
let typeBadge = '';
if (c.type === 'letsencrypt') {
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">Let's Encrypt</span>`;
} else if (c.type === 'custom') {
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Import Custom</span>`;
} else {
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600">Auto-signé</span>`;
}
let validityBadge = '';
if (days < 0) {
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/40">Expiré (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
} else if (days <= 15) {
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
} else {
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
}
let defaultBadge = '';
if (isDefault) {
defaultBadge = `
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold text-[11px]">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span>ACTIF PAR DÉFAUT</span>
</div>
`;
} else {
defaultBadge = `
<button type="button" onclick="setDefaultCertificate(${c.id})"
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-emerald-600 hover:text-white text-slate-300 border border-anthracite-600 font-semibold text-xs transition">
Activer
</button>
`;
}
let actions = '<div class="flex items-center justify-end gap-1.5">';
if (c.type === 'letsencrypt') {
actions += `
<button type="button" onclick="renewCertificate(${c.id})" title="Renouveler ce certificat Let's Encrypt"
class="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/20 border border-emerald-500/30 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
`;
}
if (!isDefault) {
actions += `
<button type="button" onclick="deleteCertificate(${c.id}, '${escapeHtml(c.name)}')" title="Supprimer ce certificat"
class="p-1.5 rounded-lg text-rose-400 hover:bg-rose-500/20 border border-rose-500/30 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
`;
}
actions += '</div>';
const sanDisplay = c.alt_names ? `<div class="text-[10px] text-slate-400 font-mono mt-0.5 truncate max-w-xs" title="${escapeHtml(c.alt_names)}">+ ${escapeHtml(c.alt_names)}</div>` : '';
return `
<tr class="hover:bg-anthracite-900/50 transition">
<td class="py-3 px-4 font-semibold text-white">
<div class="flex items-center gap-2">
<span>${escapeHtml(c.name)}</span>
${isDefault ? '<span class="text-emerald-400" title="Actif en production">★</span>' : ''}
</div>
</td>
<td class="py-3 px-4">${typeBadge}</td>
<td class="py-3 px-4 font-mono text-slate-200">
<div class="font-bold text-white">${escapeHtml(c.primary_domain)}</div>
${sanDisplay}
</td>
<td class="py-3 px-4 text-slate-300 font-medium">${escapeHtml(c.issuer || '-')}</td>
<td class="py-3 px-4">${validityBadge}</td>
<td class="py-3 px-4 text-center">${defaultBadge}</td>
<td class="py-3 px-4 text-right">${actions}</td>
</tr>
`;
}).join('');
}
function openLetsEncryptModal() {
const f = document.getElementById('form-cert-letsencrypt');
if (f) f.reset();
const box = document.getElementById('letsencrypt-msg-box');
if (box) { box.className = 'hidden'; box.textContent = ''; }
if (appState.settings && appState.settings.domain && appState.settings.domain !== 'pbx.local') {
const domInput = f?.querySelector('input[name="domain"]');
if (domInput && !domInput.value) domInput.value = appState.settings.domain;
}
openModal('modal-cert-letsencrypt');
}
function openCustomCertModal() {
const f = document.getElementById('form-cert-custom');
if (f) f.reset();
const box = document.getElementById('custom-cert-msg-box');
if (box) { box.className = 'hidden'; box.textContent = ''; }
openModal('modal-cert-custom');
}
async function setDefaultCertificate(id) {
if (!confirm("Voulez-vous activer ce certificat comme certificat par défaut ?\n\nIl sera immédiatement utilisé pour le transport PJSIP TLS (port 5061) et le chiffrement des communications audio (SRTP).")) {
return;
}
try {
const res = await apiCall('set_default_cert', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
async function renewCertificate(id) {
if (!confirm("Lancer le renouvellement ACME Let's Encrypt pour ce certificat ?")) {
return;
}
try {
showToast("Renouvellement Let's Encrypt en cours...", 'info');
const res = await apiCall('renew_cert', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast("Erreur de renouvellement : " + err.message, 'error');
}
}
async function renewCertificateFromBanner() {
const certs = appState.certificates || [];
const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1);
if (activeCert && activeCert.id) {
await renewCertificate(activeCert.id);
}
}
async function deleteCertificate(id, name) {
if (!confirm(`Confirmez-vous la suppression du certificat « ${name} » ?\nCette action est irréversible.`)) {
return;
}
try {
const res = await apiCall('delete_cert', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
async function saveTlsSecurityPolicy() {
const sel = document.getElementById('select-tls-verify-client');
const val = Number(sel?.value ?? 0);
const label = val === 1
? 'Activer le mode Mutual TLS strict (certificat client X.509 obligatoirement exigé sur chaque téléphone)'
: 'Désactiver le mode Mutual TLS (standard : authentification par mot de passe SIP dans le tunnel TLS)';
if (!confirm(`Confirmez-vous cette modification de la politique TLS ?\n\n${label}\n\nAsterisk rechargera immédiatement le transport TLS 5061.`)) {
return;
}
try {
const res = await apiCall('set_tls_security', { tls_verify_client: val });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// =========================================================================
// PARAMÈTRES SIP GLOBAUX & NAT (FONCTIONS GLOBALES)
// =========================================================================
function renderSipSettings() {
const s = appState.settings || {};
const extIpEl = document.getElementById('sip-external-ip');
if (extIpEl && document.activeElement !== extIpEl) {
extIpEl.value = s.external_ip || '';
}
const extUdpPortEl = document.getElementById('sip-external-udp-port');
if (extUdpPortEl && document.activeElement !== extUdpPortEl) {
extUdpPortEl.value = (s.external_udp_port && Number(s.external_udp_port) > 0) ? s.external_udp_port : '';
}
const extTlsPortEl = document.getElementById('sip-external-tls-port');
if (extTlsPortEl && document.activeElement !== extTlsPortEl) {
extTlsPortEl.value = (s.external_tls_port && Number(s.external_tls_port) > 0) ? s.external_tls_port : '';
}
const udpPortEl = document.getElementById('sip-udp-port');
if (udpPortEl && document.activeElement !== udpPortEl) {
udpPortEl.value = s.udp_port || 5060;
}
const tlsPortEl = document.getElementById('sip-tls-port');
if (tlsPortEl && document.activeElement !== tlsPortEl) {
tlsPortEl.value = s.tls_port || 5061;
}
const rtpStartEl = document.getElementById('sip-rtp-start');
if (rtpStartEl && document.activeElement !== rtpStartEl) {
rtpStartEl.value = s.rtp_start || 10000;
}
const rtpEndEl = document.getElementById('sip-rtp-end');
if (rtpEndEl && document.activeElement !== rtpEndEl) {
rtpEndEl.value = s.rtp_end || 20000;
}
const rtpStrictEl = document.getElementById('sip-rtp-strict');
if (rtpStrictEl) {
rtpStrictEl.value = (s.rtp_strict !== undefined && s.rtp_strict !== null) ? String(s.rtp_strict) : '1';
}
const rtpChecksumsEl = document.getElementById('sip-rtp-checksums');
if (rtpChecksumsEl) {
rtpChecksumsEl.value = (s.rtp_checksums !== undefined && s.rtp_checksums !== null) ? String(s.rtp_checksums) : '1';
}
const rtpKeepaliveEl = document.getElementById('sip-rtp-keepalive');
if (rtpKeepaliveEl && document.activeElement !== rtpKeepaliveEl) {
rtpKeepaliveEl.value = (s.rtp_keepalive !== undefined && s.rtp_keepalive !== null) ? s.rtp_keepalive : 15;
}
const rtpTimeoutEl = document.getElementById('sip-rtp-timeout');
if (rtpTimeoutEl && document.activeElement !== rtpTimeoutEl) {
rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30;
}
const allowAnonEl = document.getElementById('sip-allow-anonymous');
if (allowAnonEl) {
allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0';
}
// Réseaux locaux (Local Networks list)
const container = document.getElementById('local-networks-container');
if (container && !container.dataset.initialized) {
container.innerHTML = '';
const nets = Array.isArray(s.local_networks) && s.local_networks.length > 0
? s.local_networks
: (s.local_net ? [s.local_net] : ['10.10.0.0/16']);
nets.forEach(net => addLocalNetworkField(net));
container.dataset.initialized = 'true';
}
}
function addLocalNetworkField(value = '') {
const container = document.getElementById('local-networks-container');
if (!container) return;
const row = document.createElement('div');
row.className = 'flex items-center gap-2 local-network-row';
row.innerHTML = `
<div class="relative flex-1">
<input type="text" name="local_networks[]" value="${escapeHtml(value)}" placeholder="ex: 10.10.0.0/16 ou 192.168.1.0/24"
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
</div>
<button type="button" onclick="removeLocalNetworkField(this)" title="Supprimer ce réseau"
class="p-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-400 hover:text-rose-400 hover:border-rose-500/50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
`;
container.appendChild(row);
}
function removeLocalNetworkField(btn) {
const container = document.getElementById('local-networks-container');
if (!container) return;
const rows = container.querySelectorAll('.local-network-row');
if (rows.length <= 1) {
showToast("Au moins un réseau local doit être défini.", "error");
return;
}
btn.closest('.local-network-row')?.remove();
}
async function detectPublicIp() {
const btn = document.getElementById('btn-detect-ip');
const spinner = document.getElementById('spinner-detect-ip');
const input = document.getElementById('sip-external-ip');
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
try {
const res = await apiCall('detect_external_ip');
if (res.ip && input) {
input.value = res.ip;
showToast(`IP publique détectée avec succès : ${res.ip}`, 'success');
} else {
showToast("Impossible de déterminer l'adresse IP publique automatiquement.", "error");
}
} catch (err) {
showToast("Erreur de détection : " + err.message, "error");
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
}
// =========================================================================
// CODES DE FONCTIONS TÉLÉPHONIQUES & SERVICES (FONCTIONS GLOBALES)
// =========================================================================
let currentFeatureCategory = 'all';
function filterFeatureCodes(category) {
currentFeatureCategory = category;
document.querySelectorAll('.fc-filter-btn').forEach(btn => {
if (btn.dataset.category === category) {
btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-indigo-600 text-white transition';
} else {
btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-anthracite-800 text-slate-300 hover:text-white transition';
}
});
renderFeatureCodes();
}
function renderFeatureCodes() {
const list = appState.feature_codes || [];
const tbody = document.getElementById('feature-codes-table-body');
const badge = document.getElementById('nav-badge-featurecodes');
const countAll = document.getElementById('fc-count-all');
const activeCount = list.filter(f => Number(f.enabled) === 1).length;
if (badge) badge.textContent = String(activeCount);
if (countAll) countAll.textContent = String(list.length);
if (!tbody) return;
const filtered = (currentFeatureCategory === 'all')
? list
: list.filter(item => item.category === currentFeatureCategory);
if (filtered.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="text-center py-8 text-slate-400 text-xs">
Aucun code de fonction trouvé dans cette catégorie.
</td>
</tr>
`;
return;
}
const categoryBadges = {
forward: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30">Renvois</span>',
pickup: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">BLF / Capture</span>',
dnd: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">Ne Pas Déranger</span>',
voicemail: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Messagerie</span>',
security: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">Sécurité</span>',
tools: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">Outils</span>'
};
tbody.innerHTML = filtered.map(fc => {
const isEnabled = Number(fc.enabled) === 1;
const catBadge = categoryBadges[fc.category] || `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-anthracite-700 text-slate-300">${fc.category}</span>`;
const codeDisplay = escapeHtml(fc.code);
return `
<tr class="hover:bg-anthracite-750 transition group">
<td class="py-3 px-4 font-mono font-bold text-sm text-indigo-300">
<span class="px-2.5 py-1 rounded-lg bg-indigo-500/15 border border-indigo-500/30 inline-block">${codeDisplay}</span>
</td>
<td class="py-3 px-4">
<div class="font-semibold text-white text-xs">${escapeHtml(fc.name)}</div>
<div class="text-[10px] text-slate-400 font-mono">Standard : ${escapeHtml(fc.default_code)}</div>
</td>
<td class="py-3 px-4 text-xs text-slate-300 max-w-xs hidden sm:table-cell leading-relaxed">
${escapeHtml(fc.description)}
</td>
<td class="py-3 px-4">
${catBadge}
</td>
<td class="py-3 px-4 text-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" ${isEnabled ? 'checked' : ''} onchange="toggleFeatureCode(${fc.id}, this.checked)" class="sr-only peer">
<div class="w-9 h-5 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-emerald-600"></div>
</label>
</td>
<td class="py-3 px-4 text-right">
<button type="button" onclick="openEditFeatureCodeModal(${fc.id})"
class="p-1.5 rounded-lg bg-anthracite-700 hover:bg-indigo-600 text-slate-300 hover:text-white transition" title="Personnaliser ce code">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
</td>
</tr>
`;
}).join('');
}
async function toggleFeatureCode(id, enabled) {
try {
const res = await apiCall('toggle_feature_code', { id: Number(id), enabled: enabled ? 1 : 0 });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
await loadState();
}
}
function openEditFeatureCodeModal(id) {
const list = appState.feature_codes || [];
const item = list.find(f => Number(f.id) === Number(id));
if (!item) return;
document.getElementById('edit-fc-id').value = item.id;
document.getElementById('edit-fc-code').value = item.code;
document.getElementById('modal-fc-service-name').textContent = item.name;
document.getElementById('modal-fc-default-code').textContent = item.default_code;
openModal('modal-edit-feature-code');
}
async function resetFeatureCodes() {
if (!confirm("Voulez-vous réinitialiser tous les codes de fonctions aux valeurs standard FreePBX / Asterisk ?\n\n(ex: ** pour BLF, *8 pour groupe, *72/*73 pour renvois, *78/*79 pour DND, *97 pour boîte vocale)")) {
return;
}
try {
const res = await apiCall('reset_feature_codes');
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// =========================================================================
// GESTIONNAIRE DE SAUVEGARDES & RESTAURATION (DISASTER RECOVERY)
// =========================================================================
let appBackupsState = {
settings: {},
backups: []
};
async function loadBackups() {
try {
const res = await apiCall('get_backup_data', {});
if (res.success) {
appBackupsState.settings = res.settings || {};
appBackupsState.backups = res.backups || [];
renderBackups();
}
} catch (err) {
showToast("Erreur chargement des sauvegardes : " + err.message, 'error');
}
}
function renderBackups() {
const s = appBackupsState.settings || {};
const backups = appBackupsState.backups || [];
// 1. Stats d'en-tête
const lastRunEl = document.getElementById('backup-stat-last-run');
if (lastRunEl) {
lastRunEl.textContent = s.backup_last_run ? s.backup_last_run : 'Jamais exécutée';
}
const storageEl = document.getElementById('backup-stat-storage');
if (storageEl) {
if (s.backup_storage_type === 'ssh') {
storageEl.textContent = `Distant SSH (${s.backup_ssh_host || 'non configuré'})`;
storageEl.className = 'text-sm font-bold text-purple-400 truncate';
} else {
storageEl.textContent = 'Stockage Local';
storageEl.className = 'text-sm font-bold text-emerald-400 truncate';
}
}
const countEl = document.getElementById('backup-stat-count');
if (countEl) {
countEl.textContent = `${backups.length} archive(s)`;
}
const badgeSidebar = document.getElementById('nav-badge-backups');
if (badgeSidebar) {
badgeSidebar.textContent = backups.length;
}
// 2. Remplissage du formulaire de configuration
const form = document.getElementById('backup-settings-form');
if (form) {
if (form.elements['backup_auto_enabled']) {
form.elements['backup_auto_enabled'].checked = Number(s.backup_auto_enabled) === 1;
}
if (form.elements['backup_frequency']) {
form.elements['backup_frequency'].value = s.backup_frequency || 'daily';
}
if (form.elements['backup_retention']) {
form.elements['backup_retention'].value = s.backup_retention || 7;
}
const storageRadios = form.querySelectorAll('input[name="backup_storage_type"]');
storageRadios.forEach(r => {
r.checked = (r.value === (s.backup_storage_type || 'local'));
});
if (form.elements['backup_ssh_host']) {
form.elements['backup_ssh_host'].value = s.backup_ssh_host || '';
}
if (form.elements['backup_ssh_port']) {
form.elements['backup_ssh_port'].value = s.backup_ssh_port || 22;
}
if (form.elements['backup_ssh_user']) {
form.elements['backup_ssh_user'].value = s.backup_ssh_user || '';
}
if (form.elements['backup_ssh_password']) {
form.elements['backup_ssh_password'].value = s.backup_ssh_password || '';
}
if (form.elements['backup_ssh_remote_path']) {
form.elements['backup_ssh_remote_path'].value = s.backup_ssh_remote_path || '/backups/nexus';
}
toggleSshBackupFields((s.backup_storage_type || 'local') === 'ssh');
}
// 3. Remplissage du tableau des archives
const tbody = document.getElementById('backups-table-body');
if (!tbody) return;
if (backups.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="px-4 py-8 text-center text-slate-400">
<div class="flex flex-col items-center justify-center gap-2">
<svg class="w-8 h-8 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/>
</svg>
<span>Aucune archive de sauvegarde disponible. Cliquez sur "Créer une Sauvegarde Immédiate" pour générer la première.</span>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = backups.map(b => {
const isLocal = b.is_local;
const locBadge = isLocal
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Local</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Distant (SSH)</span>`;
const versionBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-mono font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v${escapeHtml(b.version || '1.2.0')}</span>`;
return `
<tr class="hover:bg-anthracite-750 transition">
<td class="px-4 py-3 font-mono font-medium text-white">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="truncate max-w-xs sm:max-w-md">${escapeHtml(b.filename)}</span>
${versionBadge}
</div>
</td>
<td class="px-4 py-3 text-slate-300 whitespace-nowrap">${escapeHtml(b.date_formatted)}</td>
<td class="px-4 py-3 font-mono text-emerald-300 whitespace-nowrap">${escapeHtml(b.size_formatted)}</td>
<td class="px-4 py-3 whitespace-nowrap">${locBadge}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1.5">
<button type="button" onclick="downloadBackup('${escapeHtml(b.filename)}')"
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-indigo-600 text-slate-200 hover:text-white transition flex items-center gap-1 font-semibold text-xs" title="Télécharger l'archive">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span>Télécharger</span>
</button>
<button type="button" onclick="confirmRestoreBackup('${escapeHtml(b.filename)}')"
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-emerald-600 text-slate-200 hover:text-white transition flex items-center gap-1 font-semibold text-xs" title="Restaurer cette sauvegarde">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span>Restaurer</span>
</button>
<button type="button" onclick="deleteBackup('${escapeHtml(b.filename)}')"
class="p-1.5 rounded-lg bg-anthracite-700 hover:bg-rose-600 text-slate-400 hover:text-white transition" title="Supprimer">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function toggleSshBackupFields(show) {
const fields = document.getElementById('backup-ssh-fields');
if (!fields) return;
if (show) {
fields.classList.remove('hidden');
} else {
fields.classList.add('hidden');
}
}
async function createBackupNow() {
const btn = document.getElementById('btn-create-backup-now');
const label = document.getElementById('btn-create-backup-label');
if (btn) btn.disabled = true;
if (label) label.textContent = 'Génération de l\'archive...';
try {
const res = await apiCall('create_backup', {});
showToast(res.message, 'success');
await loadBackups();
} catch (err) {
showToast("Échec de création : " + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (label) label.textContent = 'Créer une Sauvegarde Immédiate';
}
}
async function confirmRestoreBackup(filename) {
const ok = confirm(`⚠️ ATTENTION — RESTAURATION COMPLÈTE DU SYSTÈME NEXUSPBX ⚠️\n\nVoulez-vous restaurer le système depuis l'archive :\n${filename} ?\n\nCette action va :\n1. Remplacer la base de données SQLite\n2. Restaurer tous les fichiers sons, annonces et musiques\n3. Restaurer toutes les boîtes vocales, salutations et PINs\n4. Recharger l'intégralité du PBX Asterisk\n\nConfirmez-vous la restauration immédiate ?`);
if (!ok) return;
showToast("Restauration du système en cours... Veuillez patienter.", "info");
try {
const res = await apiCall('restore_backup', { filename });
showToast(res.message || "Restauration terminée avec succès !", 'success');
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
showToast("Échec de la restauration : " + err.message, 'error');
}
}
function triggerBackupUpload() {
document.getElementById('backup-upload-input')?.click();
}
async function handleBackupUpload(input) {
const file = input?.files?.[0];
if (!file) return;
const lowerName = file.name.toLowerCase();
if (!lowerName.endsWith('.tar.gz') && !lowerName.endsWith('.tgz')) {
showToast("Format de fichier non supporté. Veuillez sélectionner une archive de sauvegarde .tar.gz ou .tgz", 'error');
input.value = '';
return;
}
const ok = confirm(`⚠️ RESTAURATION DISASTER RECOVERY VIA FICHIER TÉLÉVERSÉ ⚠️\n\nFichier sélectionné : ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)} Mo)\n\nVoulez-vous restaurer immédiatement le système NexusPBX avec cette archive ?\nToutes les données actuelles seront remplacées.`);
if (!ok) {
input.value = '';
return;
}
showToast("Téléversement et restauration de l'archive en cours...", "info");
const fd = new FormData();
fd.append('backup_file', file);
try {
const response = await fetch('/api.php?action=upload_restore_backup', {
method: 'POST',
body: fd
});
const res = await response.json();
if (!res.success) {
throw new Error(res.message || "Erreur lors de la restauration");
}
showToast(res.message, 'success');
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
showToast("Échec de la restauration : " + err.message, 'error');
} finally {
input.value = '';
}
}
function downloadBackup(filename) {
window.location.href = `/api.php?action=download_backup&filename=${encodeURIComponent(filename)}`;
}
async function deleteBackup(filename) {
if (!confirm(`Êtes-vous sûr de vouloir supprimer définitivement l'archive de sauvegarde suivante ?\n\n${filename}`)) {
return;
}
try {
const res = await apiCall('delete_backup', { filename });
showToast(res.message, res.success ? 'success' : 'error');
await loadBackups();
} catch (err) {
showToast("Erreur lors de la suppression : " + err.message, 'error');
}
}
async function testSshBackupConnection() {
const form = document.getElementById('backup-settings-form');
if (!form) return;
const payload = {
backup_ssh_host: (form.elements['backup_ssh_host']?.value || '').trim(),
backup_ssh_port: Number(form.elements['backup_ssh_port']?.value || 22),
backup_ssh_user: (form.elements['backup_ssh_user']?.value || '').trim(),
backup_ssh_password: form.elements['backup_ssh_password']?.value || '',
backup_ssh_remote_path: (form.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim(),
backup_ssh_auth_type: 'password'
};
showToast("Test de la connexion SSH vers " + payload.backup_ssh_host + "...", "info");
try {
const res = await apiCall('test_ssh_backup', payload);
if (res.success) {
showToast(res.message, 'success');
} else {
showToast(res.message, 'error');
}
} catch (err) {
showToast("Échec du test SSH : " + err.message, 'error');
}
}
// =========================================================================
// GESTIONNAIRE DE PARE-FEU & SÉCURITÉ (FAIL2BAN & NFTABLES)
// =========================================================================
let appFirewallState = {
is_running: false,
settings: {},
jails: {},
banned_ips: [],
total_banned: 0,
client_ip: ''
};
async function loadFirewallStatus() {
try {
const res = await apiCall('get_firewall_status', {});
if (res.success && res.data) {
appFirewallState = res.data;
renderFirewall();
}
} catch (err) {
showToast("Erreur chargement sécurité : " + err.message, 'error');
}
}
function renderFirewall() {
const d = appFirewallState;
const s = d.settings || {};
const banned = d.banned_ips || [];
// 1. Stat cards
const engineEl = document.getElementById('fw-stat-engine');
if (engineEl) {
engineEl.textContent = d.is_running ? 'En ligne (Actif)' : 'Arrêté (Inactif)';
engineEl.className = d.is_running ? 'text-sm font-bold text-emerald-400 truncate' : 'text-sm font-bold text-rose-400 truncate';
}
const sipJail = d.jails?.asterisk;
const sipJailEl = document.getElementById('fw-stat-sip-jail');
if (sipJailEl) {
sipJailEl.textContent = sipJail ? `Actif (${sipJail.banned_ips?.length || 0} banni)` : 'Inactif';
}
const webJail = d.jails?.['nexus-web'];
const webJailEl = document.getElementById('fw-stat-web-jail');
if (webJailEl) {
webJailEl.textContent = webJail ? `Protégé (${webJail.banned_ips?.length || 0} banni)` : 'Inactif';
}
const countEl = document.getElementById('fw-stat-banned-count');
if (countEl) {
countEl.textContent = `${d.total_banned} bannie(s)`;
countEl.className = d.total_banned > 0 ? 'text-sm font-bold text-rose-400 truncate' : 'text-sm font-bold text-emerald-400 truncate';
}
const badgeSidebar = document.getElementById('nav-badge-firewall');
if (badgeSidebar) {
badgeSidebar.textContent = d.total_banned;
badgeSidebar.className = d.total_banned > 0
? 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/30 font-bold'
: 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold';
}
// 2. Table of banned IPs
const tbody = document.getElementById('firewall-banned-table-body');
if (tbody) {
if (banned.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-8 text-center text-slate-400">
<div class="flex flex-col items-center justify-center gap-2">
<svg class="w-8 h-8 text-emerald-500/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="text-slate-300 font-medium">Aucune adresse IP n'est actuellement bloquée.</span>
<span class="text-[11px] text-slate-500">Le pare-feu NFTables et Fail2ban surveillent les flux SIP et Web 24h/24.</span>
</div>
</td>
</tr>
`;
} else {
tbody.innerHTML = banned.map(b => {
const jailClass = b.jail === 'asterisk' ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30' : 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
return `
<tr class="hover:bg-anthracite-750 transition">
<td class="px-4 py-3 font-mono font-bold text-white text-sm">
<span class="text-rose-400">⛔ ${escapeHtml(b.ip)}</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${jailClass}">${escapeHtml(b.jail_label || b.jail)}</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-950/80 text-rose-300 border border-rose-600/40">DROP (Bloqué)</span>
</td>
<td class="px-4 py-3 text-right">
<button type="button" onclick="unbanIp('${escapeHtml(b.ip)}', '${escapeHtml(b.jail)}')"
class="px-2.5 py-1 rounded-lg bg-emerald-600/20 hover:bg-emerald-600 text-emerald-300 hover:text-white border border-emerald-500/40 text-xs font-semibold transition">
Débannir
</button>
</td>
</tr>
`;
}).join('');
}
}
// 3. Form fields
const form = document.getElementById('firewall-settings-form');
if (form) {
if (form.elements['admin_https_port']) {
form.elements['admin_https_port'].value = s.admin_https_port || 8443;
}
if (form.elements['certbot_strict_port80']) {
form.elements['certbot_strict_port80'].checked = Number(s.certbot_strict_port80) === 1;
}
if (form.elements['firewall_enabled']) {
form.elements['firewall_enabled'].checked = Number(s.firewall_enabled) === 1;
}
if (form.elements['fail2ban_sip_maxretry']) {
form.elements['fail2ban_sip_maxretry'].value = s.fail2ban_sip_maxretry || 3;
}
if (form.elements['fail2ban_sip_bantime']) {
form.elements['fail2ban_sip_bantime'].value = s.fail2ban_sip_bantime || 3600;
}
if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) {
form.elements['fail2ban_whitelist_ips'].value = s.fail2ban_whitelist_ips || '';
}
}
}
async function unbanIp(ip, jail) {
if (!confirm(`Débannir l'adresse IP ${ip} et rétablir son accès immédiat ?`)) {
return;
}
try {
const res = await apiCall('unban_ip', { ip, jail });
showToast(res.message, res.success ? 'success' : 'error');
await loadFirewallStatus();
} catch (err) {
showToast("Erreur lors du débannissement : " + err.message, 'error');
}
}
function whitelistMyIp() {
const clientIp = appFirewallState.client_ip;
if (!clientIp) {
showToast("Impossible de déterminer votre adresse IP actuelle.", 'error');
return;
}
const textarea = document.getElementById('fail2ban_whitelist_ips');
if (!textarea) return;
const currentVal = textarea.value.trim();
const tokens = currentVal ? currentVal.split(/\s+/) : [];
if (tokens.includes(clientIp)) {
showToast(`Votre IP actuelle (${clientIp}) est déjà dans la liste blanche.`, 'info');
return;
}
tokens.push(clientIp);
textarea.value = tokens.join(' ');
showToast(`Votre IP actuelle (${clientIp}) a été ajoutée. Pensez à cliquer sur "Enregistrer" pour appliquer.`, 'success');
}
// Modal Journal des Événements de Sécurité
async function openSecurityEventsModal() {
const modal = document.getElementById('modal-security-events');
const tbody = document.getElementById('security-events-tbody');
const emptyState = document.getElementById('security-events-empty');
if (!modal) return;
modal.classList.remove('hidden');
if (tbody) tbody.innerHTML = '<tr><td colspan="5" class="py-8 text-center text-slate-400">Chargement des événements de sécurité en direct...</td></tr>';
if (emptyState) emptyState.classList.add('hidden');
try {
const resp = await fetch('/api.php?action=get_security_events');
const data = await resp.json();
const events = data.events || [];
if (events.length === 0) {
if (tbody) tbody.innerHTML = '';
if (emptyState) emptyState.classList.remove('hidden');
return;
}
if (tbody) {
tbody.innerHTML = events.map(e => `
<tr class="hover:bg-anthracite-900/50 transition border-b border-anthracite-800">
<td class="py-2.5 px-3 font-mono text-[11px] text-slate-300 whitespace-nowrap">${escapeHtml(e.time)}</td>
<td class="py-2.5 px-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 font-mono">
${escapeHtml(e.service)}
</span>
</td>
<td class="py-2.5 px-3">
<span class="text-rose-400 font-semibold font-mono text-xs">${escapeHtml(e.event)}</span>
<div class="text-[10px] text-slate-400">Échec mot de passe / Défi SIP</div>
</td>
<td class="py-2.5 px-3 font-mono text-xs text-amber-300 font-bold">${escapeHtml(e.account)}</td>
<td class="py-2.5 px-3 font-mono text-xs text-cyan-300 font-bold">${escapeHtml(e.ip)}</td>
</tr>
`).join('');
}
} catch (err) {
if (tbody) {
tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-rose-400">Erreur : ${escapeHtml(err.message)}</td></tr>`;
}
}
}
async function clearSecurityLog() {
if (!confirm("Voulez-vous vraiment vider le journal des événements de sécurité et réinitialiser les compteurs d'attaques à zéro ?")) {
return;
}
try {
const res = await apiCall('clear_security_log', {});
showToast(res.message, 'success');
await openSecurityEventsModal();
await pollMetrics();
} catch (err) {
showToast("Erreur lors de la purge du journal : " + err.message, 'error');
}
}
// =========================================================================
// 2C. ANNUAIRE D'ENTREPRISE & YEALINK REMOTE PHONEBOOK XML
// =========================================================================
let selectedPhonebookGroup = 'all';
function renderPhonebookTable() {
const tbody = document.getElementById('phonebook-tbody');
if (!tbody) return;
const contacts = appState.phonebook_contacts || [];
const groups = appState.phonebook_groups || [];
const searchVal = (document.getElementById('phonebook-search')?.value || '').trim().toLowerCase();
// 1. Rendu des Group Pills
const pillsContainer = document.getElementById('phonebook-group-pills');
if (pillsContainer) {
let pillsHtml = `
<button type="button" onclick="selectPhonebookGroupFilter('all')"
class="phonebook-grp-pill ${selectedPhonebookGroup === 'all' ? 'active bg-indigo-600 text-white' : 'bg-anthracite-950 text-slate-400 hover:text-white'} px-3 py-1 rounded-lg text-xs font-semibold border border-anthracite-700 transition">
Tous les groupes (${contacts.length})
</button>
`;
groups.forEach(g => {
const count = contacts.filter(c => {
const cg = (c.group_name || '').toLowerCase();
const gl = g.toLowerCase();
return cg === gl || cg.includes(gl) || cg === 'tous' || cg === 'all';
}).length;
const isActive = selectedPhonebookGroup.toLowerCase() === g.toLowerCase();
pillsHtml += `
<button type="button" onclick="selectPhonebookGroupFilter('${escapeHtml(g)}')"
class="phonebook-grp-pill ${isActive ? 'active bg-teal-600 text-white border-teal-500' : 'bg-anthracite-950 text-slate-400 hover:text-white border-anthracite-700'} px-3 py-1 rounded-lg text-xs font-semibold border transition">
${escapeHtml(g)} (${count})
</button>
`;
});
pillsContainer.innerHTML = pillsHtml;
}
// 2. Filtrage des contacts
const filtered = contacts.filter(c => {
// Filtre de groupe
if (selectedPhonebookGroup !== 'all') {
const cg = (c.group_name || '').toLowerCase();
const sg = selectedPhonebookGroup.toLowerCase();
if (cg !== sg && !cg.includes(sg) && cg !== 'tous' && cg !== 'all') {
return false;
}
}
// Filtre de recherche
if (searchVal !== '') {
const matchName = (c.display_name || '').toLowerCase().includes(searchVal);
const matchInt = (c.internal_number || '').toLowerCase().includes(searchVal);
const matchMob = (c.mobile_number || '').toLowerCase().includes(searchVal);
const matchOth = (c.other_number || '').toLowerCase().includes(searchVal);
const matchMail = (c.email || '').toLowerCase().includes(searchVal);
const matchGrp = (c.group_name || '').toLowerCase().includes(searchVal);
if (!matchName && !matchInt && !matchMob && !matchOth && !matchMail && !matchGrp) {
return false;
}
}
return true;
});
// 3. Statistiques affichées
const statsEl = document.getElementById('phonebook-stats');
if (statsEl) {
const intCount = contacts.filter(c => c.is_internal).length;
const extCount = contacts.length - intCount;
statsEl.innerHTML = `<span><strong>${filtered.length}</strong> affiché(s) • ${intCount} interne(s) • ${extCount} externe(s)</span>`;
}
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="py-10 text-center text-slate-400 text-xs">Aucun contact trouvé dans l'annuaire pour ces critères.</td></tr>`;
return;
}
tbody.innerHTML = filtered.map(c => {
const initials = getInitials(c.display_name || '?');
const colorClass = getAvatarBgColor(c.display_name || '');
const avatarHtml = c.photo_url
? `<img src="${escapeHtml(c.photo_url)}" alt="${escapeHtml(c.display_name)}" class="w-8 h-8 rounded-full object-cover border border-anthracite-700 shadow" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.style.display='flex';"><div class="w-8 h-8 rounded-full ${colorClass} text-white items-center justify-center font-bold text-xs shadow-inner" style="display:none;">${escapeHtml(initials)}</div>`
: `<div class="w-8 h-8 rounded-full ${colorClass} text-white flex items-center justify-center font-bold text-xs shadow-inner">${escapeHtml(initials)}</div>`;
const typeBadge = c.is_internal
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">Poste ${escapeHtml(c.internal_number)}</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-700/60 text-slate-300 border border-slate-600">Contact Externe</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3 px-4">${avatarHtml}</td>
<td class="py-3 px-4 font-semibold text-white">
<div>${escapeHtml(c.display_name)}</div>
${c.photo_url ? '<span class="text-[10px] text-teal-400 font-mono">Photo active</span>' : ''}
</td>
<td class="py-3 px-4 font-mono font-bold text-indigo-300">${c.internal_number ? escapeHtml(c.internal_number) : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4 font-mono text-xs text-slate-200">${c.mobile_number ? escapeHtml(c.mobile_number) : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4 font-mono text-xs text-slate-400">${c.other_number ? escapeHtml(c.other_number) : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4 text-xs text-slate-300">${c.email ? `<a href="mailto:${escapeHtml(c.email)}" class="hover:text-teal-300 underline">${escapeHtml(c.email)}</a>` : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4">
<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-teal-500/15 text-teal-300 border border-teal-500/30">${escapeHtml(c.group_name || 'Général')}</span>
</td>
<td class="py-3 px-4">${typeBadge}</td>
<td class="py-3 px-4 text-right space-x-2">
<button type="button" onclick="editPhonebookContact(${c.id})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
${!c.is_internal ? `<button type="button" onclick="deletePhonebookContact(${c.id})" class="text-xs font-semibold text-rose-400 hover:text-rose-300">Supprimer</button>` : ''}
</td>
</tr>
`;
}).join('');
}
function getInitials(name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return (name.substring(0, 2) || '?').toUpperCase();
}
function getAvatarBgColor(str) {
const colors = [
'bg-teal-600', 'bg-indigo-600', 'bg-emerald-600', 'bg-cyan-600',
'bg-purple-600', 'bg-amber-600', 'bg-rose-600', 'bg-blue-600'
];
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function filterPhonebook() {
renderPhonebookTable();
}
function selectPhonebookGroupFilter(grp) {
selectedPhonebookGroup = grp;
renderPhonebookTable();
}
function openPhonebookContactModal(contact = null) {
const form = document.getElementById('form-phonebook-contact');
if (!form) return;
form.reset();
const titleEl = document.getElementById('modal-phonebook-title');
if (titleEl) {
titleEl.textContent = contact ? `Modifier le Contact ${contact.display_name}` : 'Nouveau Contact Annuaire';
}
form.elements['id'].value = contact ? contact.id : '';
form.elements['extension_id'].value = contact ? (contact.extension_id || '') : '';
form.elements['is_internal'].value = contact ? (contact.is_internal ? 1 : 0) : 0;
form.elements['photo_url'].value = contact ? (contact.photo_url || '') : '';
form.elements['display_name'].value = contact ? contact.display_name : '';
form.elements['internal_number'].value = contact ? (contact.internal_number || '') : '';
form.elements['mobile_number'].value = contact ? (contact.mobile_number || '') : '';
form.elements['other_number'].value = contact ? (contact.other_number || '') : '';
form.elements['email'].value = contact ? (contact.email || '') : '';
form.elements['group_name'].value = contact ? (contact.group_name || 'Général') : (selectedPhonebookGroup !== 'all' ? selectedPhonebookGroup : 'Général');
// Datalist des groupes existants
const dl = document.getElementById('phonebook-groups-datalist');
if (dl) {
dl.innerHTML = (appState.phonebook_groups || []).map(g => `<option value="${escapeHtml(g)}">`).join('');
}
updateAvatarPreview(contact?.photo_url || '', contact?.display_name || '?');
openModal('modal-phonebook-contact');
}
function editPhonebookContact(id) {
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
if (contact) {
openPhonebookContactModal(contact);
}
}
async function savePhonebookContact(e) {
e.preventDefault();
const form = e.target;
const payload = {
id: form.elements['id'].value ? Number(form.elements['id'].value) : null,
extension_id: form.elements['extension_id'].value ? Number(form.elements['extension_id'].value) : null,
is_internal: Number(form.elements['is_internal'].value) || 0,
display_name: form.elements['display_name'].value.trim(),
internal_number: form.elements['internal_number'].value.trim(),
mobile_number: form.elements['mobile_number'].value.trim(),
other_number: form.elements['other_number'].value.trim(),
email: form.elements['email'].value.trim(),
group_name: form.elements['group_name'].value.trim() || 'Général',
photo_url: form.elements['photo_url'].value.trim(),
};
try {
const res = await apiCall('save_phonebook_contact', payload);
showToast(res.message, 'success');
closeModal('modal-phonebook-contact');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function deletePhonebookContact(id) {
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
if (!contact) return;
if (!confirm(`Supprimer le contact « ${contact.display_name} » de l'annuaire ?`)) return;
try {
const res = await apiCall('delete_phonebook_contact', { id });
showToast(res.message, 'info');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function syncPhonebookNow() {
try {
const res = await apiCall('sync_phonebook', {});
showToast(res.message, 'success');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function uploadContactPhoto(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const form = document.getElementById('form-phonebook-contact');
const contactId = form ? form.elements['id'].value : '';
const formData = new FormData();
formData.append('avatar', file);
formData.append('contact_id', contactId);
try {
const res = await fetch('/api.php?action=upload_phonebook_avatar', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!data.success) throw new Error(data.message || "Erreur d'envoi");
document.getElementById('phonebook-input-photo-url').value = data.photo_url;
updateAvatarPreview(data.photo_url, form.elements['display_name'].value || '?');
showToast(data.message, 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
function clearContactPhoto() {
document.getElementById('phonebook-input-photo-url').value = '';
const form = document.getElementById('form-phonebook-contact');
updateAvatarPreview('', form?.elements['display_name']?.value || '?');
}
function updateAvatarPreview(photoUrl, name) {
const preview = document.getElementById('phonebook-avatar-preview');
const clearBtn = document.getElementById('phonebook-btn-clear-photo');
if (!preview) return;
const initials = getInitials(name || '?');
const colorClass = getAvatarBgColor(name || '');
if (photoUrl) {
preview.className = 'w-14 h-14 rounded-full overflow-hidden border border-teal-500/40 shrink-0 shadow relative flex items-center justify-center';
preview.innerHTML = `
<img src="${escapeHtml(photoUrl)}" class="w-full h-full object-cover" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.style.display='flex';">
<div class="w-full h-full ${colorClass} text-white items-center justify-center font-bold text-lg shadow-inner" style="display:none;">${escapeHtml(initials)}</div>
`;
clearBtn?.classList.remove('hidden');
} else {
preview.className = `w-14 h-14 rounded-full ${colorClass} text-white flex items-center justify-center font-bold text-lg overflow-hidden shrink-0 shadow-inner`;
preview.textContent = initials;
clearBtn?.classList.add('hidden');
}
}
// Modal Yealink Remote XML Phonebook
function openYealinkXmlModal() {
const sel = document.getElementById('yealink-modal-group-select');
if (sel) {
const groups = appState.phonebook_groups || [];
sel.innerHTML = `<option value="all">Tous les groupes (Annuaire complet de l'entreprise)</option>` +
groups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join('');
if (selectedPhonebookGroup !== 'all' && groups.includes(selectedPhonebookGroup)) {
sel.value = selectedPhonebookGroup;
}
}
updateYealinkXmlUrls();
openModal('modal-yealink-xml');
}
function updateYealinkXmlUrls() {
const sel = document.getElementById('yealink-modal-group-select');
const grp = sel ? sel.value : 'all';
const host = window.location.hostname || '10.10.175.204';
const httpsPort = window.location.port || '8443';
const groupParam = grp !== 'all' ? `?group=${encodeURIComponent(grp)}` : `?group=all`;
const url8080 = `http://${host}:8080/phonebook.php${groupParam}`;
const urlHttps = `https://${host}:${httpsPort}/phonebook.php${groupParam}`;
const el8080 = document.getElementById('yealink-url-8080');
if (el8080) el8080.value = url8080;
const elHttps = document.getElementById('yealink-url-https');
if (elHttps) elHttps.value = urlHttps;
const openBtn = document.getElementById('yealink-open-browser-btn');
if (openBtn) openBtn.href = url8080;
const previewEl = document.getElementById('yealink-xml-preview');
if (previewEl) {
previewEl.textContent = 'Chargement du flux XML...';
fetch(`/phonebook.php${groupParam}`)
.then(r => r.text())
.then(xml => {
previewEl.textContent = xml;
})
.catch(() => {
previewEl.textContent = `<?xml version="1.0" encoding="UTF-8"?>\n<YealinkIPPhoneDirectory>\n <Title>Annuaire ${grp}</Title>\n</YealinkIPPhoneDirectory>`;
});
}
}
function copyYealinkUrl(inputId, btn) {
const el = document.getElementById(inputId);
if (!el) return;
el.select();
navigator.clipboard.writeText(el.value).then(() => {
showToast("URL Yealink copiée dans le presse-papiers !", 'success');
const origText = btn.innerHTML;
btn.innerHTML = '<span>Copié !</span>';
setTimeout(() => { btn.innerHTML = origText; }, 2000);
}).catch(() => {
showToast("Sélectionnez l'URL et appuyez sur Ctrl+C.", 'info');
});
}