Add device statistics to analytics reports and implement pie chart visualization in admin panel
This commit is contained in:
@@ -131,6 +131,27 @@ try {
|
|||||||
$stmtGeo->execute($baseParams);
|
$stmtGeo->execute($baseParams);
|
||||||
$geo = $stmtGeo->fetch() ?: ['geo_passed' => 0, 'geo_failed' => 0, 'geo_bypass' => 0];
|
$geo = $stmtGeo->fetch() ?: ['geo_passed' => 0, 'geo_failed' => 0, 'geo_bypass' => 0];
|
||||||
|
|
||||||
|
$sqlDeviceStats = "
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(device_type, ''), 'other') AS device_type,
|
||||||
|
COUNT(*) AS total
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE event_name = 'qr_scan' {$whereWindow}
|
||||||
|
GROUP BY COALESCE(NULLIF(device_type, ''), 'other')
|
||||||
|
";
|
||||||
|
$stmtDeviceStats = $pdo->prepare($sqlDeviceStats);
|
||||||
|
$stmtDeviceStats->execute($baseParams);
|
||||||
|
$deviceRows = $stmtDeviceStats->fetchAll();
|
||||||
|
$deviceStats = ['ios' => 0, 'android' => 0, 'other' => 0];
|
||||||
|
foreach ($deviceRows as $row) {
|
||||||
|
$key = (string) ($row['device_type'] ?? 'other');
|
||||||
|
if (!isset($deviceStats[$key])) {
|
||||||
|
$deviceStats['other'] += (int) $row['total'];
|
||||||
|
} else {
|
||||||
|
$deviceStats[$key] = (int) $row['total'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$sqlRecentOpens = "
|
$sqlRecentOpens = "
|
||||||
SELECT
|
SELECT
|
||||||
created_at,
|
created_at,
|
||||||
@@ -248,6 +269,7 @@ try {
|
|||||||
'failed' => (int)$geo['geo_failed'],
|
'failed' => (int)$geo['geo_failed'],
|
||||||
'bypass' => (int)$geo['geo_bypass'],
|
'bypass' => (int)$geo['geo_bypass'],
|
||||||
],
|
],
|
||||||
|
'deviceStats' => $deviceStats,
|
||||||
'recentOpens' => $recentOpens,
|
'recentOpens' => $recentOpens,
|
||||||
'guestQueueSummary' => [
|
'guestQueueSummary' => [
|
||||||
'total' => (int)$queueSummary['total_actions'],
|
'total' => (int)$queueSummary['total_actions'],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ requireAdminAuth(true);
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Panel Admina</title>
|
<title>Panel Admina</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root { --bg:#0f172a; --surface:#111827; --line:#334155; --text:#e2e8f0; --muted:#94a3b8; --accent:#3b82f6; }
|
:root { --bg:#0f172a; --surface:#111827; --line:#334155; --text:#e2e8f0; --muted:#94a3b8; --accent:#3b82f6; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -33,6 +34,10 @@ requireAdminAuth(true);
|
|||||||
.legend-list li { margin-bottom: 6px; }
|
.legend-list li { margin-bottom: 6px; }
|
||||||
.status-ok { color: #22c55e; font-weight: 700; }
|
.status-ok { color: #22c55e; font-weight: 700; }
|
||||||
.status-fail { color: #ef4444; font-weight: 700; }
|
.status-fail { color: #ef4444; font-weight: 700; }
|
||||||
|
.chart-wrap { max-width: 360px; margin: 0 auto; }
|
||||||
|
.chart-legend { display: flex; flex-wrap: wrap; gap: 12px 20px; justify-content: center; margin-top: 12px; font-size: .88rem; color: #cbd5e1; }
|
||||||
|
.chart-legend-item { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.chart-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
@media (max-width: 900px) { .col-3,.col-4,.col-6,.col-8 { grid-column: span 12; } .top { flex-direction: column; align-items: flex-start; } }
|
@media (max-width: 900px) { .col-3,.col-4,.col-6,.col-8 { grid-column: span 12; } .top { flex-direction: column; align-items: flex-start; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -105,6 +110,16 @@ requireAdminAuth(true);
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card col-12">
|
||||||
|
<h3>Urządzenia (skanowania QR)</h3>
|
||||||
|
<div class="muted" style="margin-bottom:12px;">Udział iPhone (iOS), Android i pozostałych w wybranym okresie.</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="devicePieChart" height="260"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="deviceChartLegend" class="chart-legend"></div>
|
||||||
|
<div id="deviceChartEmpty" class="muted" style="text-align:center; display:none; margin-top:8px;">Brak skanowań w tym okresie.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card col-12">
|
<div class="card col-12">
|
||||||
<h3>Kolejka akcji gościa (historia + statusy)</h3>
|
<h3>Kolejka akcji gościa (historia + statusy)</h3>
|
||||||
<div class="muted" style="margin-bottom:10px;">
|
<div class="muted" style="margin-bottom:10px;">
|
||||||
@@ -139,6 +154,78 @@ requireAdminAuth(true);
|
|||||||
const chips = Array.from(document.querySelectorAll(".chip"));
|
const chips = Array.from(document.querySelectorAll(".chip"));
|
||||||
let selectedDays = "7";
|
let selectedDays = "7";
|
||||||
let isLoadingAnalytics = false;
|
let isLoadingAnalytics = false;
|
||||||
|
let devicePieChart = null;
|
||||||
|
|
||||||
|
const deviceChartLabels = {
|
||||||
|
ios: "iPhone (iOS)",
|
||||||
|
android: "Android",
|
||||||
|
other: "Inne",
|
||||||
|
};
|
||||||
|
const deviceChartColors = {
|
||||||
|
ios: "#60a5fa",
|
||||||
|
android: "#34d399",
|
||||||
|
other: "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderDevicePieChart(stats) {
|
||||||
|
const canvas = document.getElementById("devicePieChart");
|
||||||
|
const legendEl = document.getElementById("deviceChartLegend");
|
||||||
|
const emptyEl = document.getElementById("deviceChartEmpty");
|
||||||
|
if (!canvas || typeof Chart === "undefined") return;
|
||||||
|
|
||||||
|
const order = ["ios", "android", "other"];
|
||||||
|
const values = order.map(k => Number(stats?.[k] || 0));
|
||||||
|
const total = values.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
if (devicePieChart) {
|
||||||
|
devicePieChart.destroy();
|
||||||
|
devicePieChart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
canvas.style.display = "none";
|
||||||
|
legendEl.innerHTML = "";
|
||||||
|
emptyEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.display = "block";
|
||||||
|
emptyEl.style.display = "none";
|
||||||
|
|
||||||
|
devicePieChart = new Chart(canvas, {
|
||||||
|
type: "pie",
|
||||||
|
data: {
|
||||||
|
labels: order.map(k => deviceChartLabels[k]),
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: order.map(k => deviceChartColors[k]),
|
||||||
|
borderColor: "#111827",
|
||||||
|
borderWidth: 2,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label(ctx) {
|
||||||
|
const v = ctx.parsed || 0;
|
||||||
|
const pct = total ? ((v / total) * 100).toFixed(1) : "0";
|
||||||
|
return ` ${ctx.label}: ${v.toLocaleString("pl-PL")} (${pct}%)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
legendEl.innerHTML = order.map((k, i) => {
|
||||||
|
const v = values[i];
|
||||||
|
const pct = total ? ((v / total) * 100).toFixed(1) : "0";
|
||||||
|
return `<span class="chart-legend-item"><span class="chart-legend-dot" style="background:${deviceChartColors[k]}"></span>${deviceChartLabels[k]}: ${n(v)} (${pct}%)</span>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function setChip(days) {
|
function setChip(days) {
|
||||||
chips.forEach(c => c.classList.toggle("active", c.dataset.days === String(days)));
|
chips.forEach(c => c.classList.toggle("active", c.dataset.days === String(days)));
|
||||||
@@ -208,6 +295,8 @@ requireAdminAuth(true);
|
|||||||
}).join("")
|
}).join("")
|
||||||
: `<tr><td colspan="7" class="muted">Brak danych</td></tr>`;
|
: `<tr><td colspan="7" class="muted">Brak danych</td></tr>`;
|
||||||
|
|
||||||
|
renderDevicePieChart(data.deviceStats || {});
|
||||||
|
|
||||||
const queueSummary = data.guestQueueSummary || {};
|
const queueSummary = data.guestQueueSummary || {};
|
||||||
document.getElementById("guestQueueSummaryBody").innerHTML = `
|
document.getElementById("guestQueueSummaryBody").innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -242,6 +331,7 @@ requireAdminAuth(true);
|
|||||||
document.getElementById("topTablesBody").innerHTML = `<tr><td colspan="5" class="muted">Nie udało się załadować analityki.</td></tr>`;
|
document.getElementById("topTablesBody").innerHTML = `<tr><td colspan="5" class="muted">Nie udało się załadować analityki.</td></tr>`;
|
||||||
document.getElementById("zonesBody").innerHTML = `<tr><td colspan="3" class="muted">Sprawdź połączenie/API.</td></tr>`;
|
document.getElementById("zonesBody").innerHTML = `<tr><td colspan="3" class="muted">Sprawdź połączenie/API.</td></tr>`;
|
||||||
document.getElementById("recentOpensBody").innerHTML = `<tr><td colspan="7" class="muted">Nie udało się pobrać ostatnich otwarć.</td></tr>`;
|
document.getElementById("recentOpensBody").innerHTML = `<tr><td colspan="7" class="muted">Nie udało się pobrać ostatnich otwarć.</td></tr>`;
|
||||||
|
renderDevicePieChart({});
|
||||||
document.getElementById("guestQueueSummaryBody").innerHTML = `<tr><td colspan="4" class="muted">Nie udało się pobrać podsumowania kolejki.</td></tr>`;
|
document.getElementById("guestQueueSummaryBody").innerHTML = `<tr><td colspan="4" class="muted">Nie udało się pobrać podsumowania kolejki.</td></tr>`;
|
||||||
document.getElementById("guestQueueBody").innerHTML = `<tr><td colspan="8" class="muted">Nie udało się pobrać kolejki.</td></tr>`;
|
document.getElementById("guestQueueBody").innerHTML = `<tr><td colspan="8" class="muted">Nie udało się pobrać kolejki.</td></tr>`;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user