Feature: add Paging & Intercom groups (Téléavertissement) with auto-answer headers, Asterisk Page application, and interactive member selection

This commit is contained in:
sorcier committed 2026-11-02 17:01:02 +01:00
1 parent 33ad75d571
commit 2bd1ae0777
5 files changed
+610

No files matched your search

+107
View File
@@ -90,6 +90,7 @@ try {
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch();
$extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$pagingGroups = $db->query('SELECT * FROM paging_groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$extensionGroups = $db->query('SELECT * FROM extension_groups ORDER BY id ASC')->fetchAll();
$trunks = $db->query('SELECT * FROM trunks ORDER BY id ASC')->fetchAll();
$ivrs = $db->query('SELECT * FROM ivrs ORDER BY id ASC')->fetchAll();
@@ -102,6 +103,11 @@ try {
}
unset($g);
foreach ($pagingGroups as &$pg) {
$pg['members'] = json_decode((string)$pg['members'], true) ?: [];
}
unset($pg);
foreach ($extensionGroups as &$eg) {
$eg['members'] = json_decode((string)$eg['members'], true) ?: [];
}
@@ -136,6 +142,7 @@ try {
'settings' => $settings,
'extensions' => $extensions,
'groups' => $groups,
'paging_groups' => $pagingGroups,
'extension_groups' => $extensionGroups,
'trunks' => $trunks,
'ivrs' => $ivrs,
@@ -441,6 +448,106 @@ try {
]);
break;
case 'save_paging_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$extension = trim((string)($payload['extension'] ?? ''));
$name = trim((string)($payload['name'] ?? ''));
$desc = trim((string)($payload['description'] ?? ''));
$members = is_array($payload['members'] ?? null) ? array_values(array_filter(array_map('trim', $payload['members']))) : [];
$duplex = !empty($payload['duplex']) ? 1 : 0;
$busyMode = in_array($payload['busy_mode'] ?? '', ['skip', 'force'], true) ? $payload['busy_mode'] : 'skip';
$playBeep = isset($payload['play_beep']) ? (!empty($payload['play_beep']) ? 1 : 0) : 1;
$timeout = max(10, min(600, (int)($payload['timeout'] ?? 120)));
$enabled = isset($payload['enabled']) ? (!empty($payload['enabled']) ? 1 : 0) : 1;
if (!preg_match('/^\d{2,6}$/', $extension)) {
throw new InvalidArgumentException("Le numéro du groupe de téléavertissement doit comporter entre 2 et 6 chiffres.");
}
if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe de téléavertissement est obligatoire.");
}
if (empty($members)) {
throw new InvalidArgumentException("Veuillez sélectionner au moins un poste membre pour ce groupe de téléavertissement.");
}
// Vérification de conflit avec un poste SIP existant
$chkExt = $db->prepare('SELECT COUNT(*) FROM extensions WHERE extension = :ext');
$chkExt->execute([':ext' => $extension]);
if ((int)$chkExt->fetchColumn() > 0) {
throw new InvalidArgumentException("Le numéro {$extension} est déjà assigné à un poste SIP individuel.");
}
// Vérification de conflit avec un groupe de sonnerie
$chkGrp = $db->prepare('SELECT COUNT(*) FROM groups WHERE extension = :ext');
$chkGrp->execute([':ext' => $extension]);
if ((int)$chkGrp->fetchColumn() > 0) {
throw new InvalidArgumentException("Le numéro {$extension} est déjà assigné à un groupe de sonnerie.");
}
if ($id) {
$stmt = $db->prepare('
UPDATE paging_groups
SET extension = :ext, name = :name, description = :desc,
members = :members, duplex = :duplex, busy_mode = :busy_mode,
play_beep = :play_beep, timeout = :timeout, enabled = :enabled,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':ext' => $extension,
':name' => $name,
':desc' => $desc,
':members' => json_encode($members),
':duplex' => $duplex,
':busy_mode' => $busyMode,
':play_beep' => $playBeep,
':timeout' => $timeout,
':enabled' => $enabled,
]);
} else {
$stmt = $db->prepare('
INSERT INTO paging_groups (extension, name, description, members, duplex, busy_mode, play_beep, timeout, enabled)
VALUES (:ext, :name, :desc, :members, :duplex, :busy_mode, :play_beep, :timeout, :enabled)
ON CONFLICT(extension) DO UPDATE SET
name = excluded.name,
description = excluded.description,
members = excluded.members,
duplex = excluded.duplex,
busy_mode = excluded.busy_mode,
play_beep = excluded.play_beep,
timeout = excluded.timeout,
enabled = excluded.enabled,
updated_at = CURRENT_TIMESTAMP
');
$stmt->execute([
':ext' => $extension,
':name' => $name,
':desc' => $desc,
':members' => json_encode($members),
':duplex' => $duplex,
':busy_mode' => $busyMode,
':play_beep' => $playBeep,
':timeout' => $timeout,
':enabled' => $enabled,
]);
}
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement {$extension} ({$name}) sauvegardé.",
]);
break;
case 'delete_paging_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM paging_groups WHERE id = :id')->execute([':id' => $id]);
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement supprimé.",
]);
break;
case 'save_extension_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$name = trim((string)($payload['name'] ?? ''));
+210
View File
@@ -4,6 +4,7 @@ let appState = {
settings: {},
extensions: [],
groups: [],
paging_groups: [],
extension_groups: [],
trunks: [],
ivrs: [],
@@ -200,6 +201,7 @@ function renderAll() {
renderExtensionsTable();
renderGroupsTable();
renderExtensionGroupsTable();
renderPagingTable();
renderPhonebookTable();
renderTrunksTable();
renderOutboundRoutesTable();
@@ -341,6 +343,8 @@ function renderSummaryCards() {
document.getElementById('nav-badge-groups').textContent = grps.length;
const egBadge = document.getElementById('nav-badge-extgroups');
if (egBadge) egBadge.textContent = (appState.extension_groups || []).length;
const pagingBadge = document.getElementById('nav-badge-paging');
if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length;
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length;
document.getElementById('nav-badge-trunks').textContent = trks.length;
@@ -1379,6 +1383,178 @@ async function deleteExtGroup(id, optName = null) {
}
}
// --- 2C. Rendu & CRUD Groupes de Téléavertissement & Interphonie (Paging) ---
function renderPagingTable() {
const tbody = document.getElementById('paging-tbody');
if (!tbody) return;
const rows = appState.paging_groups || [];
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="py-8 text-center text-slate-400 text-xs">Aucun groupe de téléavertissement configuré. Cliquez sur « + Nouveau Groupe de Téléavertissement ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(pg => {
const members = Array.isArray(pg.members) ? pg.members : [];
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-purple-500/20 text-purple-200 border border-purple-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
const isDuplex = Number(pg.duplex) === 1;
const typeBadge = isDuplex
? `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">Duplex (2 voies)</span>`
: `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/30">Simplex (Haut-parleur)</span>`;
const busyLabel = pg.busy_mode === 'force'
? `<span class="text-rose-300 text-xs font-semibold">Forcer</span>`
: `<span class="text-emerald-300 text-xs font-semibold">Ignorer si occupé</span>`;
const beepLabel = Number(pg.play_beep) === 1
? `<span class="text-emerald-400 text-xs font-semibold">✓ Oui</span>`
: `<span class="text-slate-400 text-xs">Non</span>`;
const statusBadge = Number(pg.enabled) === 1
? `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Actif</span>`
: `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300 border border-slate-600">Inactif</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-purple-300 text-base">${escapeHtml(pg.extension)}</td>
<td class="py-3.5 px-4">
<div class="font-bold text-white">${escapeHtml(pg.name)}</div>
${pg.description ? `<div class="text-xs text-slate-400">${escapeHtml(pg.description)}</div>` : ''}
</td>
<td class="py-3.5 px-4">${typeBadge}</td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4">${busyLabel}</td>
<td class="py-3.5 px-4">${beepLabel}</td>
<td class="py-3.5 px-4">${statusBadge}</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editPagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-purple-300 hover:text-purple-200">Modifier</button>
<button type="button" onclick="deletePagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).join('');
}
let activePagingSelectedExts = new Set();
function openPagingModal(pg = null) {
const form = document.getElementById('form-paging');
if (!form) return;
form.reset();
document.getElementById('modal-paging-title').textContent = pg
? `Modifier le téléavertissement ${pg.name} (${pg.extension})`
: "Nouveau Groupe de Téléavertissement";
form.elements['id'].value = pg ? pg.id : '';
form.elements['extension'].value = pg ? pg.extension : '';
form.elements['name'].value = pg ? pg.name : '';
form.elements['description'].value = pg ? (pg.description || '') : '';
form.elements['duplex'].value = pg ? String(pg.duplex) : '0';
form.elements['busy_mode'].value = pg ? (pg.busy_mode || 'skip') : 'skip';
form.elements['timeout'].value = pg ? (pg.timeout || 120) : 120;
if (form.elements['play_beep']) {
form.elements['play_beep'].checked = pg ? Number(pg.play_beep) === 1 : true;
}
if (form.elements['enabled']) {
form.elements['enabled'].checked = pg ? Number(pg.enabled) === 1 : true;
}
activePagingSelectedExts = new Set(pg && Array.isArray(pg.members) ? pg.members.map(String) : []);
renderPagingInteractiveBoxes();
openModal('modal-paging');
}
function renderPagingInteractiveBoxes() {
const allowedBox = document.getElementById('paging-members-allowed-box');
const blockedBox = document.getElementById('paging-members-blocked-box');
const hiddenBox = document.getElementById('paging-members-checkboxes');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
let hiddenHtml = '';
let allowedCount = 0;
let blockedCount = 0;
allExts.forEach(ext => {
const extStr = String(ext.extension);
const isSel = activePagingSelectedExts.has(extStr);
if (isSel) {
allowedCount++;
allowedHtml += `
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-purple-500/20 hover:bg-red-500/20 border border-purple-500/40 hover:border-red-500/40 text-xs font-semibold text-purple-300 hover:text-red-300 transition cursor-pointer">
<span class="font-mono font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
</button>
`;
hiddenHtml += `<input type="checkbox" name="members[]" value="${escapeHtml(extStr)}" checked>`;
} else {
blockedCount++;
blockedHtml += `
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-purple-500/20 border border-anthracite-700 hover:border-purple-500/40 text-xs font-medium text-slate-300 hover:text-purple-300 transition cursor-pointer">
<span class="font-mono text-purple-300 font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-500 group-hover:text-purple-300 ml-1 font-bold">+</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste qui reçoit l'annonce. Cliquez sur un poste à droite pour l'ajouter.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes reçoivent l'annonce !</div>`;
if (hiddenBox) hiddenBox.innerHTML = hiddenHtml;
const countA = document.getElementById('paging-selected-count');
const countB = document.getElementById('paging-unselected-count');
if (countA) countA.textContent = String(allowedCount);
if (countB) countB.textContent = String(blockedCount);
}
function togglePagingMemberExt(extStr) {
if (activePagingSelectedExts.has(extStr)) {
activePagingSelectedExts.delete(extStr);
} else {
activePagingSelectedExts.add(extStr);
}
renderPagingInteractiveBoxes();
}
function pagingTransferAllExtensions(includeAll) {
const allExts = appState.extensions || [];
if (includeAll) {
allExts.forEach(e => activePagingSelectedExts.add(String(e.extension)));
} else {
activePagingSelectedExts.clear();
}
renderPagingInteractiveBoxes();
}
function editPagingGroup(id) {
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
if (pg) openPagingModal(pg);
}
async function deletePagingGroup(id) {
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
const num = pg ? pg.extension : 'ce groupe';
if (!confirm(`Supprimer le groupe de téléavertissement ${num} ?`)) return;
try {
const res = await apiCall('delete_paging_group', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 3. Rendu & CRUD Trunks SIP (Configuration PJSIP Avancée) ---
function slugifyTrunkName(text) {
return String(text || '')
@@ -1512,6 +1688,7 @@ function toggleTrunkAuthFields(mode) {
function normalizeDestinationValue(rawVal) {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const paging = appState.paging_groups || [];
let normVal = String(rawVal || '').trim();
if (!normVal) return 'ext-101';
if (normVal === 'busy') return 'terminate-busy';
@@ -1521,6 +1698,7 @@ function normalizeDestinationValue(rawVal) {
if (/^\d+$/.test(normVal)) {
if (exts.some(e => String(e.extension) === normVal)) return `ext-${normVal}`;
if (grps.some(g => String(g.extension) === normVal)) return `grp-${normVal}`;
if (paging.some(p => String(p.extension) === normVal)) return `page-${normVal}`;
}
return normVal;
}
@@ -1528,6 +1706,7 @@ function normalizeDestinationValue(rawVal) {
function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const paging = appState.paging_groups || [];
const ivrs = appState.ivrs || [];
const sounds = appState.sounds || [];
const normVal = normalizeDestinationValue(selectedValue);
@@ -1551,6 +1730,15 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
html += `</optgroup>`;
}
if (paging.length > 0) {
html += `<optgroup label="📢 Téléavertissement & Interphonie (Paging)">`;
paging.forEach(pg => {
const val = `page-${pg.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Paging: ${escapeHtml(pg.extension)} — ${escapeHtml(pg.name)}</option>`;
});
html += `</optgroup>`;
}
if (ivrs.length > 0) {
html += `<optgroup label="🎙️ Serveurs Vocaux (SVI / IVR)">`;
ivrs.forEach(i => {
@@ -3131,6 +3319,7 @@ const PAGE_TITLES = {
extensions: 'Postes Téléphoniques SIP (PJSIP)',
groups: "Groupes de Sonnerie (Ring Groups)",
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
paging: 'Groupes de Téléavertissement & Interphonie (Paging)',
trunks: 'Trunks Opérateurs SIP',
outbound: 'Routes Sortantes (Outbound Routes & Séquence Trunks)',
ivrs: 'Serveurs Vocaux Interactifs (SVI / IVR)',
@@ -3366,6 +3555,27 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Form Groupe de Téléavertissement (Paging)
document.getElementById('form-paging')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.members = Array.from(activePagingSelectedExts);
payload.duplex = Number(payload.duplex || 0);
payload.timeout = Number(payload.timeout || 120);
payload.play_beep = e.target.querySelector('input[name="play_beep"]')?.checked ? 1 : 0;
payload.enabled = e.target.querySelector('input[name="enabled"]')?.checked ? 1 : 0;
try {
const res = await apiCall('save_paging_group', payload);
showToast(res.message, 'success');
closeModal('modal-paging');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Trunk (Configuration PJSIP complète)
document.getElementById('form-trunk')?.addEventListener('submit', async (e) => {
e.preventDefault();
+164
View File
@@ -289,6 +289,11 @@ $currentUser = Auth::getUsername();
<span>• Groupes d'extensions</span>
<span id="nav-badge-extgroups" class="px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-cyan-300 border border-anthracite-700 font-bold">0</span>
</button>
<button type="button" data-tab="paging" onclick="switchTab('paging')"
class="tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition">
<span>• Téléavertissement (Paging)</span>
<span id="nav-badge-paging" class="px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-purple-300 border border-anthracite-700 font-bold">0</span>
</button>
<button type="button" data-tab="phonebook" onclick="switchTab('phonebook')"
class="tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition">
<span>• Annuaire d'Entreprise</span>
@@ -1140,6 +1145,65 @@ $currentUser = Auth::getUsername();
</div>
</section>
<!-- ========================================================= -->
<!-- ONGLET 2D : TÉLÉAVERTISSEMENT & INTERPHONIE (PAGING) -->
<!-- ========================================================= -->
<section id="tab-paging" class="tab-panel hidden space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h2 class="text-lg font-bold text-white">Groupes de Téléavertissement & Interphonie (Paging)</h2>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Auto-décroché immédiat</span>
</div>
<p class="text-xs text-slate-300">Diffusion d'annonces vocales en direct sur les haut-parleurs des téléphones IP sélectionnés. Composez le numéro du groupe (ex: <code class="text-purple-300 font-mono font-bold">100</code>) pour parler à tous les postes simultanément.</p>
</div>
<div class="flex items-center gap-2">
<button type="button" onclick="openPagingModal()"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs sm:text-sm font-semibold bg-purple-600 hover:bg-purple-500 text-white shadow-lg shadow-purple-600/20 transition">
<span>+ Nouveau Groupe de Téléavertissement</span>
</button>
</div>
</div>
<!-- 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="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="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>
</div>
</div>
</div>
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl overflow-hidden shadow-lg">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-anthracite-700 text-[11px] font-semibold uppercase tracking-wider text-slate-300 bg-anthracite-950/60">
<th class="py-3.5 px-4">Numéro (Extension)</th>
<th class="py-3.5 px-4">Nom du Groupe</th>
<th class="py-3.5 px-4">Mode Audio</th>
<th class="py-3.5 px-4">Postes Membres Associés</th>
<th class="py-3.5 px-4">Si Occupé</th>
<th class="py-3.5 px-4">Bip</th>
<th class="py-3.5 px-4">Statut</th>
<th class="py-3.5 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody id="paging-tbody" class="divide-y divide-anthracite-700/70 text-sm"></tbody>
</table>
</div>
</div>
</section>
<!-- ========================================================= -->
<!-- ONGLET 3 : TRUNKS SIP -->
<!-- ========================================================= -->
@@ -2813,6 +2877,106 @@ $currentUser = Auth::getUsername();
</div>
<!-- MODALE CRUD GROUPE DE TÉLÉAVERTISSEMENT & INTERPHONIE (PAGING) -->
<div id="modal-paging" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/75 backdrop-blur-sm p-3 sm:p-4 overflow-y-auto">
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl w-[94vw] max-w-4xl p-6 shadow-2xl my-auto">
<div class="flex items-center justify-between pb-3 mb-4 border-b border-anthracite-700">
<div>
<h3 id="modal-paging-title" class="text-base sm:text-lg font-bold text-white">Groupe de Téléavertissement (Paging / Intercom)</h3>
<p class="text-xs text-slate-300">Diffusion d'annonces vocales directes avec auto-décroché immédiat sur haut-parleur.</p>
</div>
<button type="button" onclick="closeModal('modal-paging')" class="text-slate-300 hover:text-white text-lg">✕</button>
</div>
<form id="form-paging" class="space-y-4">
<input type="hidden" name="id">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-4">
<div class="sm:col-span-3">
<label class="block text-xs font-medium text-slate-200 mb-1">Numéro d'appel (Extension) *</label>
<input type="text" name="extension" placeholder="ex: 100" required
class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-purple-300 font-mono font-bold">
</div>
<div class="sm:col-span-5">
<label class="block text-xs font-medium text-slate-200 mb-1">Nom du groupe *</label>
<input type="text" name="name" placeholder="ex: Téléavertissement Général, Atelier..." required
class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white">
</div>
<div class="sm:col-span-4">
<label class="block text-xs font-medium text-slate-200 mb-1">Description (optionnel)</label>
<input type="text" name="description" placeholder="ex: Annonce vocale globale"
class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-medium text-slate-200 mb-1">Mode Audio</label>
<select name="duplex" class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white">
<option value="0">Simplex (Haut-parleur seul / Micro coupé)</option>
<option value="1">Duplex (Interphonie 2 voies / Conversation)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-200 mb-1">Gestion des Postes Occupés</label>
<select name="busy_mode" class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white">
<option value="skip">Ignorer si le poste est en communication</option>
<option value="force">Forcer l'annonce même si en ligne</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-200 mb-1">Durée maximale d'annonce (sec)</label>
<input type="number" name="timeout" value="120" min="10" max="600" required
class="w-full px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-sm text-white">
</div>
</div>
<div class="flex flex-wrap items-center gap-6 p-3 rounded-xl bg-anthracite-950 border border-anthracite-700">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="play_beep" value="1" checked class="w-4 h-4 rounded text-purple-600 focus:ring-purple-500 bg-anthracite-900 border-anthracite-700">
<span class="text-xs font-medium text-slate-200">Émettre un bip sonore d'avertissement avant la prise de parole</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="enabled" value="1" checked class="w-4 h-4 rounded text-emerald-600 focus:ring-emerald-500 bg-anthracite-900 border-anthracite-700">
<span class="text-xs font-medium text-slate-200">Activer ce groupe de téléavertissement</span>
</label>
</div>
<!-- Sélection Double Liste Interactive : Postes qui reçoivent l'annonce -->
<div class="p-4 rounded-xl bg-anthracite-950 border border-purple-500/40 space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs font-bold uppercase tracking-wider text-purple-300">Postes membres qui recevront l'annonce (Cliquez pour inclure / retirer)</span>
<div class="flex gap-2">
<button type="button" onclick="pagingTransferAllExtensions(true)" class="px-2.5 py-1 rounded-lg bg-purple-500/15 hover:bg-purple-500/25 border border-purple-500/30 text-[11px] font-semibold text-purple-300">← Tout inclure</button>
<button type="button" onclick="pagingTransferAllExtensions(false)" class="px-2.5 py-1 rounded-lg bg-anthracite-800 hover:bg-anthracite-700 border border-anthracite-600 text-[11px] font-semibold text-slate-300">Tout retirer →</button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="p-3 rounded-xl bg-anthracite-900 border border-purple-500/30 min-h-[130px]">
<div class="text-xs font-bold text-purple-300 mb-2 flex items-center justify-between">
<span>✓ Postes ciblés par le téléavertissement</span>
<span id="paging-selected-count" class="px-2 py-0.2 rounded-full bg-purple-500/20 text-[10px]">0</span>
</div>
<div id="paging-members-allowed-box" class="flex flex-wrap gap-1.5"></div>
</div>
<div class="p-3 rounded-xl bg-anthracite-900 border border-anthracite-700 min-h-[130px]">
<div class="text-xs font-bold text-slate-400 mb-2 flex items-center justify-between">
<span>Autres postes disponibles</span>
<span id="paging-unselected-count" class="px-2 py-0.2 rounded-full bg-anthracite-800 text-[10px]">0</span>
</div>
<div id="paging-members-blocked-box" class="flex flex-wrap gap-1.5"></div>
</div>
</div>
<div id="paging-members-checkboxes" class="hidden"></div>
</div>
<div class="pt-3 border-t border-anthracite-700 flex justify-end gap-2">
<button type="button" onclick="closeModal('modal-paging')" class="px-4 py-2 rounded-xl bg-anthracite-700 text-slate-200 text-xs font-semibold">Annuler</button>
<button type="submit" class="px-4 py-2 rounded-xl bg-purple-600 hover:bg-purple-500 text-white text-xs font-semibold">Enregistrer le groupe de téléavertissement</button>
</div>
</form>
</div>
</div>
<!-- MODALE CRUD ROUTE SORTANTE (OUTBOUND ROUTE : TRUNK SEQUENCE, DIAL PATTERNS, EXTENSION & GROUP ROUTING) -->
<div id="modal-outbound" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/75 backdrop-blur-sm p-2 sm:p-4 overflow-y-auto">
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl w-[96vw] max-w-[1380px] p-5 sm:p-7 shadow-2xl my-auto max-h-[94vh] flex flex-col">