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);
|
||||
$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 = "
|
||||
SELECT
|
||||
created_at,
|
||||
@@ -248,6 +269,7 @@ try {
|
||||
'failed' => (int)$geo['geo_failed'],
|
||||
'bypass' => (int)$geo['geo_bypass'],
|
||||
],
|
||||
'deviceStats' => $deviceStats,
|
||||
'recentOpens' => $recentOpens,
|
||||
'guestQueueSummary' => [
|
||||
'total' => (int)$queueSummary['total_actions'],
|
||||
|
||||
@@ -8,6 +8,7 @@ requireAdminAuth(true);
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Panel Admina</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root { --bg:#0f172a; --surface:#111827; --line:#334155; --text:#e2e8f0; --muted:#94a3b8; --accent:#3b82f6; }
|
||||
* { box-sizing: border-box; }
|
||||
@@ -33,6 +34,10 @@ requireAdminAuth(true);
|
||||
.legend-list li { margin-bottom: 6px; }
|
||||
.status-ok { color: #22c55e; 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; } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -105,6 +110,16 @@ requireAdminAuth(true);
|
||||
</table>
|
||||
</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">
|
||||
<h3>Kolejka akcji gościa (historia + statusy)</h3>
|
||||
<div class="muted" style="margin-bottom:10px;">
|
||||
@@ -139,6 +154,78 @@ requireAdminAuth(true);
|
||||
const chips = Array.from(document.querySelectorAll(".chip"));
|
||||
let selectedDays = "7";
|
||||
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) {
|
||||
chips.forEach(c => c.classList.toggle("active", c.dataset.days === String(days)));
|
||||
@@ -208,6 +295,8 @@ requireAdminAuth(true);
|
||||
}).join("")
|
||||
: `<tr><td colspan="7" class="muted">Brak danych</td></tr>`;
|
||||
|
||||
renderDevicePieChart(data.deviceStats || {});
|
||||
|
||||
const queueSummary = data.guestQueueSummary || {};
|
||||
document.getElementById("guestQueueSummaryBody").innerHTML = `
|
||||
<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("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>`;
|
||||
renderDevicePieChart({});
|
||||
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>`;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user