280 lines
10 KiB
HTML
280 lines
10 KiB
HTML
<!doctype html>
|
|
<html lang="en" data-bs-theme="dark">
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>Delivery Log — IMFestival SMS Dashboard</title>
|
|
|
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
|
crossorigin="anonymous">
|
|
<link href="assets/css/dashboard.css" rel="stylesheet">
|
|
<link href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css" rel="stylesheet">
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<!-- ─── Mobile top bar ─── -->
|
|
<div class="topbar">
|
|
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
|
<i class="fa-solid fa-bars"></i>
|
|
</button>
|
|
<span style="font-weight:600;font-size:0.95rem;">Delivery Log</span>
|
|
</div>
|
|
|
|
<!-- ─── Mobile offcanvas sidebar ─── -->
|
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
<div class="offcanvas-header">
|
|
<div class="brand"
|
|
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
<span>IMFestival SMS Dashboard</span>
|
|
</div>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
</div>
|
|
<div class="offcanvas-body p-3">
|
|
<nav class="d-flex flex-column gap-1">
|
|
<a href="index.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-house fa-fw"></i>
|
|
Home
|
|
</a>
|
|
<a href="attendees.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-users fa-fw"></i>
|
|
Attendees
|
|
</a>
|
|
<a href="templates.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
SMS Templates
|
|
</a>
|
|
<a href="schedule.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
Schedule
|
|
</a>
|
|
<a href="delivery-log.html" class="nav-link-custom active">
|
|
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
Delivery Log
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Desktop sidebar ─── -->
|
|
<div class="sidebar">
|
|
<div class="brand">
|
|
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
|
</div>
|
|
|
|
<nav class="d-flex flex-column gap-1 mt-2">
|
|
<a href="index.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-house fa-fw"></i>
|
|
Home
|
|
</a>
|
|
<a href="attendees.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-users fa-fw"></i>
|
|
Attendees
|
|
</a>
|
|
<a href="templates.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
SMS Templates
|
|
</a>
|
|
<a href="schedule.html" class="nav-link-custom">
|
|
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
Schedule
|
|
</a>
|
|
<a href="delivery-log.html" class="nav-link-custom active">
|
|
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
Delivery Log
|
|
</a>
|
|
</nav>
|
|
|
|
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
|
<div class="nav-link-custom" style="cursor:default;">
|
|
<i class="fa-solid fa-circle-user fa-fw"></i>
|
|
<span class="nav-username" style="font-size:0.82rem;">User</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Main content ─── -->
|
|
<div class="main">
|
|
<div class="main-inner">
|
|
<div id="sandboxBanner" class="alert alert-warning"
|
|
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
|
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
|
cost calculations.
|
|
</div>
|
|
|
|
<div class="page-header">
|
|
<h1>Delivery Log</h1>
|
|
<p>A record of all messages sent and failed.</p>
|
|
</div>
|
|
|
|
<!-- Summary stats -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-4">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Sent</div>
|
|
<div class="stat-value" id="stat-sent" style="color:#4ade80;">—</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-4">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Failed</div>
|
|
<div class="stat-value" id="stat-failed" style="color:#f87171;">—</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-4">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Success Rate</div>
|
|
<div class="stat-value" id="stat-rate">—</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card p-4">
|
|
<div class="table-responsive">
|
|
<table class="table table-striped mb-0" id="logTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Recipient</th>
|
|
<th>Template</th>
|
|
<th>Mobile</th>
|
|
<th>Status</th>
|
|
<th>Sent at</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="logBody">
|
|
<tr>
|
|
<td colspan="5" class="text-muted text-center py-4">Loading…</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
|
crossorigin="anonymous"></script>
|
|
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
|
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
|
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
|
<script>
|
|
let logTable = null;
|
|
let _lastDataKey = '';
|
|
|
|
function esc(str) {
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function errorDesc(msg) {
|
|
if (!msg) return '';
|
|
try {
|
|
const parsed = JSON.parse(msg);
|
|
// Top-level error (older Clickatell responses)
|
|
if (parsed.error) return parsed.error;
|
|
// Clickatell v2: { messages: [{ error, errorDescription }] }
|
|
const m = Array.isArray(parsed.messages) ? parsed.messages[0] : null;
|
|
if (m?.error) return m.error;
|
|
if (m?.errorDescription) return m.errorDescription;
|
|
return parsed.message || msg;
|
|
} catch {
|
|
return msg;
|
|
}
|
|
}
|
|
|
|
function fmtDate(str) {
|
|
if (!str) return '—';
|
|
return new Date(str).toLocaleString('en-GB', {
|
|
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
async function loadLog(isRefresh = false) {
|
|
const tbody = document.getElementById('logBody');
|
|
|
|
try {
|
|
const data = await fetch('/api/delivery-log.php?all=1').then(r => r.json());
|
|
const stats = data.stats ?? {};
|
|
const entries = data.entries ?? [];
|
|
|
|
document.getElementById('stat-sent').textContent = stats.total_sent ?? '—';
|
|
document.getElementById('stat-failed').textContent = stats.total_failed ?? '—';
|
|
document.getElementById('stat-rate').textContent = stats.success_rate != null ? stats.success_rate + '%' : '—';
|
|
|
|
// Skip expensive DOM rebuild if nothing has changed
|
|
const dataKey = `${entries.length}:${stats.total_sent}:${stats.total_failed}`;
|
|
if (isRefresh && dataKey === _lastDataKey) return;
|
|
_lastDataKey = dataKey;
|
|
|
|
// Preserve current page position before destroying the table
|
|
const prevPage = logTable ? logTable.page() : 0;
|
|
|
|
// Destroy existing DataTable before rewriting DOM
|
|
if (logTable) { logTable.destroy(); logTable = null; }
|
|
|
|
if (entries.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="5" class="text-muted text-center py-4">No messages sent yet.</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = entries.map(e => {
|
|
const statusBadge = e.status === 'failed'
|
|
? `<span class="badge-failed" style="cursor:help;"${e.error_message ? ` data-bs-toggle="tooltip" data-bs-placement="left" data-bs-title="${esc(errorDesc(e.error_message))}"` : ''}>Failed</span>`
|
|
: `<span class="badge-delivered">Sent</span>`;
|
|
return `
|
|
<tr>
|
|
<td>${esc(e.attendee_name ?? '—')}</td>
|
|
<td style="color:var(--text-muted);">${e.message_body
|
|
? `<span style="cursor:help;border-bottom:1px dashed var(--text-muted);" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="${esc(e.message_body)}">${esc(e.template_name)}</span>`
|
|
: esc(e.template_name)}</td>
|
|
<td style="color:var(--text-muted);">${esc(e.mobile_number)}</td>
|
|
<td>${statusBadge}</td>
|
|
<td style="color:var(--text-muted);font-size:0.82rem;" data-order="${e.sent_at ?? ''}">${fmtDate(e.sent_at)}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
logTable = new DataTable('#logTable', {
|
|
order: [[4, 'desc']],
|
|
columnDefs: [{ orderable: false, targets: 3 }],
|
|
pageLength: 25,
|
|
drawCallback: () => {
|
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
|
bootstrap.Tooltip.getOrCreateInstance(el);
|
|
});
|
|
},
|
|
});
|
|
if (isRefresh && prevPage > 0 && prevPage < logTable.page.info().pages) {
|
|
logTable.page(prevPage).draw(false);
|
|
}
|
|
} catch {
|
|
tbody.innerHTML = `<tr><td colspan="5" class="text-danger text-center py-4">Could not load delivery log.</td></tr>`;
|
|
}
|
|
}
|
|
|
|
loadLog();
|
|
setInterval(() => loadLog(true), 10_000);
|
|
</script>
|
|
<script>
|
|
async function checkAuth() {
|
|
try {
|
|
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
|
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
|
const d = await r.json();
|
|
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
|
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
|
const name = d.name || d.username;
|
|
if (name) {
|
|
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
|
}
|
|
} catch { }
|
|
}
|
|
checkAuth();
|
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|