v2.6.7: Replace Admin & UCP emojis with uniform Heroicons SVG icons

This commit is contained in:
sorcier committed 2026-11-12 10:25:40 +01:00
1 parent 70a021ef63
commit ddbaabb657
5 files changed
+198 -124

No files matched your search

+74 -49
View File
@@ -28,6 +28,31 @@ const chartHistory = {
let metricsTimer = null;
function adminIcon(name, cls = 'w-3.5 h-3.5') {
const paths = {
shield: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>',
lock: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>',
unlock: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>',
warning: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>',
phone: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/>',
mobile: '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>',
building: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"/>',
voicemail: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/>',
music: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"/>',
refresh: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/>',
trash: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>',
tag: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/>',
ban: '<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>',
speaker: '<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"/>',
rocket: '<path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z"/>',
emergency: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/>',
clock: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>',
globe: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"/>',
dotGreen: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
};
return `<svg class="${cls} shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">${paths[name] || paths.tag}</svg>`;
}
// --- Notifications Toast Tailwind ---
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
@@ -765,11 +790,11 @@ function renderDashboardPhonesTable() {
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>`;
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)">${adminIcon('shield', 'w-3 h-3')} 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>`;
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">${adminIcon('shield', 'w-3 h-3')} 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>`;
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)">${adminIcon('speaker', 'w-3 h-3')} RTP Clair</span>`;
}
// Détection dynamique du mode réel négocié en direct par le téléphone
@@ -782,21 +807,21 @@ function renderDashboardPhonesTable() {
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é)
${adminIcon('lock', 'w-3 h-3')} 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
${adminIcon('lock', 'w-3 h-3')} 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)
${adminIcon('unlock', 'w-3 h-3')} TCP Actif (Clair)
</span>
`;
profileNote = `<span class="text-slate-300">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
@@ -804,10 +829,10 @@ function renderDashboardPhonesTable() {
// 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)
${adminIcon('unlock', 'w-3 h-3')} UDP Actif (Clair)
</span>
`;
profileNote = isTls ? '<span class="text-amber-400 font-semibold">⚠️ Exige TLS</span>' : '<span class="text-slate-400">UDP Interne</span>';
profileNote = isTls ? `<span class="text-amber-400 font-semibold inline-flex items-center gap-1">${adminIcon('warning', 'w-3 h-3')} Exige TLS</span>` : '<span class="text-slate-400">UDP Interne</span>';
}
modeBadge = `
@@ -853,7 +878,7 @@ function renderDashboardPhonesTable() {
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>` : ''}
${ua ? `<div class="text-[11px] text-indigo-300 font-medium truncate max-w-[240px]" title="${ua}">${adminIcon('mobile', 'w-3 h-3 text-indigo-400')} ${ua}</div>` : ''}
</div>
`;
} else {
@@ -909,10 +934,10 @@ function renderExtensionsTable() {
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>`
? `<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">${adminIcon('lock', 'w-3.5 h-3.5 text-emerald-400')} 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>`);
? `<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">${adminIcon('shield', 'w-3.5 h-3.5 text-indigo-400')} 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">${adminIcon('building', 'w-3.5 h-3.5 text-slate-400')} UDP (RTP Clair)</span>`);
const actTr = c ? (c.active_transport || 'udp').toUpperCase() : '';
const stLower = c ? String(c.status).toLowerCase() : '';
@@ -935,7 +960,7 @@ function renderExtensionsTable() {
: `<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>`
? `<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)">${adminIcon('voicemail', 'w-3 h-3')} Boîte</span>`
: '';
const gwBadge = Number(ext.groundwire_enabled) === 1
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Provisionnement XML GroundWire activé dans l'UCP">GroundWire</span>`
@@ -1256,7 +1281,7 @@ function renderGroupsTable() {
? 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>';
const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
? `<span class="px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">${adminIcon('voicemail', 'w-3.5 h-3.5')} Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
: `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`;
return `
@@ -1773,7 +1798,7 @@ function renderTrunksTable() {
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="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">${adminIcon('music', 'w-3 h-3')} ${escapeHtml(mohSound.name)}</span>`
: `<span class="text-[10px] text-slate-500">MoH : Globale</span>`;
return `
@@ -1803,12 +1828,12 @@ function renderTrunksTable() {
${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>` : ''}
${tcCount > 0 ? `<span class="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">${adminIcon('clock', 'w-3 h-3')} ${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>` : ''}
${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="inline-flex items-center gap-1 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">${adminIcon('refresh', 'w-3.5 h-3.5')} 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>
@@ -2230,11 +2255,11 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
</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 type="button" class="btn-toggle-time inline-flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-semibold border transition" title="Configurer les horaires d'ouverture (Mode Jour / Nuit)">
${adminIcon('clock', 'w-3.5 h-3.5')} <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>
<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">${adminIcon('trash', 'w-3.5 h-3.5')}</button>
</div>
</div>
@@ -2278,7 +2303,7 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
<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>
<span>${adminIcon('tag', 'w-3.5 h-3.5 text-amber-400')} Préfixe CID spécifique à cette règle :</span>
</label>
<input type="text" placeholder="ex: [Ligne-1] (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">
@@ -2307,7 +2332,7 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
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>`;
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">${adminIcon('clock', 'w-3 h-3')} ${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 = '';
@@ -2717,10 +2742,10 @@ function openTrunkModal(trunk = null) {
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>`;
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>`;
opts += `<option value="${s.id}" ${isSel}>${escapeHtml(s.name)} (${escapeHtml(s.site_category || 'Global')})</option>`;
});
mohSelect.innerHTML = opts;
}
@@ -2821,7 +2846,7 @@ function renderOutboundRoutesTable() {
<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>`;
: `<span class="text-amber-400 text-xs font-semibold">${adminIcon('warning', 'w-3.5 h-3.5')} Aucun Trunk associé</span>`;
const isRestricted = r.permission_mode === 'restricted';
const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : [];
@@ -2846,7 +2871,7 @@ function renderOutboundRoutesTable() {
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>`
? `<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">${adminIcon('emergency', 'w-3 h-3')} 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>`);
@@ -2941,19 +2966,19 @@ function openOutboundRouteModal(route = null) {
if (congSel) {
const curCong = route ? route.congestion_dest : 'all-circuits-busy';
let congOpts = `
<optgroup label="📢 Messages Vocaux (Recommandé)">
<option value="all-circuits-busy" ${(curCong === 'all-circuits-busy' || (!route && curCong !== 'terminate-congestion')) ? 'selected' : ''}>📢 Message vocal standard (« Toutes les lignes sont occupées »)</option>
<optgroup label="Messages Vocaux (Recommandé)">
<option value="all-circuits-busy" ${(curCong === 'all-circuits-busy' || (!route && curCong !== 'terminate-congestion')) ? 'selected' : ''}>Message vocal standard (« Toutes les lignes sont occupées »)</option>
`;
const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh');
if (annSounds.length > 0) {
annSounds.forEach(s => {
const val = `sound-${s.id}`;
congOpts += `<option value="${val}" ${curCong === val ? 'selected' : ''}>🎙️ Annonce : ${escapeHtml(s.name)}</option>`;
congOpts += `<option value="${val}" ${curCong === val ? 'selected' : ''}>Annonce : ${escapeHtml(s.name)}</option>`;
});
}
congOpts += `
</optgroup>
<optgroup label="📴 Tonalités & Raccrochage">
<optgroup label="Tonalités & Raccrochage">
<option value="terminate-congestion" ${curCong === 'terminate-congestion' ? 'selected' : ''}>Tonalité de Congestion (Bip rapide réseau saturé)</option>
<option value="terminate-busy" ${curCong === 'terminate-busy' ? 'selected' : ''}>Tonalité d'Occupation (Bip occupé)</option>
<option value="terminate-hangup" ${curCong === 'terminate-hangup' ? 'selected' : ''}>Raccrocher immédiatement</option>
@@ -2967,7 +2992,7 @@ function openOutboundRouteModal(route = null) {
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' : ''}>🌐 Hériter du Trunk / Global (Recommandé)</option>`;
let mohOpts = `<option value="default" ${curMoh === 'default' ? 'selected' : ''}>Hériter du Trunk / Global (Recommandé)</option>`;
mohSounds.forEach(s => {
const sSlug = slugifyTrunkName(s.site_category || 'global');
const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
@@ -3092,7 +3117,7 @@ function addOutboundTrunkSeqRow(selectedTrunk = '') {
<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>
<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">${adminIcon('trash', 'w-3.5 h-3.5')}</button>
`;
row.querySelector('.btn-del-trunk-row').addEventListener('click', () => {
@@ -3139,7 +3164,7 @@ function addOutboundPatternRow(prepend = '', prefix = '', match = '', 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>
<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">${adminIcon('trash', 'w-3.5 h-3.5')}</button>
</div>
`;
@@ -3322,8 +3347,8 @@ function renderIvrsTable() {
</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>
<div class="inline-flex items-center gap-1">${adminIcon('clock', 'w-3.5 h-3.5 text-amber-400')} 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">${adminIcon('warning', 'w-3.5 h-3.5 text-amber-400')} 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">
@@ -3397,7 +3422,7 @@ function openIvrModal(ivr = null) {
let audioHtml = '';
if (sounds.length > 0) {
audioHtml += `<optgroup label="📂 Fichiers sons de la médiathèque">`;
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);
@@ -3407,7 +3432,7 @@ function openIvrModal(ivr = null) {
}
audioHtml += `
<optgroup label="🔔 Messages système Asterisk par défaut">
<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>
@@ -5629,7 +5654,7 @@ function renderFirewall() {
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>
<span class="text-rose-400">${adminIcon('ban', 'w-3.5 h-3.5')} ${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>
@@ -6260,7 +6285,7 @@ async function checkSystemUpdate(notify = false) {
if (statusText) {
statusText.className = 'text-amber-400 font-bold';
statusText.innerHTML = `🚀 Nouvelle version disponible : <span class="underline">v${escapeHtml(data.latest_version)}</span> !`;
statusText.innerHTML = `${adminIcon('rocket', 'w-3.5 h-3.5 text-indigo-300')} Nouvelle version disponible : <span class="underline">v${escapeHtml(data.latest_version)}</span> !`;
}
if (btnRun) {
btnRun.style.display = 'inline-flex';
@@ -6911,13 +6936,13 @@ async function refreshClusterStatus() {
if (svcKeepalived) {
if (!st.keepalived_installed) {
svcKeepalived.className = 'font-mono text-amber-400 font-bold';
svcKeepalived.textContent = '⚠️ Non installé';
svcKeepalived.innerHTML = adminIcon('warning', 'w-3 h-3 text-amber-400') + ' Non installé';
} else if (st.keepalived_running) {
svcKeepalived.className = 'font-mono text-emerald-400 font-bold';
svcKeepalived.textContent = '🟢 Actif (En cours)';
svcKeepalived.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>Actif (En cours)';
} else {
svcKeepalived.className = 'font-mono text-slate-400';
svcKeepalived.textContent = '⚪ Arrêté';
svcKeepalived.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-slate-400 mr-1.5"></span>Arrêté';
}
}
@@ -6925,13 +6950,13 @@ async function refreshClusterStatus() {
if (svcAsterisk) {
if (st.asterisk_healthy) {
svcAsterisk.className = 'font-mono text-emerald-400 font-bold';
svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)';
svcAsterisk.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>En ligne (Ping Pong OK)';
} else if (st.asterisk_running) {
svcAsterisk.className = 'font-mono text-amber-400 font-bold';
svcAsterisk.textContent = '🟡 En cours (Ping en attente)';
svcAsterisk.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-amber-400 mr-1.5"></span>En cours (Ping en attente)';
} else {
svcAsterisk.className = 'font-mono text-red-400 font-bold';
svcAsterisk.textContent = '🔴 Arrêté';
svcAsterisk.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-rose-400 mr-1.5"></span>Arrêté';
}
}
@@ -6941,10 +6966,10 @@ async function refreshClusterStatus() {
svcPeer.textContent = 'Non configuré';
svcPeer.className = 'font-mono text-slate-500';
} else if (st.peer_reachable) {
svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
svcPeer.innerHTML = `<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
svcPeer.className = 'font-mono text-emerald-400 font-bold';
} else {
svcPeer.textContent = '🔴 Injoignable (Timeout)';
svcPeer.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-rose-400 mr-1.5"></span>Injoignable (Timeout)';
svcPeer.className = 'font-mono text-red-400 font-bold';
}
}
@@ -7088,11 +7113,11 @@ function renderUcpTab() {
const links = [];
if (httpsPort > 0) {
const url = `https://${host}:${httpsPort}/`;
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-mono transition">🔒 ${escapeHtml(url)}</a>`);
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-mono transition">${adminIcon('lock', 'w-3.5 h-3.5')} ${escapeHtml(url)}</a>`);
}
if (httpPort > 0) {
const url = `http://${host}:${httpPort}/`;
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 font-mono transition">🌐 ${escapeHtml(url)} (Reverse Proxy)</a>`);
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 font-mono transition">${adminIcon('globe', 'w-3.5 h-3.5')} ${escapeHtml(url)} (Reverse Proxy)</a>`);
}
urlsBox.innerHTML = links.length ? links.join('') : '<span class="text-amber-400">Aucun port d’écoute configuré.</span>';
}
+37 -36
View File
@@ -41,7 +41,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>">
<title><?= $isDevEnv ? '🛠️ [DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title>
<title><?= $isDevEnv ? '[DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title>
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
<link rel="apple-touch-icon" href="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>">
<script src="https://cdn.tailwindcss.com"></script>
@@ -537,7 +537,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Badge Notification Mise à Jour Disponible -->
<button id="header-update-badge" type="button" onclick="openUpdatePanel()" style="display:none;"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 shadow-sm transition animate-pulse" title="Une mise à jour NexusPBX est disponible ! Cliquez pour voir les détails.">
<span>🚀</span>
<span><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z"/></svg></span>
<span id="header-update-text">Mise à jour dispo</span>
</button>
@@ -1383,14 +1383,14 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Bannière d'aide rapide Paging & Intercom 1-à-1 -->
<div class="p-4 rounded-2xl bg-anthracite-800/90 border border-purple-500/30 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300 shrink-0 font-bold text-sm">📢</div>
<div class="w-8 h-8 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300 shrink-0 font-bold text-sm"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"/></svg></div>
<div class="space-y-0.5">
<div class="text-xs font-bold text-purple-200">Annonce Générale de Groupe (Paging)</div>
<p class="text-[11px] text-slate-300">Composez le numéro du groupe configuré (ex: <span class="font-mono text-purple-300 font-bold">100</span>). Les téléphones cibles décrochent automatiquement sur haut-parleur après un bip d'avertissement sonore.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-xl bg-indigo-500/20 border border-indigo-500/40 flex items-center justify-center text-indigo-300 shrink-0 font-bold text-sm">📞</div>
<div class="w-8 h-8 rounded-xl bg-indigo-500/20 border border-indigo-500/40 flex items-center justify-center text-indigo-300 shrink-0 font-bold text-sm"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg></div>
<div class="space-y-0.5">
<div class="text-xs font-bold text-indigo-200">Interphonie Directe 1-à-1 (*80 + poste)</div>
<p class="text-[11px] text-slate-300">Composez <span class="font-mono text-indigo-300 font-bold">*80</span> suivi du numéro de l'extension (ex: <span class="font-mono text-indigo-300 font-bold">*80101</span>) pour ouvrir directement le haut-parleur et le micro du poste en conversation duplex.</p>
@@ -2482,8 +2482,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="bg-anthracite-900 p-2 rounded-lg border border-anthracite-700"><span class="text-slate-400 block text-[10px] uppercase font-sans">Ligne (Line)</span><strong class="text-white">Line 1</strong></div>
<div class="bg-anthracite-900 p-2 rounded-lg border border-indigo-500/40 bg-indigo-950/30"><span class="text-indigo-300 block text-[10px] uppercase font-sans">Extension (Pickup)</span><strong class="text-indigo-300 font-bold">**</strong></div>
</div>
<div class="text-[11px] text-slate-400 pt-1">
💡 Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe <code class="text-indigo-300">**</code> !
<div class="text-[11px] text-slate-400 pt-1 flex items-start gap-1.5">
<svg class="w-3.5 h-3.5 shrink-0 text-amber-400 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
<span>Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe <code class="text-indigo-300">**</code> !</span>
</div>
</div>
</div>
@@ -2559,7 +2560,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<button type="button" onclick="detectPublicIp()" id="btn-detect-ip"
class="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-semibold transition">
<span id="spinner-detect-ip" class="hidden animate-spin">↻</span>
<span>🔍 Détecter l'IP publique</span>
<svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/></svg><span>Détecter l'IP publique</span>
</button>
</div>
<input type="text" id="sip-external-ip" name="external_ip" placeholder="ex: sip.mondomaine.com ou 198.51.100.1"
@@ -2575,7 +2576,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<label class="block text-xs font-semibold text-slate-200">Réseaux Locaux (Local Networks - CIDR) *</label>
<button type="button" onclick="addLocalNetworkField()"
class="text-[11px] text-emerald-400 hover:text-emerald-300 font-semibold flex items-center gap-1 transition">
<span>➕ Ajouter un réseau local</span>
<svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg><span>Ajouter un réseau local</span>
</button>
</div>
<div id="local-networks-container" class="space-y-2 max-h-48 overflow-y-auto pr-1"></div>
@@ -3068,9 +3069,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<input type="text" id="cluster-secret" name="cluster_secret" required
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono focus:outline-none focus:border-cyan-400">
<button type="button" onclick="regenerateClusterSecret()" title="Générer un nouveau token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">🎲</button>
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg></button>
<button type="button" onclick="copyClusterSecret()" title="Copier le token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">📋</button>
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"/></svg></button>
</div>
</div>
</div>
@@ -3159,7 +3160,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div>
<div class="p-3 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-[11px] text-indigo-300">
💡 <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>.
<svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg> <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>.
</div>
</div>
</div>
@@ -3181,11 +3182,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="flex items-center gap-2 border-b border-anthracite-700 pb-2 mb-4">
<button type="button" id="tab-btn-ext-general" onclick="switchExtModalTab('general')"
class="px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white">
⚙️ Général & SIP
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg><span>Général &amp; SIP</span></span>
</button>
<button type="button" id="tab-btn-ext-voicemail" onclick="switchExtModalTab('voicemail')"
class="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">
<span>📟 Messagerie Vocale</span>
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg><span>Messagerie Vocale</span></span>
<span id="badge-ext-vm-status" class="w-2 h-2 rounded-full bg-slate-500"></span>
</button>
</div>
@@ -3377,7 +3378,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Indicateur MWI Yealink -->
<div class="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-start gap-2.5">
<span class="text-base">💡</span>
<svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
<div class="text-xs text-emerald-200">
<strong class="font-bold">Notification MWI automatique :</strong>
Le voyant LED ou l'enveloppe clignoteront sur vos téléphones IP (Yealink, Fanvil, Grandstream, Snom...) dès qu'un message vocal est reçu.
@@ -3711,11 +3712,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</label>
<label class="cursor-pointer">
<input type="radio" name="route_type" value="emergency" class="peer hidden">
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-emerald-600 peer-checked:text-white transition inline-block">🚑 Urgence</span>
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-emerald-600 peer-checked:text-white transition inline-block"><span class="inline-flex items-center gap-1"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-rose-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/></svg><span>Urgence</span></span></span>
</label>
<label class="cursor-pointer">
<input type="radio" name="route_type" value="intracompany" class="peer hidden">
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-cyan-600 peer-checked:text-white transition inline-block">🏢 Intra-Company</span>
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-cyan-600 peer-checked:text-white transition inline-flex items-center gap-1"><svg class="w-3.5 h-3.5 shrink-0 text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"/></svg><span>Intra-Company</span></span>
</label>
</div>
</div>
@@ -3757,11 +3758,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</label>
</div>
<div>
<label class="block text-xs font-medium text-slate-200 mb-1">🎵 Musique d'attente (MoH)</label>
<label class="block text-xs font-medium text-slate-200 mb-1"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-sky-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"/></svg><span>Musique d'attente (MoH)</span></span></label>
<select id="ob-musicclass-select" name="moh_class" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select>
</div>
<div>
<label class="block text-xs font-medium text-slate-200 mb-1">📢 Si Trunks indisponibles <span class="text-slate-400">(Échec / Congestion)</span></label>
<label class="block text-xs font-medium text-slate-200 mb-1"><span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"/></svg><span>Si Trunks indisponibles <span class="text-slate-400">(Échec / Congestion)</span></label>
<select id="ob-congestion-dest-select" name="congestion_dest" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select>
</div>
</div>
@@ -3772,7 +3773,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-4 rounded-xl bg-anthracite-950 border border-emerald-500/30 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300">🪄 Masques de numérotation pré-définis (1 clic)</div>
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg><span>Masques de numérotation pré-définis (1 clic)</span></span></div>
<p class="text-[11px] text-slate-300">Ajoutez instantanément les masques standards ou créez vos propres règles <code class="text-indigo-300">(prepend) + préfixe | [match pattern / ID appelant]</code>.</p>
</div>
<button type="button" onclick="addOutboundPatternRow()"
@@ -3788,19 +3789,19 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</button>
<button type="button" onclick="applyOutboundPatternPreset('fr_free')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-emerald-500/20 border border-anthracite-600 hover:border-emerald-500/40 text-xs font-semibold text-cyan-300 transition">
🟢 Numéros Verts / Gratuits (080[0-9]XXXXXX)
<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>Numéros Verts / Gratuits (080[0-9]XXXXXX)
</button>
<button type="button" onclick="applyOutboundPatternPreset('fr_emergency')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-amber-500/20 border border-anthracite-600 hover:border-amber-500/40 text-xs font-semibold text-amber-300 transition">
🚑 Numéros d'Urgence (15, 17, 18, 112, 114)
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-rose-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/></svg><span>Numéros d'Urgence (15, 17, 18, 112, 114)</span></span>
</button>
<button type="button" onclick="applyOutboundPatternPreset('international')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-indigo-500/20 border border-anthracite-600 hover:border-indigo-500/40 text-xs font-semibold text-indigo-300 transition">
🌍 International (00. & +X.)
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"/></svg><span>International (00. &amp; +X.)</span></span>
</button>
<button type="button" onclick="applyOutboundPatternPreset('all')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-purple-500/20 border border-anthracite-600 hover:border-purple-500/40 text-xs font-semibold text-purple-300 transition">
⭐ Tout autoriser (X.)
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-purple-500/20 border border-anthracite-600 hover:border-purple-500/40 text-xs font-semibold text-purple-300 transition inline-flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 shrink-0 text-purple-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"/></svg><span>Tout autoriser (X.)</span>
</button>
</div>
</div>
@@ -3851,7 +3852,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-4 rounded-xl bg-anthracite-950 border border-cyan-500/40 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-xs font-bold uppercase tracking-wider text-cyan-300">👥 Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)</div>
<div class="text-xs font-bold uppercase tracking-wider text-cyan-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0z"/></svg><span>Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)</div>
<p class="text-[11px] text-slate-300">Tous les postes rattachés à un groupe d'extensions coché ci-dessous auront le droit d'appeler via cette route sortante.</p>
</div>
<button type="button" onclick="closeModal('modal-outbound'); switchTab('extgroups'); openExtGroupModal();"
@@ -3866,7 +3867,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300">📱 Extension Routing (Postes individuels Autorisés ↔ Bloqués)</div>
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/></svg><span>Extension Routing (Postes individuels Autorisés ↔ Bloqués)</div>
<p class="text-[11px] text-slate-300">Cliquez sur un poste pour le déplacer entre <strong>Allowed Extensions</strong> et <strong>Blocked Extensions</strong>. Si un poste est bloqué ici, l'appel passera automatiquement à la Route Sortante suivante.</p>
</div>
</div>
@@ -3938,7 +3939,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
Trunk avec Enregistrement (REGISTER + Auth)
</button>
<button type="button" onclick="applyTrunkArchitecture('gateway_receive')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-600/30 border border-emerald-500/50 text-emerald-300 font-medium transition flex items-center gap-1">
<span>📟 Passerelle FXO / Ligne Box (Grandstream, Cisco SPA, AudioCodes)</span>
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg><span>Passerelle FXO / Ligne Box (Grandstream, Cisco SPA, AudioCodes)</span>
</button>
<button type="button" onclick="applyTrunkArchitecture('ip_peering')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-purple-300 font-medium transition">
Peering IP / SBC / Passerelle (Sans REGISTER)
@@ -4044,7 +4045,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Guide interactif d'aide pour Passerelles FXO (Grandstream, Cisco SPA...) -->
<div id="trunk-receive-guide" class="hidden p-3.5 rounded-xl bg-emerald-950/40 border border-emerald-500/40 text-xs text-slate-200 space-y-2">
<div class="flex items-center gap-2 font-bold text-emerald-300 text-xs">
<span>💡 Guide Rapide : Paramétrage d'une passerelle FXO (Grandstream HT813/HT503, Cisco SPA3102...)</span>
<span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg> Guide Rapide : Paramétrage d'une passerelle FXO (Grandstream HT813/HT503, Cisco SPA3102...)</span>
</div>
<p class="text-[11px] text-slate-300 leading-relaxed">
Dans l'interface web de votre passerelle (onglet <strong>FXO Port</strong> Grandstream ou <strong>Line 1/PSTN</strong> Cisco SPA relié à la prise téléphonique d'une Box Internet / modem FAI / ligne analogique) :
@@ -4181,7 +4182,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="max-w-xl">
<label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5">
<span>📞 Options d'appel Asterisk (Asterisk Trunk Dial Options)</span>
<span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg><span>Options d'appel Asterisk (Asterisk Trunk Dial Options)</span>
<span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX Dial Options</span>
</label>
<p class="text-[11px] text-slate-300">
@@ -4216,7 +4217,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="min-w-[280px]">
<select name="moh_sound_id" id="trunk-moh-sound-id"
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-amber-500 font-medium">
<option value="0">🌐 Hériter de la musique générale par défaut</option>
<option value="0">Hériter de la musique générale par défaut</option>
</select>
</div>
</div>
@@ -4225,7 +4226,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="max-w-xl">
<label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5">
<span>🏷️ Préfixe Nom Appelant (CID Name Prefix)</span>
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/></svg><span>Préfixe Nom Appelant (CID Name Prefix)</span>
<span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX CID Prefix</span>
</label>
<p class="text-[11px] text-slate-300">
@@ -4251,7 +4252,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="addArcepSpamBlockPreset()"
class="px-3 py-1.5 rounded-xl bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 text-xs font-semibold text-amber-300 transition">
🛡️ + Ajouter Pack Blocage Démarchage (0162, 0163, 0270, 0377, 0424, 0568, 0948...)
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg><span>+ Ajouter Pack Blocage Démarchage</span></span> (0162, 0163, 0270, 0377, 0424, 0568, 0948...)
</button>
<button type="button" onclick="addTrunkInboundRouteRow('Any', 'Any', 'Nouvelle règle', 'ext-101', true)"
class="px-3.5 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white shadow-md shadow-indigo-600/20 transition">
@@ -4263,7 +4264,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Barre de filtre rapide dans les règles -->
<div class="flex flex-wrap items-center justify-between gap-2 pt-1">
<input type="text" id="inbound-routes-filter" oninput="filterInboundRoutesRows(this.value)"
placeholder="🔍 Filtrer vos règles (ex: 0162, Block, VIP, Issy, Occupation...)"
placeholder="Filtrer vos règles (ex: 0162, Block, VIP, Issy, Occupation...)"
class="w-full sm:w-96 px-3 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white">
<div class="text-[11px] text-slate-400">
Syntaxe Asterisk : <code class="text-indigo-300">Any</code> = Tous • <code class="text-indigo-300">_0162XXXXXX</code> = Plage 0162 • <code class="text-indigo-300">0612345678</code> = Numéro exact
@@ -4651,7 +4652,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Colonne Choix Invalide -->
<div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3">
<div class="text-xs font-bold text-white flex items-center gap-1.5">
<span class="text-red-400">⚠️</span> En cas de Touche Invalide
<span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg><span>En cas de Touche Invalide</span></span>
</div>
<div>
@@ -4678,7 +4679,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Colonne Expiration / Timeout -->
<div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3">
<div class="text-xs font-bold text-white flex items-center gap-1.5">
<span class="text-amber-400">⏱️</span> En cas de Timeout (Aucune touche pressée)
<svg class="w-4 h-4 shrink-0 text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> En cas de Timeout (Aucune touche pressée)
</div>
<div class="grid grid-cols-2 gap-2">
@@ -5564,7 +5565,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</p>
<div id="reboot-modal-call-details" class="p-2.5 rounded-lg bg-black/50 border border-rose-500/40 text-xs font-mono text-rose-300"></div>
<div class="p-3 rounded-lg bg-rose-950/60 border border-rose-500/40 text-xs font-semibold text-rose-200 flex items-start gap-2">
<span class="text-base leading-none">⚠️</span>
<svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
<span>Si vous confirmez le redémarrage, <strong>la conversation téléphonique sera immédiatement coupée</strong> pour votre interlocuteur !</span>
</div>
</div>
+57 -29
View File
@@ -25,6 +25,28 @@ function escapeHtml(str) {
.replace(/'/g, '&#39;');
}
function ucpIcon(name, cls = 'w-4 h-4') {
const paths = {
voicemail: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/>',
bolt: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"/>',
calendar: '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/>',
clock: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>',
download: '<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>',
trash: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>',
phone: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/>',
mobile: '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>',
mail: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/>',
pencil: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"/>',
forward: '<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"/>',
pickup: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75v-4.5m0 4.5h4.5m-4.5 0l6-6m-3 18c-8.284 0-15-6.716-15-15V4.5A2.25 2.25 0 014.5 2.25h1.372c.516 0 .966.351 1.091.852l1.106 4.423c.11.44-.054.902-.417 1.173l-1.293.97a1.062 1.062 0 00-.38 1.21 12.035 12.035 0 007.143 7.143c.441.162.928-.004 1.21-.38l.97-1.293a1.125 1.125 0 011.173-.417l4.423 1.106c.5.125.852.575.852 1.091V19.5a2.25 2.25 0 01-2.25 2.25h-2.25z"/>',
dnd: '<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>',
shield: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>',
wrench: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085"/>',
tag: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/>',
};
return `<svg class="${cls} shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">${paths[name] || paths.tag}</svg>`;
}
function showUcpToast(message, type = 'info') {
const container = document.getElementById('ucp-toast-container');
if (!container) return;
@@ -232,14 +254,14 @@ function buildForwardOptions(extObj, selectedValue) {
if (Number(extObj.voicemail_enabled) === 1) {
const vmVal = `vm-${extObj.extension}`;
html += `<option value="${escapeHtml(vmVal)}" ${val === vmVal ? 'selected' : ''}>📬 Ma messagerie vocale (${escapeHtml(extObj.extension)})</option>`;
html += `<option value="${escapeHtml(vmVal)}" ${val === vmVal ? 'selected' : ''}>Ma messagerie vocale (${escapeHtml(extObj.extension)})</option>`;
}
const others = (ucpState.forward_targets || []).filter(t => t.extension !== extObj.extension);
if (others.length > 0) {
html += `<optgroup label="Postes internes">`;
others.forEach(t => {
html += `<option value="${escapeHtml(t.extension)}" ${val === t.extension ? 'selected' : ''}>📱 ${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
html += `<option value="${escapeHtml(t.extension)}" ${val === t.extension ? 'selected' : ''}>${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
});
html += `</optgroup>`;
}
@@ -249,7 +271,7 @@ function buildForwardOptions(extObj, selectedValue) {
html += `<optgroup label="Messageries vocales d'autres postes">`;
otherVms.forEach(t => {
const vVal = `vm-${t.extension}`;
html += `<option value="${escapeHtml(vVal)}" ${val === vVal ? 'selected' : ''}>📬 Répondeur ${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
html += `<option value="${escapeHtml(vVal)}" ${val === vVal ? 'selected' : ''}>Répondeur ${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
});
html += `</optgroup>`;
}
@@ -286,7 +308,8 @@ function renderExtensionsGrid() {
const vmPill = Number(ext.voicemail_enabled) === 1
? `<button type="button" onclick="openExtensionVoicemail('${escapeHtml(ext.extension)}')"
class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border transition ${ext.vm_inbox_count > 0 ? 'bg-rose-50 border-rose-200 text-rose-700 hover:bg-rose-100' : 'bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100'}">
📬 ${ext.vm_inbox_count} nouveau(x)
${ucpIcon('voicemail', 'w-3.5 h-3.5')}
<span>${ext.vm_inbox_count} nouveau(x)</span>
</button>`
: '';
@@ -374,13 +397,13 @@ function renderExtensionsGrid() {
<div class="flex flex-wrap items-center gap-2">
<a href="${escapeHtml(ext.groundwire_deeplink_url)}"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 text-xs font-semibold transition flex-1 sm:flex-initial">
<span>⚡</span>
${ucpIcon('bolt', 'w-3.5 h-3.5')}
<span>Configurer dans Groundwire</span>
</a>
<a href="${escapeHtml(ext.groundwire_xml_url)}"
download="groundwire-${escapeHtml(ext.extension)}.xml"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 text-xs font-medium transition">
<span>⬇</span>
${ucpIcon('download', 'w-3.5 h-3.5')}
<span>Télécharger le XML</span>
</a>
</div>
@@ -546,21 +569,25 @@ function renderVoicemailsList() {
<span class="font-bold text-slate-900 text-sm">${escapeHtml(msg.callerid)}</span>
${badge}
</div>
<div class="text-xs text-slate-500 mt-0.5">
📅 ${escapeHtml(msg.date_human)} • ⏱️ ${formatDuration(msg.duration)}
<div class="text-xs text-slate-500 mt-0.5 flex items-center gap-2 flex-wrap">
<span class="inline-flex items-center gap-1">${ucpIcon('calendar', 'w-3.5 h-3.5 text-slate-400')} ${escapeHtml(msg.date_human)}</span>
<span>•</span>
<span class="inline-flex items-center gap-1">${ucpIcon('clock', 'w-3.5 h-3.5 text-slate-400')} ${formatDuration(msg.duration)}</span>
</div>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<a href="${dlUrl}" class="px-2.5 py-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-[11px] font-semibold text-slate-700 transition">
⬇️ Télécharger
<a href="${dlUrl}" class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-[11px] font-semibold text-slate-700 transition">
${ucpIcon('download', 'w-3.5 h-3.5 text-slate-500')}
<span>Télécharger</span>
</a>
<button type="button" onclick="toggleVmRead('${escapeHtml(msg.extension)}', '${escapeHtml(msg.folder)}', '${escapeHtml(msg.id)}')"
class="px-2.5 py-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-[11px] font-semibold text-slate-700 transition">
${msg.is_new ? '✓ Marquer lu' : '↺ Marquer non lu'}
</button>
<button type="button" onclick="deleteVm('${escapeHtml(msg.extension)}', '${escapeHtml(msg.folder)}', '${escapeHtml(msg.id)}')"
class="px-2.5 py-1.5 rounded-lg border border-rose-200 bg-rose-50 hover:bg-rose-100 text-[11px] font-semibold text-rose-700 transition">
🗑️ Supprimer
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-rose-200 bg-rose-50 hover:bg-rose-100 text-[11px] font-semibold text-rose-700 transition">
${ucpIcon('trash', 'w-3.5 h-3.5')}
<span>Supprimer</span>
</button>
</div>
</div>
@@ -722,7 +749,7 @@ async function loadPersonalCdr() {
${dirBadge}
${stBadge}
</div>
<span class="font-mono text-xs font-semibold text-slate-700">⏱ ${durText}</span>
<span class="inline-flex items-center gap-1 font-mono text-xs font-semibold text-slate-700">${ucpIcon('clock', 'w-3.5 h-3.5 text-slate-400')} ${durText}</span>
</div>
<div class="text-xs leading-snug break-words flex flex-wrap items-center gap-1.5">
<span>${formatInlineParty(srcName, srcNum)}</span>
@@ -864,17 +891,18 @@ function renderPhonebookGrid() {
</div>
<div class="text-[11px] text-slate-400">${escapeHtml(c.group_name || 'Général')}</div>
<div class="mt-2 space-y-1 text-xs text-slate-600">
${c.internal_number ? `<div class="font-mono">📱 Interne : <strong class="text-slate-900">${escapeHtml(c.internal_number)}</strong></div>` : ''}
${c.mobile_number ? `<div class="font-mono">📲 Mobile : ${escapeHtml(c.mobile_number)}</div>` : ''}
${c.other_number ? `<div class="font-mono">☎️ Autre : ${escapeHtml(c.other_number)}</div>` : ''}
${c.email ? `<div class="truncate">✉️ ${escapeHtml(c.email)}</div>` : ''}
${c.internal_number ? `<div class="font-mono flex items-center gap-1.5">${ucpIcon('phone', 'w-3.5 h-3.5 text-indigo-500')} Interne : <strong class="text-slate-900">${escapeHtml(c.internal_number)}</strong></div>` : ''}
${c.mobile_number ? `<div class="font-mono flex items-center gap-1.5">${ucpIcon('mobile', 'w-3.5 h-3.5 text-emerald-500')} Mobile : ${escapeHtml(c.mobile_number)}</div>` : ''}
${c.other_number ? `<div class="font-mono flex items-center gap-1.5">${ucpIcon('phone', 'w-3.5 h-3.5 text-slate-400')} Autre : ${escapeHtml(c.other_number)}</div>` : ''}
${c.email ? `<div class="truncate flex items-center gap-1.5">${ucpIcon('mail', 'w-3.5 h-3.5 text-slate-400')} <span class="truncate">${escapeHtml(c.email)}</span></div>` : ''}
</div>
</div>
</div>
${c.is_mine ? `
<button type="button" onclick="openProfileModal('${escapeHtml(c.mine_extension)}')"
class="w-full py-1.5 px-3 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 text-xs font-semibold border border-indigo-200 transition">
✏️ Modifier ma photo / mon e-mail
class="w-full py-1.5 px-3 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 text-xs font-semibold border border-indigo-200 transition inline-flex items-center justify-center gap-1.5">
${ucpIcon('pencil', 'w-3.5 h-3.5')}
<span>Modifier ma photo / mon e-mail</span>
</button>
` : ''}
</div>
@@ -996,7 +1024,7 @@ function renderFeatureGuide() {
quickBox.innerHTML = `
<div class="ucp-print-avoid-break rounded-2xl border border-indigo-200 bg-indigo-50/70 p-4 space-y-2">
<div class="flex items-center gap-2 text-xs font-bold text-indigo-900 uppercase tracking-wide">
<span>📬</span>
${ucpIcon('voicemail', 'w-4 h-4 text-indigo-600')}
<span>Utiliser la Messagerie Vocale</span>
</div>
<ul class="text-[11px] text-slate-700 space-y-1.5 leading-relaxed">
@@ -1009,7 +1037,7 @@ function renderFeatureGuide() {
<div class="ucp-print-avoid-break rounded-2xl border border-amber-200 bg-amber-50/70 p-4 space-y-2">
<div class="flex items-center gap-2 text-xs font-bold text-amber-900 uppercase tracking-wide">
<span>↪️</span>
${ucpIcon('forward', 'w-4 h-4 text-amber-600')}
<span>Gérer Renvois &amp; Ne Pas Déranger</span>
</div>
<ul class="text-[11px] text-slate-700 space-y-1.5 leading-relaxed">
@@ -1022,7 +1050,7 @@ function renderFeatureGuide() {
<div class="ucp-print-avoid-break rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 space-y-2">
<div class="flex items-center gap-2 text-xs font-bold text-emerald-900 uppercase tracking-wide">
<span>🎧</span>
${ucpIcon('pickup', 'w-4 h-4 text-emerald-600')}
<span>Interception &amp; Prise d'Appel</span>
</div>
<ul class="text-[11px] text-slate-700 space-y-1.5 leading-relaxed">
@@ -1049,12 +1077,12 @@ function renderFeatureGuide() {
}
const catMeta = {
voicemail: { label: 'Messagerie Vocale', icon: '📬', badge: 'bg-indigo-50 text-indigo-700 border-indigo-200', header: 'bg-indigo-50/60 border-indigo-100 text-indigo-950' },
forward: { label: "Renvois d'Appel", icon: '↪️', badge: 'bg-amber-50 text-amber-800 border-amber-200', header: 'bg-amber-50/60 border-amber-100 text-amber-950' },
dnd: { label: 'Ne Pas Déranger (DND)', icon: '🔕', badge: 'bg-rose-50 text-rose-700 border-rose-200', header: 'bg-rose-50/60 border-rose-100 text-rose-950' },
pickup: { label: "Interception d'Appels", icon: '📲', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', header: 'bg-emerald-50/60 border-emerald-100 text-emerald-950' },
security: { label: 'Liste Noire & Blocage Appels', icon: '🛡️', badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-100/80 border-slate-200 text-slate-900' },
tools: { label: 'Outils, Tests Audio & Services', icon: '🛠️', badge: 'bg-sky-50 text-sky-700 border-sky-200', header: 'bg-sky-50/60 border-sky-100 text-sky-950' },
voicemail: { label: 'Messagerie Vocale', icon: ucpIcon('voicemail', 'w-4 h-4 text-indigo-600'), badge: 'bg-indigo-50 text-indigo-700 border-indigo-200', header: 'bg-indigo-50/60 border-indigo-100 text-indigo-950' },
forward: { label: "Renvois d'Appel", icon: ucpIcon('forward', 'w-4 h-4 text-amber-600'), badge: 'bg-amber-50 text-amber-800 border-amber-200', header: 'bg-amber-50/60 border-amber-100 text-amber-950' },
dnd: { label: 'Ne Pas Déranger (DND)', icon: ucpIcon('dnd', 'w-4 h-4 text-rose-600'), badge: 'bg-rose-50 text-rose-700 border-rose-200', header: 'bg-rose-50/60 border-rose-100 text-rose-950' },
pickup: { label: "Interception d'Appels", icon: ucpIcon('pickup', 'w-4 h-4 text-emerald-600'), badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', header: 'bg-emerald-50/60 border-emerald-100 text-emerald-950' },
security: { label: 'Liste Noire & Blocage Appels', icon: ucpIcon('shield', 'w-4 h-4 text-slate-600'), badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-100/80 border-slate-200 text-slate-900' },
tools: { label: 'Outils, Tests Audio & Services', icon: ucpIcon('wrench', 'w-4 h-4 text-sky-600'), badge: 'bg-sky-50 text-sky-700 border-sky-200', header: 'bg-sky-50/60 border-sky-100 text-sky-950' },
};
const orderedCats = ['voicemail', 'forward', 'dnd', 'pickup', 'security', 'tools'];
@@ -1071,7 +1099,7 @@ function renderFeatureGuide() {
];
catBox.innerHTML = catKeys.map(catKey => {
const meta = catMeta[catKey] || { label: catKey, icon: '📌', badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-50 border-slate-200 text-slate-900' };
const meta = catMeta[catKey] || { label: catKey, icon: ucpIcon('tag', 'w-4 h-4 text-slate-500'), badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-50 border-slate-200 text-slate-900' };
const items = grouped.get(catKey) || [];
const rows = items.map(item => `
+21 -9
View File
@@ -122,7 +122,9 @@ $csrfToken = UcpAuth::getCsrfToken();
<?php if (!$ucpEnabled): ?>
<div class="min-h-screen flex items-center justify-center p-6">
<div class="max-w-md w-full ucp-card rounded-2xl p-8 text-center space-y-3">
<div class="w-12 h-12 mx-auto rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 text-2xl">🔒</div>
<div class="w-12 h-12 mx-auto rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
</div>
<h1 class="text-lg font-bold text-slate-900">Portail Utilisateur désactivé</h1>
<p class="text-sm text-slate-500">Ce service a été temporairement désactivé par votre administrateur système.</p>
</div>
@@ -216,29 +218,35 @@ $csrfToken = UcpAuth::getCsrfToken();
<nav class="flex items-center gap-1 py-2 whitespace-nowrap pr-6 sm:pr-0" id="ucp-nav">
<button type="button" data-ucp-tab="extensions" onclick="switchUcpTab('extensions')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700">
<span>📱 Mes Postes &amp; Renvois</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/></svg>
<span>Mes Postes &amp; Renvois</span>
<span id="ucp-badge-exts" class="px-1.5 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold">0</span>
</button>
<button type="button" data-ucp-tab="voicemail" onclick="switchUcpTab('voicemail')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<span>📬 Messagerie Vocale</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg>
<span>Messagerie Vocale</span>
<span id="ucp-badge-vm" class="px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold">0</span>
</button>
<button type="button" data-ucp-tab="cdr" onclick="switchUcpTab('cdr')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<span>📞 Mes Appels</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg>
<span>Mes Appels</span>
</button>
<button type="button" data-ucp-tab="phonebook" onclick="switchUcpTab('phonebook')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<span>📖 Annuaire &amp; Profil</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
<span>Annuaire &amp; Profil</span>
</button>
<button type="button" data-ucp-tab="guide" onclick="switchUcpTab('guide')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<span>📋 Notice &amp; Codes</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z"/></svg>
<span>Notice &amp; Codes</span>
</button>
<button type="button" data-ucp-tab="account" onclick="switchUcpTab('account')"
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
<span>🔐 Mon Compte</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
<span>Mon Compte</span>
</button>
</nav>
</div>
@@ -451,7 +459,10 @@ $csrfToken = UcpAuth::getCsrfToken();
<!-- Pied de document -->
<div class="ucp-print-avoid-break pt-4 border-t border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-[11px] text-slate-500">
<div>💡 <strong>Astuce :</strong> composez ces codes directement sur votre combiné IP, ou pilotez DND / renvois / messagerie depuis le portail UCP.</div>
<div class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-amber-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
<span><strong>Astuce :</strong> composez ces codes directement sur votre combiné IP, ou pilotez DND / renvois / messagerie depuis le portail UCP.</span>
</div>
<div class="font-semibold text-slate-600"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Document interne</div>
</div>
</div>
@@ -531,7 +542,8 @@ $csrfToken = UcpAuth::getCsrfToken();
<div id="ucp-profile-initials" class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-700 font-bold flex items-center justify-center text-lg">?</div>
<div class="flex-1 min-w-0">
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white border border-slate-300 hover:bg-slate-100 text-xs font-semibold text-slate-700 cursor-pointer transition">
📷 Choisir une photo
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"/><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"/></svg>
<span>Choisir une photo</span>
<input type="file" id="ucp-avatar-input" accept="image/jpeg,image/png,image/webp" class="hidden" onchange="uploadSelectedAvatar()">
</label>
<p class="text-[11px] text-slate-400 mt-1">JPG, PNG ou WebP • 5 Mo max.</p>
+9 -1
View File
@@ -1,9 +1,17 @@
{
"version": "2.6.6",
"version": "2.6.7",
"codename": "Pulsar UCP",
"release_date": "2026-11-12",
"min_php_version": "8.2",
"changelog": [
{
"version": "2.6.7",
"date": "2026-11-12",
"changes": [
"UI Admin & UCP : Remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes (style Heroicons), sans modifier le menu latéral Admin",
"Correction d'un problème de syntaxe JS sur le badge d'avertissement TLS dans app.js"
]
},
"UI v2.6.6 : menu déroulant personnalisé sombre avec icônes SVG Heroicons pour toutes les destinations d'appel (sans emojis natifs)",
"UI v2.6.5 : refonte des intitulés de destinations audio (Musique d'attente & Annonce vocale en boucle continue), nouveau wallpaper clair UCP et placeholder générique",
"Correctif v2.6.4 : prise en charge multi-groupes dans l'annuaire UCP & Admin (un poste appartenant à plusieurs groupes apparaît bien dans chacun des onglets correspondants)",