feat(cdr): add Call Logs & CDR module with automatic trunk detection and CSV export (v1.6.0)
This commit is contained in:
1 parent
a9d86dd6ac
commit
7c28a237ba
5 files changed
+927
-1
No files matched your search
@@ -3508,6 +3508,7 @@ function renderSettingsAndDiagnostics() {
|
||||
// --- Navigation Sidebar & Modales ---
|
||||
const PAGE_TITLES = {
|
||||
dashboard: 'Tableau de Bord & Supervision Temps Réel',
|
||||
cdr: 'Journal d\'Appels & Historique CDR (Call Detail Records)',
|
||||
extensions: 'Postes Téléphoniques SIP (PJSIP)',
|
||||
groups: "Groupes de Sonnerie (Ring Groups)",
|
||||
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
|
||||
@@ -3586,6 +3587,11 @@ function switchTab(tabId) {
|
||||
loadBackups();
|
||||
}
|
||||
|
||||
// Si onglet Journal d'appels, charger l'historique CDR
|
||||
if (tabId === 'cdr') {
|
||||
loadCdr();
|
||||
}
|
||||
|
||||
// Si onglet Pare-feu, charger le statut en direct
|
||||
if (tabId === 'firewall') {
|
||||
loadFirewallStatus();
|
||||
@@ -6073,3 +6079,235 @@ async function executeSystemUpdate() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// JOURNAL D'APPELS (CDR) & HISTORIQUE DES COMMUNICATIONS
|
||||
// =========================================================================
|
||||
let cdrDebounceTimer = null;
|
||||
|
||||
function debounceCdrLoad() {
|
||||
clearTimeout(cdrDebounceTimer);
|
||||
cdrDebounceTimer = setTimeout(() => {
|
||||
loadCdr();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function loadCdr() {
|
||||
const tbody = document.getElementById('cdr-table-body');
|
||||
const info = document.getElementById('cdr-status-info');
|
||||
const spinner = document.getElementById('spinner-refresh-cdr');
|
||||
if (spinner) spinner.classList.add('animate-spin');
|
||||
|
||||
const search = document.getElementById('cdr-filter-search')?.value || '';
|
||||
const period = document.getElementById('cdr-filter-period')?.value || 'today';
|
||||
const direction = document.getElementById('cdr-filter-direction')?.value || 'all';
|
||||
const disposition = document.getElementById('cdr-filter-disposition')?.value || 'all';
|
||||
const limit = document.getElementById('cdr-filter-limit')?.value || 100;
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
search,
|
||||
period,
|
||||
direction,
|
||||
disposition,
|
||||
limit
|
||||
});
|
||||
|
||||
const res = await apiCall(`get_cdr&${query.toString()}`);
|
||||
if (!res || !res.success) throw new Error(res?.message || 'Erreur lors de la récupération des CDR');
|
||||
|
||||
renderCdrStats(res.stats || {});
|
||||
renderCdrTable(res.calls || []);
|
||||
|
||||
if (info) {
|
||||
const count = (res.calls || []).length;
|
||||
const total = res.total_records || 0;
|
||||
info.textContent = `Affichage de ${count} appel(s) sur ${total} analysé(s) pour la période sélectionnée.`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="py-8 text-center text-rose-400">
|
||||
Erreur de chargement du journal d'appels : ${escapeHtml(err.message)}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
} finally {
|
||||
if (spinner) spinner.classList.remove('animate-spin');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCdrStats(stats) {
|
||||
const elTotal = document.getElementById('cdr-stat-total');
|
||||
const elAns = document.getElementById('cdr-stat-answered');
|
||||
const elRate = document.getElementById('cdr-stat-rate');
|
||||
const elMiss = document.getElementById('cdr-stat-missed');
|
||||
const elOut = document.getElementById('cdr-stat-outbound');
|
||||
const elIn = document.getElementById('cdr-stat-inbound');
|
||||
|
||||
const total = stats.total || 0;
|
||||
const answered = stats.answered || 0;
|
||||
const missed = stats.missed || 0;
|
||||
const rate = total > 0 ? Math.round((answered / total) * 100) : 0;
|
||||
|
||||
if (elTotal) elTotal.textContent = total;
|
||||
if (elAns) elAns.textContent = answered;
|
||||
if (elRate) elRate.textContent = `${rate}% de réponse`;
|
||||
if (elMiss) elMiss.textContent = missed;
|
||||
if (elOut) elOut.textContent = stats.outbound || 0;
|
||||
if (elIn) elIn.textContent = stats.inbound || 0;
|
||||
}
|
||||
|
||||
function renderCdrTable(calls) {
|
||||
const tbody = document.getElementById('cdr-table-body');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!calls || calls.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="py-8 text-center text-slate-400">
|
||||
Aucun appel enregistré pour les filtres sélectionnés.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = calls.map(c => {
|
||||
// Direction badge & icon
|
||||
let dirBadge = '';
|
||||
if (c.direction === 'inbound') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-teal-500/15 text-teal-300 border border-teal-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg>
|
||||
<span>Entrant</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'outbound') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg>
|
||||
<span>Sortant</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'service') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span>Service</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'group') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span>Groupe</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'ivr') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-pink-500/15 text-pink-300 border border-pink-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
|
||||
<span>SVI</span>
|
||||
</span>`;
|
||||
} else {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
|
||||
<span>Interne</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// Status badge
|
||||
let statusBadge = '';
|
||||
if (c.disposition === 'ANSWERED') {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
|
||||
<span>Décroché</span>
|
||||
</span>`;
|
||||
} else if (c.disposition === 'NO ANSWER') {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
<span>Non répondu</span>
|
||||
</span>`;
|
||||
} else if (c.disposition === 'BUSY') {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||||
<span>Occupé</span>
|
||||
</span>`;
|
||||
} else {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
|
||||
<span>Échec</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// Trunk badge
|
||||
let trunkBadge = '';
|
||||
if (c.trunk_name) {
|
||||
trunkBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-semibold bg-emerald-950/60 text-emerald-300 border border-emerald-500/40 shadow-sm">
|
||||
<svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
|
||||
<span>${escapeHtml(c.trunk_name)}</span>
|
||||
</span>`;
|
||||
} else {
|
||||
trunkBadge = `<span class="text-slate-500 font-mono text-xs">—</span>`;
|
||||
}
|
||||
|
||||
// Caller display
|
||||
const callerNameHtml = c.caller_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.caller_name)}</div>` : '';
|
||||
const callerNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.caller_number || 'Inconnu')}</div>`;
|
||||
|
||||
// Callee display
|
||||
const calleeNameHtml = c.callee_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.callee_name)}</div>` : '';
|
||||
const calleeNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.callee_number || '—')}</div>`;
|
||||
|
||||
// Talked vs Total duration
|
||||
const durationHtml = c.billsec > 0
|
||||
? `<div><span class="font-bold text-emerald-300 font-mono text-xs">${c.billsec_formatted}</span> <span class="text-[10px] text-slate-500 font-sans">(${c.duration_formatted} tot.)</span></div>`
|
||||
: `<span class="font-mono text-slate-400 text-xs">${c.duration_formatted}</span>`;
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-anthracite-750/50 transition">
|
||||
<td class="py-3 px-4 whitespace-nowrap text-slate-300 font-mono text-xs">
|
||||
<div class="font-semibold text-slate-200">${escapeHtml(c.datetime_formatted)}</div>
|
||||
<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(c.datetime)}</div>
|
||||
</td>
|
||||
<td class="py-3 px-3 whitespace-nowrap">
|
||||
${dirBadge}
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
${callerNameHtml}
|
||||
${callerNumHtml}
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
${calleeNameHtml}
|
||||
${calleeNumHtml}
|
||||
</td>
|
||||
<td class="py-3 px-4 whitespace-nowrap">
|
||||
${trunkBadge}
|
||||
</td>
|
||||
<td class="py-3 px-3 whitespace-nowrap">
|
||||
${durationHtml}
|
||||
</td>
|
||||
<td class="py-3 px-3 whitespace-nowrap">
|
||||
${statusBadge}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function exportCdrCsv() {
|
||||
const search = encodeURIComponent(document.getElementById('cdr-filter-search')?.value || '');
|
||||
const period = encodeURIComponent(document.getElementById('cdr-filter-period')?.value || 'all');
|
||||
const direction = encodeURIComponent(document.getElementById('cdr-filter-direction')?.value || 'all');
|
||||
const disposition = encodeURIComponent(document.getElementById('cdr-filter-disposition')?.value || 'all');
|
||||
|
||||
window.location.href = `/api.php?action=export_cdr_csv&search=${search}&period=${period}&direction=${direction}&disposition=${disposition}`;
|
||||
}
|
||||
|
||||
async function confirmClearCdr() {
|
||||
if (!confirm("Êtes-vous sûr de vouloir purger l'historique complet du journal d'appels Asterisk ? Cette action est irréversible.")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiCall('clear_cdr', {});
|
||||
showToast(res.message || "Journal d'appels réinitialisé.", 'success');
|
||||
loadCdr();
|
||||
} catch (e) {
|
||||
showToast(e.message || "Erreur lors de la purge du journal.", 'error');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user