diff --git a/public/api.php b/public/api.php index e6ae4db..76d0a56 100644 --- a/public/api.php +++ b/public/api.php @@ -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'] ?? '')); diff --git a/public/assets/app.js b/public/assets/app.js index 4461a9a..e941bd2 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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 = `Aucun groupe de téléavertissement configuré. Cliquez sur « + Nouveau Groupe de Téléavertissement ».`; + return; + } + + tbody.innerHTML = rows.map(pg => { + const members = Array.isArray(pg.members) ? pg.members : []; + const badges = members.length + ? members.map(m => `${escapeHtml(m)}`).join(' ') + : 'Aucun membre'; + + const isDuplex = Number(pg.duplex) === 1; + const typeBadge = isDuplex + ? `Duplex (2 voies)` + : `Simplex (Haut-parleur)`; + + const busyLabel = pg.busy_mode === 'force' + ? `Forcer` + : `Ignorer si occupé`; + + const beepLabel = Number(pg.play_beep) === 1 + ? `✓ Oui` + : `Non`; + + const statusBadge = Number(pg.enabled) === 1 + ? `Actif` + : `Inactif`; + + return ` + + ${escapeHtml(pg.extension)} + +
${escapeHtml(pg.name)}
+ ${pg.description ? `
${escapeHtml(pg.description)}
` : ''} + + ${typeBadge} +
${badges}
+ ${busyLabel} + ${beepLabel} + ${statusBadge} + + + + + + `; + }).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 += ` + + `; + hiddenHtml += ``; + } else { + blockedCount++; + blockedHtml += ` + + `; + } + }); + + allowedBox.innerHTML = allowedHtml || `
Aucun poste qui reçoit l'annonce. Cliquez sur un poste à droite pour l'ajouter.
`; + blockedBox.innerHTML = blockedHtml || `
Tous les postes reçoivent l'annonce !
`; + 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 += ``; } + if (paging.length > 0) { + html += ``; + paging.forEach(pg => { + const val = `page-${pg.extension}`; + html += ``; + }); + html += ``; + } + if (ivrs.length > 0) { html += ``; 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(); diff --git a/public/index.php b/public/index.php index c589175..0dcc708 100644 --- a/public/index.php +++ b/public/index.php @@ -289,6 +289,11 @@ $currentUser = Auth::getUsername(); • Groupes d'extensions 0 + + + + + +
+
+
📢
+
+
Annonce Générale de Groupe (Paging)
+

Composez le numéro du groupe configuré (ex: 100). Les téléphones cibles décrochent automatiquement sur haut-parleur après un bip d'avertissement sonore.

+
+
+
+
📞
+
+
Interphonie Directe 1-à-1 (*80 + poste)
+

Composez *80 suivi du numéro de l'extension (ex: *80101) pour ouvrir directement le haut-parleur et le micro du poste en conversation duplex.

+
+
+
+ +
+
+ + + + + + + + + + + + + + +
Numéro (Extension)Nom du GroupeMode AudioPostes Membres AssociésSi OccupéBipStatutActions
+
+
+ + @@ -2813,6 +2877,106 @@ $currentUser = Auth::getUsername(); + + + +