505 lines
27 KiB
PHP
505 lines
27 KiB
PHP
<?php
|
|
require_once __DIR__ . '/auth.php';
|
|
requireAdminAuth(true);
|
|
?>
|
|
<!doctype html>
|
|
<html lang="pl">
|
|
<head>
|
|
<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; }
|
|
body { margin: 0; font-family: Inter, Arial, sans-serif; background: var(--bg); color: var(--text); }
|
|
.wrap { width: min(1200px, 94vw); margin: 24px auto; }
|
|
.top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 16px; }
|
|
.title { font-size: 1.5rem; font-weight: 800; }
|
|
.btn { display: inline-block; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; color: var(--text); text-decoration: none; background: #0b1220; }
|
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
|
.grid { display: grid; gap: 14px; grid-template-columns: repeat(12,1fr); }
|
|
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px; }
|
|
.card h3 { margin: 0 0 8px; font-size: 1rem; color: #cbd5e1; }
|
|
.kpi { font-size: 1.8rem; font-weight: 800; margin-top: 4px; }
|
|
.muted { color: var(--muted); font-size: .88rem; }
|
|
.col-3 { grid-column: span 3; } .col-4 { grid-column: span 4; } .col-6 { grid-column: span 6; } .col-8 { grid-column: span 8; } .col-12 { grid-column: span 12; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { border-bottom: 1px solid #263446; padding: 8px 6px; text-align: left; font-size: .9rem; }
|
|
th { color: #cbd5e1; }
|
|
#recentOpensBody tr.recent-open-row {
|
|
border-left: 4px solid transparent;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
|
}
|
|
#recentOpensBody tr.recent-open-row:hover {
|
|
background-color: rgba(255, 255, 255, 0.04);
|
|
}
|
|
#recentOpensBody tr.recent-open-row.session-row-highlight {
|
|
background-color: var(--session-highlight-bg, rgba(59, 130, 246, 0.18));
|
|
box-shadow: inset 0 0 0 1px var(--session-highlight-border, rgba(59, 130, 246, 0.45));
|
|
}
|
|
.visitor-cell { display: flex; align-items: flex-start; gap: 8px; }
|
|
.visitor-session-dot {
|
|
width: 11px;
|
|
height: 11px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
margin-top: 3px;
|
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
|
}
|
|
.visitor-session-dash {
|
|
width: 4px;
|
|
min-height: 34px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 7px 11px; background: #0b1220; color: var(--text); cursor: pointer; }
|
|
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
.legend-list { margin: 0; padding-left: 18px; line-height: 1.6; color: #cbd5e1; }
|
|
.legend-list li { margin-bottom: 6px; }
|
|
.status-ok { color: #22c55e; font-weight: 700; }
|
|
.status-fail { color: #ef4444; font-weight: 700; }
|
|
.status-menu { color: #fbbf24; 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; }
|
|
.visitor-id { font-family: ui-monospace, Consolas, monospace; font-size: .82rem; color: #94a3b8; }
|
|
.visitor-new { color: var(--muted); font-size: .88rem; }
|
|
.visitor-return { color: #fbbf24; font-weight: 600; font-size: .88rem; }
|
|
@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>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="top">
|
|
<div>
|
|
<div class="title">Panel Admina</div>
|
|
<div class="muted">KDS, generator QR i analityka operacyjna.</div>
|
|
</div>
|
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
|
<a class="btn" href="kds.php">Otwórz KDS</a>
|
|
<a class="btn" href="generator.php">Generator QR</a>
|
|
<a class="btn" href="logout.php">Wyloguj</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card col-12" style="margin-bottom:14px;">
|
|
<div class="filters">
|
|
<button class="chip" data-days="today">Dzisiaj</button>
|
|
<button class="chip active" data-days="7">Ostatnie 7 dni</button>
|
|
<button class="chip" data-days="30">Ostatnie 30 dni</button>
|
|
<button class="chip" data-days="90">Ostatnie 90 dni</button>
|
|
<button class="chip" data-days="this_year">Ten rok</button>
|
|
<button class="chip" data-days="last_year">Poprzedni rok</button>
|
|
<button class="chip" data-days="all">Cały dostępny</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="card col-3"><h3>Skanowania QR</h3><div class="kpi" id="kpiScans">-</div><div class="muted">Otwarcia stron stolików</div></div>
|
|
<div class="card col-3"><h3>Sesje</h3><div class="kpi" id="kpiSessions">-</div><div class="muted">Użytkownicy, którzy weszli do aplikacji</div></div>
|
|
<div class="card col-3"><h3>Lokalizacja OK</h3><div class="kpi" id="kpiGeoPass">-</div><div class="muted">Pozytywna weryfikacja geolokalizacji</div></div>
|
|
<div class="card col-3"><h3>Przywołania kelnera</h3><div class="kpi" id="kpiWaiterCall">-</div><div class="muted">Wpisy w kolejce gościa (KDS)</div></div>
|
|
|
|
<div class="card col-8">
|
|
<h3>Top stoliki</h3>
|
|
<table>
|
|
<thead><tr><th>Stolik</th><th>Skany</th><th>Sesje</th><th>Geo pass</th><th>Geo fail</th></tr></thead>
|
|
<tbody id="topTablesBody"><tr><td colspan="5" class="muted">Ładowanie...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card col-4">
|
|
<h3>Strefy (Karczma/Taras)</h3>
|
|
<table>
|
|
<thead><tr><th>Strefa</th><th>Skany</th><th>Sesje</th></tr></thead>
|
|
<tbody id="zonesBody"><tr><td colspan="3" class="muted">Ładowanie...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card col-6">
|
|
<h3>Lejek użycia (droga użytkownika)</h3>
|
|
<table>
|
|
<tbody id="funnelBody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card col-6">
|
|
<h3>Geolokalizacja</h3>
|
|
<table>
|
|
<tbody id="geoBody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card col-12">
|
|
<h3>Ostatnie otwarcia stron stolików</h3>
|
|
<div class="muted" id="visitorSummaryLine" style="margin-bottom:10px;">Ładowanie podsumowania gości…</div>
|
|
<table>
|
|
<thead><tr><th>Kiedy</th><th>Stolik</th><th>Gość</th><th>W aplikacji</th><th>Urządzenie</th><th>Przeglądarka</th><th>IP</th></tr></thead>
|
|
<tbody id="recentOpensBody"><tr><td colspan="7" class="muted">Ładowanie...</td></tr></tbody>
|
|
</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;">
|
|
Historia jest zachowywana. Rekordów nie usuwamy, pracujemy na statusach `api_sent` i `status_kds`.
|
|
</div>
|
|
<table style="margin-bottom:10px;">
|
|
<thead><tr><th>Wszystkie akcje</th><th>Do wysyłki API</th><th>Oczekuje na KDS</th><th>Oznaczone jako gotowe</th></tr></thead>
|
|
<tbody id="guestQueueSummaryBody"><tr><td colspan="4" class="muted">Ładowanie...</td></tr></tbody>
|
|
</table>
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Kiedy</th><th>Stolik</th><th>Kelner</th><th>Typ</th><th>Treść</th><th>Wysłane API</th><th>KDS gotowe</th></tr></thead>
|
|
<tbody id="guestQueueBody"><tr><td colspan="8" class="muted">Ładowanie...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card col-12">
|
|
<h3>Jak czytać te dane?</h3>
|
|
<ul class="legend-list">
|
|
<li><strong>Skanowania QR</strong> - ile razy otwarto stronę stolika z kodu QR.</li>
|
|
<li><strong>Sesje</strong> - ile razy użytkownik faktycznie wszedł do aplikacji po starcie.</li>
|
|
<li><strong>Lokalizacja OK</strong> - ile razy weryfikacja geolokalizacji zakończyła się powodzeniem.</li>
|
|
<li><strong>Przywołania kelnera (kafelek KPI)</strong> - liczba wpisów <code>waiter_call</code> w kolejce gościa (ta sama baza co tabela na dole).</li>
|
|
<li><strong>Lejek → Przywołanie kelnera (analityka kliknięć)</strong> - osobna liczba z tabeli <code>analytics_events</code>; może być wyższa po testach lub po wyczyszczeniu tylko kolejki.</li>
|
|
<li><strong>Top stoliki</strong> - które stoliki są najczęściej otwierane i używane.</li>
|
|
<li><strong>Lejek użycia</strong> - droga użytkownika: wejście -> start aplikacji -> menu -> rachunek.</li>
|
|
<li><strong>Kolumna W aplikacji</strong> - ✓ pełna aplikacja (geo OK), M tylko menu bez lokalizacji, ✗ zatrzymanie na geo.</li>
|
|
<li><strong>Kolumna Gość</strong> - szacunkowy identyfikator urządzenia/przeglądarki (localStorage). Ten sam kolor = ta sama sesja. Kliknij wiersz, aby podświetlić wszystkie wizyty gościa.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const chips = Array.from(document.querySelectorAll(".chip"));
|
|
let selectedDays = "7";
|
|
let isLoadingAnalytics = false;
|
|
let devicePieChart = null;
|
|
let selectedVisitorSessionId = 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 visitorSessionColor(sessionId) {
|
|
const id = String(sessionId || "");
|
|
if (!id) return "#64748b";
|
|
let hash = 0;
|
|
for (let i = 0; i < id.length; i++) {
|
|
hash = id.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const hue = Math.abs(hash) % 360;
|
|
return `hsl(${hue}, 70%, 58%)`;
|
|
}
|
|
|
|
function hslToHighlightVars(hslColor) {
|
|
const match = String(hslColor).match(/hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)/i);
|
|
if (!match) {
|
|
return {
|
|
bg: "rgba(59, 130, 246, 0.18)",
|
|
border: "rgba(59, 130, 246, 0.45)",
|
|
};
|
|
}
|
|
const h = match[1];
|
|
const s = match[2];
|
|
const l = match[3];
|
|
return {
|
|
bg: `hsla(${h}, ${s}%, ${l}%, 0.22)`,
|
|
border: `hsla(${h}, ${s}%, ${l}%, 0.55)`,
|
|
};
|
|
}
|
|
|
|
function escapeAttr(value) {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function applyVisitorSessionHighlight(sessionId) {
|
|
document.querySelectorAll("#recentOpensBody tr.recent-open-row").forEach((row) => {
|
|
const rowSessionId = row.dataset.sessionId || "";
|
|
const isMatch = !!sessionId && rowSessionId === sessionId;
|
|
row.classList.toggle("session-row-highlight", isMatch);
|
|
if (isMatch) {
|
|
const vars = hslToHighlightVars(row.dataset.sessionColor || "");
|
|
row.style.setProperty("--session-highlight-bg", vars.bg);
|
|
row.style.setProperty("--session-highlight-border", vars.border);
|
|
} else {
|
|
row.style.removeProperty("--session-highlight-bg");
|
|
row.style.removeProperty("--session-highlight-border");
|
|
}
|
|
});
|
|
}
|
|
|
|
function bindRecentOpensRowClicks() {
|
|
const tbody = document.getElementById("recentOpensBody");
|
|
if (!tbody || tbody.dataset.clickBound === "1") return;
|
|
tbody.dataset.clickBound = "1";
|
|
tbody.addEventListener("click", (event) => {
|
|
const row = event.target.closest("tr.recent-open-row");
|
|
if (!row) return;
|
|
const sessionId = row.dataset.sessionId || "";
|
|
if (!sessionId) return;
|
|
selectedVisitorSessionId = selectedVisitorSessionId === sessionId ? null : sessionId;
|
|
applyVisitorSessionHighlight(selectedVisitorSessionId);
|
|
});
|
|
}
|
|
|
|
function formatVisitorCell(row, markColor) {
|
|
const visitNo = Number(row.visitor_visit_number || 1);
|
|
const total = Number(row.visitor_total_visits || visitNo);
|
|
const isReturning = Number(row.visitor_is_returning) === 1;
|
|
const shortId = row.visitor_id_short || "—";
|
|
const firstSeen = row.visitor_first_seen_at
|
|
? String(row.visitor_first_seen_at).replace("T", " ").slice(0, 16)
|
|
: "—";
|
|
const statusLabel = isReturning
|
|
? `<span class="visitor-return">Powrót · ${visitNo}. wizyta</span>`
|
|
: `<span class="visitor-new">Nowy · 1. wizyta</span>`;
|
|
const title = `Ten sam kolor = ta sama sesja/urządzenie · ID: ${shortId}… · Łącznie skanów: ${total} · Pierwsze wejście: ${firstSeen}`;
|
|
return `<span class="visitor-cell" title="${title}">
|
|
<span class="visitor-session-dash" style="background:${markColor}"></span>
|
|
<span>
|
|
<span style="display:inline-flex;align-items:center;gap:6px;">
|
|
<span class="visitor-session-dot" style="background:${markColor}"></span>
|
|
<span class="visitor-id" style="color:${markColor}">#${shortId}</span>
|
|
</span><br>${statusLabel}
|
|
</span>
|
|
</span>`;
|
|
}
|
|
|
|
function setChip(days) {
|
|
chips.forEach(c => c.classList.toggle("active", c.dataset.days === String(days)));
|
|
}
|
|
|
|
function n(value) {
|
|
return Number(value || 0).toLocaleString("pl-PL");
|
|
}
|
|
|
|
async function loadAnalytics(days) {
|
|
if (isLoadingAnalytics) return;
|
|
isLoadingAnalytics = true;
|
|
try {
|
|
const res = await fetch(`../../api/analytics_reports.php?days=${days}`, { credentials: "same-origin" });
|
|
const data = await res.json();
|
|
if (data.status !== "success") throw new Error("API error");
|
|
|
|
const totalScans = (data.topTables || []).reduce((a, r) => a + Number(r.qr_scans || 0), 0);
|
|
const totalSessions = (data.topTables || []).reduce((a, r) => a + Number(r.sessions || 0), 0);
|
|
document.getElementById("kpiScans").textContent = n(totalScans);
|
|
document.getElementById("kpiSessions").textContent = n(totalSessions);
|
|
document.getElementById("kpiGeoPass").textContent = n(data.geolocation?.passed);
|
|
document.getElementById("kpiWaiterCall").textContent = n(data.guestQueueSummary?.waiterCalls);
|
|
|
|
const topBody = document.getElementById("topTablesBody");
|
|
const top = data.topTables || [];
|
|
topBody.innerHTML = top.length
|
|
? top.map(r => `<tr><td>${r.table_id ?? "-"}</td><td>${n(r.qr_scans)}</td><td>${n(r.sessions)}</td><td>${n(r.geo_pass)}</td><td>${n(r.geo_fail)}</td></tr>`).join("")
|
|
: `<tr><td colspan="5" class="muted">Brak danych</td></tr>`;
|
|
|
|
const zonesBody = document.getElementById("zonesBody");
|
|
const zones = data.zoneStats || [];
|
|
zonesBody.innerHTML = zones.length
|
|
? zones.map(r => `<tr><td>${r.zone ?? "unknown"}</td><td>${n(r.qr_scans)}</td><td>${n(r.sessions)}</td></tr>`).join("")
|
|
: `<tr><td colspan="3" class="muted">Brak danych</td></tr>`;
|
|
|
|
const f = data.funnel || {};
|
|
document.getElementById("funnelBody").innerHTML = `
|
|
<tr><td>Otwarcie strony stolika (QR)</td><td>${n(f.qr_scan)}</td></tr>
|
|
<tr><td>Start aplikacji (geo OK)</td><td>${n(f.session_start)}</td></tr>
|
|
<tr><td>Tylko menu (bez geo)</td><td>${n(f.menu_only_entered)}</td></tr>
|
|
<tr><td>Wejście do menu</td><td>${n(f.view_menu)}</td></tr>
|
|
<tr><td>Próba odblokowania funkcji (geo gate)</td><td>${n(f.geo_gate_prompted)}</td></tr>
|
|
<tr><td>Ponowna geo z trybu menu</td><td>${n(f.geo_retry_from_menu)}</td></tr>
|
|
<tr><td>Przywołanie kelnera (analityka kliknięć)</td><td>${n(f.waiter_call_requested)}</td></tr>
|
|
<tr><td>Otwarcie modułu rachunku</td><td>${n(f.bill_dialog_opened)}</td></tr>
|
|
<tr><td>Wysłanie prośby o rachunek</td><td>${n(f.bill_request_sent)}</td></tr>
|
|
`;
|
|
|
|
const g = data.geolocation || {};
|
|
document.getElementById("geoBody").innerHTML = `
|
|
<tr><td>Lokalizacja potwierdzona</td><td>${n(g.passed)}</td></tr>
|
|
<tr><td>Lokalizacja odrzucona / błąd</td><td>${n(g.failed)}</td></tr>
|
|
<tr><td>Wejście z hosta bypass</td><td>${n(g.bypass)}</td></tr>
|
|
`;
|
|
|
|
const recent = data.recentOpens || [];
|
|
const visitorSummary = data.visitorSummary || {};
|
|
const uniqueVisitors = Number(visitorSummary.uniqueVisitors || 0);
|
|
const returningVisitors = Number(visitorSummary.returningVisitors || 0);
|
|
const repeatPct = uniqueVisitors
|
|
? ((returningVisitors / uniqueVisitors) * 100).toFixed(1)
|
|
: "0";
|
|
document.getElementById("visitorSummaryLine").textContent =
|
|
`W wybranym okresie: ${n(uniqueVisitors)} unikalnych gości (urządzeń), ${n(returningVisitors)} powracających (${repeatPct}%). Kliknij wiersz, aby podświetlić wszystkie wizyty tego gościa (ten sam kolor).`;
|
|
|
|
const recentBody = document.getElementById("recentOpensBody");
|
|
recentBody.innerHTML = recent.length
|
|
? recent.map(r => {
|
|
const dt = r.created_at ? new Date(String(r.created_at).replace(" ", "T")) : null;
|
|
const when = dt && !Number.isNaN(dt.getTime()) ? dt.toLocaleString("pl-PL") : (r.created_at || "-");
|
|
const device = r.device_type || "-";
|
|
const browser = r.browser || "-";
|
|
const reachedApp = Number(r.reached_app) === 1;
|
|
const menuOnly = Number(r.menu_only) === 1;
|
|
let appCell;
|
|
if (reachedApp) {
|
|
appCell = `<span class="status-ok" title="Pełna aplikacja — geo OK">✓</span>`;
|
|
} else if (menuOnly) {
|
|
appCell = `<span class="status-menu" title="Tylko menu — bez lokalizacji">M</span>`;
|
|
} else {
|
|
appCell = `<span class="status-fail" title="Zatrzymano na ekranie lokalizacji">✗</span>`;
|
|
}
|
|
const markColor = visitorSessionColor(r.session_id);
|
|
const sessionId = escapeAttr(r.session_id || "");
|
|
return `<tr class="recent-open-row" data-session-id="${sessionId}" data-session-color="${markColor}" style="border-left-color:${markColor}"><td>${when}</td><td>${r.table_id || "-"}</td><td>${formatVisitorCell(r, markColor)}</td><td>${appCell}</td><td>${device}</td><td>${browser}</td><td>${r.ip_address || "-"}</td></tr>`;
|
|
}).join("")
|
|
: `<tr><td colspan="7" class="muted">Brak danych</td></tr>`;
|
|
|
|
bindRecentOpensRowClicks();
|
|
applyVisitorSessionHighlight(selectedVisitorSessionId);
|
|
|
|
renderDevicePieChart(data.deviceStats || {});
|
|
|
|
const queueSummary = data.guestQueueSummary || {};
|
|
document.getElementById("guestQueueSummaryBody").innerHTML = `
|
|
<tr>
|
|
<td>${n(queueSummary.total)}</td>
|
|
<td>${n(queueSummary.pendingApi)}</td>
|
|
<td>${n(queueSummary.pendingKds)}</td>
|
|
<td>${n(queueSummary.doneKds)}</td>
|
|
</tr>
|
|
`;
|
|
|
|
const queueRows = data.guestQueue || [];
|
|
const queueBody = document.getElementById("guestQueueBody");
|
|
queueBody.innerHTML = queueRows.length
|
|
? queueRows.map(row => {
|
|
const dt = row.created_at ? new Date(String(row.created_at).replace(" ", "T")) : null;
|
|
const when = dt && !Number.isNaN(dt.getTime()) ? dt.toLocaleString("pl-PL") : (row.created_at || "-");
|
|
const typeLabel = row.message_type === "waiter_call" ? "Przywołanie kelnera" : "Prośba o rachunek";
|
|
const apiSent = Number(row.api_sent) === 1 ? "Tak" : "Nie";
|
|
const kdsDone = Number(row.status_kds) === 1 ? "Tak" : "Nie";
|
|
const messageText = String(row.message_text || "-")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
const kelner = [row.otwierajacy_imie, row.otwierajacy_nazwisko]
|
|
.map(v => String(v || "").trim())
|
|
.filter(Boolean)
|
|
.join(" ") || "-";
|
|
return `<tr><td>${row.id}</td><td>${when}</td><td>${row.table_id || "-"}</td><td>${kelner}</td><td>${typeLabel}</td><td style="white-space:pre-line;max-width:360px">${messageText}</td><td>${apiSent}</td><td>${kdsDone}</td></tr>`;
|
|
}).join("")
|
|
: `<tr><td colspan="8" class="muted">Brak danych</td></tr>`;
|
|
} catch (e) {
|
|
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>`;
|
|
document.getElementById("visitorSummaryLine").textContent = "Nie udało się pobrać statystyk gości.";
|
|
selectedVisitorSessionId = null;
|
|
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 {
|
|
isLoadingAnalytics = false;
|
|
}
|
|
}
|
|
|
|
chips.forEach(chip => {
|
|
chip.addEventListener("click", () => {
|
|
selectedDays = chip.dataset.days;
|
|
setChip(selectedDays);
|
|
loadAnalytics(selectedDays);
|
|
});
|
|
});
|
|
|
|
setChip(selectedDays);
|
|
loadAnalytics(selectedDays);
|
|
setInterval(() => loadAnalytics(selectedDays), 60000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|