Baza danych i zapis akcji przywoływania kelnera. Czekam na endpointy lub coś z KDS
This commit is contained in:
20
README.md
20
README.md
@@ -49,6 +49,26 @@ Z powodów bezpieczeństwa, aby klienci w restauracji nie podglądali zamówień
|
|||||||
4. Upewnij się, że folder `api/cache` posiada prawa zapisu (`chmod 777` na Linuksie / Pełna kontrola na Windowsie) by skrypt mógł wygenerować `tables_cache.json`.
|
4. Upewnij się, że folder `api/cache` posiada prawa zapisu (`chmod 777` na Linuksie / Pełna kontrola na Windowsie) by skrypt mógł wygenerować `tables_cache.json`.
|
||||||
5. Dla ułatwienia pracy, na roocie znajduje się `index.php` (Dev Portal), z którego możesz klikać we wszystkie moduły (do usunięcia na produkcji).
|
5. Dla ułatwienia pracy, na roocie znajduje się `index.php` (Dev Portal), z którego możesz klikać we wszystkie moduły (do usunięcia na produkcji).
|
||||||
|
|
||||||
|
## 📊 Analityka (MVP)
|
||||||
|
|
||||||
|
W projekcie działa eventowa analityka oparta o MySQL:
|
||||||
|
|
||||||
|
- Endpoint zapisu eventów: `api/analytics.php` (POST JSON).
|
||||||
|
- Endpoint raportowy pod panel: `api/analytics_reports.php` (GET, parametr `days`).
|
||||||
|
- Skrypt agregacji dziennej: `scripts/analytics_aggregate_daily.php`.
|
||||||
|
|
||||||
|
Przykładowe uruchomienie agregacji:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php scripts/analytics_aggregate_daily.php
|
||||||
|
php scripts/analytics_aggregate_daily.php 2026-05-28
|
||||||
|
```
|
||||||
|
|
||||||
|
Przykładowe raporty JSON:
|
||||||
|
|
||||||
|
- `api/analytics_reports.php?days=7`
|
||||||
|
- `api/analytics_reports.php?days=30`
|
||||||
|
|
||||||
## 💡 Jak działa mapowanie zamówień?
|
## 💡 Jak działa mapowanie zamówień?
|
||||||
|
|
||||||
- Aplikacja KDS wyciąga dane łącząc tabele `dbo.NGastroDTRachunek` i `dbo.NGastroDTRachunekPozycja`.
|
- Aplikacja KDS wyciąga dane łącząc tabele `dbo.NGastroDTRachunek` i `dbo.NGastroDTRachunekPozycja`.
|
||||||
|
|||||||
157
api/analytics.php
Normal file
157
api/analytics.php
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/database.php';
|
||||||
|
require_once __DIR__ . '/get_table_name.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Method not allowed'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody = file_get_contents('php://input');
|
||||||
|
$data = json_decode($rawBody, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid JSON payload'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventName = isset($data['eventName']) ? trim((string)$data['eventName']) : '';
|
||||||
|
$sessionId = isset($data['sessionId']) ? trim((string)$data['sessionId']) : '';
|
||||||
|
$tableId = isset($data['tableId']) ? trim((string)$data['tableId']) : null;
|
||||||
|
$zone = isset($data['zone']) ? trim((string)$data['zone']) : null;
|
||||||
|
$qrHash = isset($data['qrHash']) ? trim((string)$data['qrHash']) : null;
|
||||||
|
$deviceType = isset($data['deviceType']) ? trim((string)$data['deviceType']) : null;
|
||||||
|
$browser = isset($data['browser']) ? trim((string)$data['browser']) : null;
|
||||||
|
$payload = isset($data['payload']) && is_array($data['payload']) ? $data['payload'] : null;
|
||||||
|
|
||||||
|
$allowedEvents = [
|
||||||
|
'qr_scan',
|
||||||
|
'session_start',
|
||||||
|
'geo_check_started',
|
||||||
|
'geo_check_passed',
|
||||||
|
'geo_check_failed',
|
||||||
|
'geo_bypass_host',
|
||||||
|
'waiter_call_requested',
|
||||||
|
'view_status',
|
||||||
|
'view_menu',
|
||||||
|
'menu_search',
|
||||||
|
'bill_dialog_opened',
|
||||||
|
'bill_request_sent',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($eventName === '' || !in_array($eventName, $allowedEvents, true)) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid eventName'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sessionId === '' || strlen($sessionId) > 64) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid sessionId'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($qrHash && !$tableId && isset($conn)) {
|
||||||
|
$resolved = getTableNameByHash($conn, $qrHash);
|
||||||
|
if ($resolved !== '') {
|
||||||
|
$tableId = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tableId !== null && strlen($tableId) > 32) {
|
||||||
|
$tableId = substr($tableId, 0, 32);
|
||||||
|
}
|
||||||
|
if ($zone !== null && strlen($zone) > 16) {
|
||||||
|
$zone = substr($zone, 0, 16);
|
||||||
|
}
|
||||||
|
if ($qrHash !== null && strlen($qrHash) > 128) {
|
||||||
|
$qrHash = substr($qrHash, 0, 128);
|
||||||
|
}
|
||||||
|
if ($deviceType !== null && strlen($deviceType) > 16) {
|
||||||
|
$deviceType = substr($deviceType, 0, 16);
|
||||||
|
}
|
||||||
|
if ($browser !== null && strlen($browser) > 32) {
|
||||||
|
$browser = substr($browser, 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
$ip = trim($parts[0]);
|
||||||
|
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||||
|
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
|
||||||
|
}
|
||||||
|
$ipHash = $ip !== '' ? hash('sha256', $ip . '|karczma_analytics_v1') : null;
|
||||||
|
$payload = is_array($payload) ? $payload : [];
|
||||||
|
if ($ip !== '' && !isset($payload['ipAddress'])) {
|
||||||
|
$payload['ipAddress'] = $ip;
|
||||||
|
}
|
||||||
|
$payloadJson = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = getAnalyticsPdo();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO analytics_events (
|
||||||
|
created_at,
|
||||||
|
event_name,
|
||||||
|
session_id,
|
||||||
|
table_id,
|
||||||
|
zone,
|
||||||
|
qr_hash,
|
||||||
|
device_type,
|
||||||
|
browser,
|
||||||
|
ip_hash,
|
||||||
|
payload_json
|
||||||
|
) VALUES (
|
||||||
|
NOW(3),
|
||||||
|
:event_name,
|
||||||
|
:session_id,
|
||||||
|
:table_id,
|
||||||
|
:zone,
|
||||||
|
:qr_hash,
|
||||||
|
:device_type,
|
||||||
|
:browser,
|
||||||
|
:ip_hash,
|
||||||
|
:payload_json
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':event_name' => $eventName,
|
||||||
|
':session_id' => $sessionId,
|
||||||
|
':table_id' => $tableId,
|
||||||
|
':zone' => $zone,
|
||||||
|
':qr_hash' => $qrHash,
|
||||||
|
':device_type' => $deviceType,
|
||||||
|
':browser' => $browser,
|
||||||
|
':ip_hash' => $ipHash,
|
||||||
|
':payload_json' => $payloadJson,
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Best-effort analytics: return success=false, but do not crash frontend flow.
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Analytics insert failed'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
220
api/analytics_reports.php
Normal file
220
api/analytics_reports.php
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/database.php';
|
||||||
|
require_once __DIR__ . '/../public/staff/auth.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Method not allowed'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdminLoggedIn()) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Unauthorized'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$range = isset($_GET['days']) ? trim((string)$_GET['days']) : '7';
|
||||||
|
$allowedRanges = ['7', '30', '90', 'all', 'this_year', 'last_year'];
|
||||||
|
if (!in_array($range, $allowedRanges, true)) {
|
||||||
|
$range = '7';
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereWindow = '';
|
||||||
|
$baseParams = [];
|
||||||
|
$now = new DateTimeImmutable('now');
|
||||||
|
|
||||||
|
switch ($range) {
|
||||||
|
case 'all':
|
||||||
|
break;
|
||||||
|
case 'this_year':
|
||||||
|
$start = new DateTimeImmutable(date('Y-01-01 00:00:00'));
|
||||||
|
$end = $start->modify('+1 year');
|
||||||
|
$whereWindow = ' AND created_at >= :start_at AND created_at < :end_at';
|
||||||
|
$baseParams = [
|
||||||
|
':start_at' => $start->format('Y-m-d H:i:s'),
|
||||||
|
':end_at' => $end->format('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'last_year':
|
||||||
|
$start = new DateTimeImmutable((date('Y') - 1) . '-01-01 00:00:00');
|
||||||
|
$end = $start->modify('+1 year');
|
||||||
|
$whereWindow = ' AND created_at >= :start_at AND created_at < :end_at';
|
||||||
|
$baseParams = [
|
||||||
|
':start_at' => $start->format('Y-m-d H:i:s'),
|
||||||
|
':end_at' => $end->format('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$daysInt = (int)$range;
|
||||||
|
if ($daysInt < 1) $daysInt = 1;
|
||||||
|
if ($daysInt > 3650) $daysInt = 3650;
|
||||||
|
$start = $now->modify("-{$daysInt} days");
|
||||||
|
$whereWindow = ' AND created_at >= :start_at';
|
||||||
|
$baseParams = [
|
||||||
|
':start_at' => $start->format('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = getAnalyticsPdo();
|
||||||
|
|
||||||
|
$sqlTopTables = "
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(table_id, ''), 'unknown') AS table_id,
|
||||||
|
SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans,
|
||||||
|
SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE 1=1 {$whereWindow}
|
||||||
|
GROUP BY COALESCE(NULLIF(table_id, ''), 'unknown')
|
||||||
|
ORDER BY qr_scans DESC, sessions DESC
|
||||||
|
LIMIT 20
|
||||||
|
";
|
||||||
|
$stmtTopTables = $pdo->prepare($sqlTopTables);
|
||||||
|
$stmtTopTables->execute($baseParams);
|
||||||
|
$topTables = $stmtTopTables->fetchAll();
|
||||||
|
|
||||||
|
$sqlByZone = "
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(zone, ''), 'unknown') AS zone,
|
||||||
|
SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans,
|
||||||
|
SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE 1=1 {$whereWindow}
|
||||||
|
GROUP BY COALESCE(NULLIF(zone, ''), 'unknown')
|
||||||
|
ORDER BY sessions DESC, qr_scans DESC
|
||||||
|
";
|
||||||
|
$stmtByZone = $pdo->prepare($sqlByZone);
|
||||||
|
$stmtByZone->execute($baseParams);
|
||||||
|
$zoneStats = $stmtByZone->fetchAll();
|
||||||
|
|
||||||
|
$sqlFunnel = "
|
||||||
|
SELECT
|
||||||
|
event_name,
|
||||||
|
COUNT(*) AS total
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE 1=1 {$whereWindow}
|
||||||
|
AND event_name IN ('qr_scan','session_start','view_menu','bill_dialog_opened','bill_request_sent','waiter_call_requested')
|
||||||
|
GROUP BY event_name
|
||||||
|
";
|
||||||
|
$stmtFunnel = $pdo->prepare($sqlFunnel);
|
||||||
|
$stmtFunnel->execute($baseParams);
|
||||||
|
$funnelRows = $stmtFunnel->fetchAll();
|
||||||
|
$funnelMap = [];
|
||||||
|
foreach ($funnelRows as $row) {
|
||||||
|
$funnelMap[$row['event_name']] = (int)$row['total'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sqlGeo = "
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_passed,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_failed,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_bypass_host' THEN 1 ELSE 0 END) AS geo_bypass
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE 1=1 {$whereWindow}
|
||||||
|
";
|
||||||
|
$stmtGeo = $pdo->prepare($sqlGeo);
|
||||||
|
$stmtGeo->execute($baseParams);
|
||||||
|
$geo = $stmtGeo->fetch() ?: ['geo_passed' => 0, 'geo_failed' => 0, 'geo_bypass' => 0];
|
||||||
|
|
||||||
|
$sqlRecentOpens = "
|
||||||
|
SELECT
|
||||||
|
created_at,
|
||||||
|
table_id,
|
||||||
|
zone,
|
||||||
|
device_type,
|
||||||
|
browser,
|
||||||
|
JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.ipAddress')) AS ip_address
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE event_name = 'qr_scan' {$whereWindow}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
";
|
||||||
|
$stmtRecentOpens = $pdo->prepare($sqlRecentOpens);
|
||||||
|
$stmtRecentOpens->execute($baseParams);
|
||||||
|
$recentOpens = $stmtRecentOpens ? $stmtRecentOpens->fetchAll() : [];
|
||||||
|
|
||||||
|
$sqlQueueSummary = "
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_actions,
|
||||||
|
SUM(CASE WHEN api_sent = 0 THEN 1 ELSE 0 END) AS pending_api,
|
||||||
|
SUM(CASE WHEN status_kds = 0 THEN 1 ELSE 0 END) AS pending_kds,
|
||||||
|
SUM(CASE WHEN status_kds = 1 THEN 1 ELSE 0 END) AS done_kds
|
||||||
|
FROM guest_action_queue
|
||||||
|
WHERE 1=1 {$whereWindow}
|
||||||
|
";
|
||||||
|
$stmtQueueSummary = $pdo->prepare($sqlQueueSummary);
|
||||||
|
$stmtQueueSummary->execute($baseParams);
|
||||||
|
$queueSummary = $stmtQueueSummary->fetch() ?: [
|
||||||
|
'total_actions' => 0,
|
||||||
|
'pending_api' => 0,
|
||||||
|
'pending_kds' => 0,
|
||||||
|
'done_kds' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$sqlQueueItems = "
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
table_id,
|
||||||
|
message_type,
|
||||||
|
message_text,
|
||||||
|
api_sent,
|
||||||
|
status_kds,
|
||||||
|
created_at
|
||||||
|
FROM guest_action_queue
|
||||||
|
WHERE 1=1 {$whereWindow}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
";
|
||||||
|
$stmtQueueItems = $pdo->prepare($sqlQueueItems);
|
||||||
|
$stmtQueueItems->execute($baseParams);
|
||||||
|
$queueItems = $stmtQueueItems->fetchAll();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'days' => $range,
|
||||||
|
'topTables' => $topTables,
|
||||||
|
'zoneStats' => $zoneStats,
|
||||||
|
'funnel' => [
|
||||||
|
'qr_scan' => (int)($funnelMap['qr_scan'] ?? 0),
|
||||||
|
'session_start' => (int)($funnelMap['session_start'] ?? 0),
|
||||||
|
'view_menu' => (int)($funnelMap['view_menu'] ?? 0),
|
||||||
|
'bill_dialog_opened' => (int)($funnelMap['bill_dialog_opened'] ?? 0),
|
||||||
|
'bill_request_sent' => (int)($funnelMap['bill_request_sent'] ?? 0),
|
||||||
|
'waiter_call_requested' => (int)($funnelMap['waiter_call_requested'] ?? 0),
|
||||||
|
],
|
||||||
|
'geolocation' => [
|
||||||
|
'passed' => (int)$geo['geo_passed'],
|
||||||
|
'failed' => (int)$geo['geo_failed'],
|
||||||
|
'bypass' => (int)$geo['geo_bypass'],
|
||||||
|
],
|
||||||
|
'recentOpens' => $recentOpens,
|
||||||
|
'guestQueueSummary' => [
|
||||||
|
'total' => (int)$queueSummary['total_actions'],
|
||||||
|
'pendingApi' => (int)$queueSummary['pending_api'],
|
||||||
|
'pendingKds' => (int)$queueSummary['pending_kds'],
|
||||||
|
'doneKds' => (int)$queueSummary['done_kds'],
|
||||||
|
],
|
||||||
|
'guestQueue' => $queueItems,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Nie udało się pobrać raportów analitycznych.'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
108
api/guest_action_queue.php
Normal file
108
api/guest_action_queue.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/database.php';
|
||||||
|
require_once __DIR__ . '/get_table_name.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Method not allowed'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody = file_get_contents('php://input');
|
||||||
|
$data = json_decode($rawBody, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid JSON payload'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tableId = isset($data['tableId']) ? trim((string)$data['tableId']) : '';
|
||||||
|
$messageType = isset($data['messageType']) ? trim((string)$data['messageType']) : '';
|
||||||
|
$messageText = isset($data['messageText']) ? trim((string)$data['messageText']) : '';
|
||||||
|
$qrHash = isset($data['qrHash']) ? trim((string)$data['qrHash']) : '';
|
||||||
|
|
||||||
|
$allowedTypes = ['waiter_call', 'bill_request'];
|
||||||
|
if ($messageType === '' || !in_array($messageType, $allowedTypes, true)) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid messageType'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tableId === '' && $qrHash !== '' && isset($conn)) {
|
||||||
|
$resolved = getTableNameByHash($conn, $qrHash);
|
||||||
|
if ($resolved !== '') {
|
||||||
|
$tableId = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tableId === '') {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'tableId is required'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($messageText === '') {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'messageText is required'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($tableId) > 32) {
|
||||||
|
$tableId = substr($tableId, 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = getAnalyticsPdo();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO guest_action_queue (
|
||||||
|
table_id,
|
||||||
|
message_type,
|
||||||
|
message_text,
|
||||||
|
api_sent,
|
||||||
|
status_kds,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
:table_id,
|
||||||
|
:message_type,
|
||||||
|
:message_text,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
NOW(3)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':table_id' => $tableId,
|
||||||
|
':message_type' => $messageType,
|
||||||
|
':message_text' => $messageText,
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'id' => (int)$pdo->lastInsertId()
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Queue insert failed'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,3 +18,30 @@ if (!$conn) {
|
|||||||
'errors' => sqlsrv_errors()
|
'errors' => sqlsrv_errors()
|
||||||
], JSON_UNESCAPED_UNICODE));
|
], JSON_UNESCAPED_UNICODE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Analytics MySQL (event tracking)
|
||||||
|
$DB_HOST = '192.168.20.24';
|
||||||
|
$DB_NAME = 'karczma_stoliki';
|
||||||
|
$DB_USER = 'karczma_stoliki';
|
||||||
|
$DB_PASS = 'Reuse-Splicing-Backfire-Bouncing-Operable4';
|
||||||
|
$DB_CHARSET = 'utf8mb4';
|
||||||
|
|
||||||
|
function getAnalyticsPdo()
|
||||||
|
{
|
||||||
|
global $DB_HOST, $DB_NAME, $DB_USER, $DB_PASS, $DB_CHARSET;
|
||||||
|
static $pdo = null;
|
||||||
|
|
||||||
|
if ($pdo instanceof PDO) {
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dsn = "mysql:host={$DB_HOST};dbname={$DB_NAME};charset={$DB_CHARSET}";
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
$pdo = new PDO($dsn, $DB_USER, $DB_PASS, $options);
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,8 +68,8 @@
|
|||||||
<p>Szybki dostęp do modułów aplikacji <strong>Karczma-Stoliki</strong>.</p>
|
<p>Szybki dostęp do modułów aplikacji <strong>Karczma-Stoliki</strong>.</p>
|
||||||
|
|
||||||
<div class="links-grid">
|
<div class="links-grid">
|
||||||
<a href="public/staff/generator.php" class="dev-link">
|
<a href="public/staff/index.php" class="dev-link">
|
||||||
🔗 Generator kodów QR (Kelner)
|
🔐 Panel Admina (logowanie)
|
||||||
</a>
|
</a>
|
||||||
<a href="public/staff/kds.php" class="dev-link kds">
|
<a href="public/staff/kds.php" class="dev-link kds">
|
||||||
🍳 Ekran KDS (Kuchnia)
|
🍳 Ekran KDS (Kuchnia)
|
||||||
|
|||||||
@@ -22,6 +22,108 @@ if (!hashParam) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tableParam = ""; // Puste, zostanie uzupełnione przez backend
|
let tableParam = ""; // Puste, zostanie uzupełnione przez backend
|
||||||
|
const analyticsEndpoint = "../api/analytics.php";
|
||||||
|
const guestActionQueueEndpoint = "../api/guest_action_queue.php";
|
||||||
|
const analyticsSessionKey = "karczma_analytics_session_id";
|
||||||
|
|
||||||
|
function getOrCreateAnalyticsSessionId() {
|
||||||
|
let existing = localStorage.getItem(analyticsSessionKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
let newId = "";
|
||||||
|
if (window.crypto && crypto.randomUUID) {
|
||||||
|
newId = crypto.randomUUID();
|
||||||
|
} else {
|
||||||
|
newId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
localStorage.setItem(analyticsSessionKey, newId);
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsSessionId = getOrCreateAnalyticsSessionId();
|
||||||
|
|
||||||
|
function detectDeviceType() {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
if (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)) return "ios";
|
||||||
|
if (/Android/i.test(ua)) return "android";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBrowser() {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
if (/Edg\//.test(ua)) return "edge";
|
||||||
|
if (/OPR\//.test(ua)) return "opera";
|
||||||
|
if (/Chrome\//.test(ua)) return "chrome";
|
||||||
|
if (/Safari\//.test(ua)) return "safari";
|
||||||
|
if (/Firefox\//.test(ua)) return "firefox";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveZoneFromTable(tableValue) {
|
||||||
|
const raw = String(tableValue || "").trim().toLowerCase();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
if (raw.startsWith("t") || raw.includes("taras")) return "taras";
|
||||||
|
if (raw.startsWith("k") || raw.includes("karczma")) return "karczma";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackEvent(eventName, payload = {}) {
|
||||||
|
const body = {
|
||||||
|
eventName,
|
||||||
|
sessionId: analyticsSessionId,
|
||||||
|
tableId: tableParam || null,
|
||||||
|
zone: deriveZoneFromTable(tableParam),
|
||||||
|
qrHash: hashParam || null,
|
||||||
|
deviceType: detectDeviceType(),
|
||||||
|
browser: detectBrowser(),
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyString = JSON.stringify(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
const blob = new Blob([bodyString], { type: "application/json" });
|
||||||
|
navigator.sendBeacon(analyticsEndpoint, blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to fetch below
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(analyticsEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: bodyString,
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {
|
||||||
|
// best effort - ignore analytics errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueGuestAction(messageType, messageText, extra = {}) {
|
||||||
|
const body = {
|
||||||
|
tableId: tableParam || null,
|
||||||
|
qrHash: hashParam || null,
|
||||||
|
messageType,
|
||||||
|
messageText,
|
||||||
|
extra
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(guestActionQueueEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {
|
||||||
|
// best effort - ignore queue errors in UI
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashParam) {
|
||||||
|
trackEvent("qr_scan", { source: "qr_link_open" });
|
||||||
|
}
|
||||||
|
|
||||||
// USER PROFILE LOGIC
|
// USER PROFILE LOGIC
|
||||||
const userProfileKey = "karczma_user_profile";
|
const userProfileKey = "karczma_user_profile";
|
||||||
@@ -537,6 +639,8 @@ function sendApiSimulated(actionName, details) {
|
|||||||
|
|
||||||
window.callWaiter = function (type) {
|
window.callWaiter = function (type) {
|
||||||
if (type === 'order') {
|
if (type === 'order') {
|
||||||
|
trackEvent("waiter_call_requested", { waiterType: "order" });
|
||||||
|
queueGuestAction("waiter_call", "Przywołanie kelnera", { waiterType: "order" });
|
||||||
sendApiSimulated("CallWaiter_Order", { table: tableParam });
|
sendApiSimulated("CallWaiter_Order", { table: tableParam });
|
||||||
showToast("Kelner wkrótce do Ciebie podejdzie!");
|
showToast("Kelner wkrótce do Ciebie podejdzie!");
|
||||||
}
|
}
|
||||||
@@ -558,6 +662,7 @@ window.confirmCallWaiter = function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.openBillDialog = async function () {
|
window.openBillDialog = async function () {
|
||||||
|
trackEvent("bill_dialog_opened");
|
||||||
billState = { payment: '', doc: '', nip: '', company: null, selectedBillId: null };
|
billState = { payment: '', doc: '', nip: '', company: null, selectedBillId: null };
|
||||||
document.getElementById("billModal").classList.add("active");
|
document.getElementById("billModal").classList.add("active");
|
||||||
document.body.style.overflow = 'hidden'; // Zablokuj scroll tła
|
document.body.style.overflow = 'hidden'; // Zablokuj scroll tła
|
||||||
@@ -671,6 +776,7 @@ window.switchTab = function (tabName) {
|
|||||||
|
|
||||||
// 2. Nadaj .active wybranym elementom
|
// 2. Nadaj .active wybranym elementom
|
||||||
if (tabName === 'status') {
|
if (tabName === 'status') {
|
||||||
|
trackEvent("view_status");
|
||||||
const view = document.getElementById('statusView');
|
const view = document.getElementById('statusView');
|
||||||
view.classList.remove('hidden');
|
view.classList.remove('hidden');
|
||||||
view.classList.add('active');
|
view.classList.add('active');
|
||||||
@@ -680,6 +786,7 @@ window.switchTab = function (tabName) {
|
|||||||
if (greetingBanner && greetingBanner.innerHTML.trim() !== '') greetingBanner.style.display = '';
|
if (greetingBanner && greetingBanner.innerHTML.trim() !== '') greetingBanner.style.display = '';
|
||||||
}
|
}
|
||||||
else if (tabName === 'menu') {
|
else if (tabName === 'menu') {
|
||||||
|
trackEvent("view_menu");
|
||||||
const view = document.getElementById('menuView');
|
const view = document.getElementById('menuView');
|
||||||
view.classList.remove('hidden');
|
view.classList.remove('hidden');
|
||||||
view.classList.add('active');
|
view.classList.add('active');
|
||||||
@@ -693,6 +800,11 @@ window.switchTab = function (tabName) {
|
|||||||
// --- MENU LOGIC ---
|
// --- MENU LOGIC ---
|
||||||
window.filterMenu = function () {
|
window.filterMenu = function () {
|
||||||
const query = document.getElementById('menuSearchInput').value.toLowerCase();
|
const query = document.getElementById('menuSearchInput').value.toLowerCase();
|
||||||
|
const now = Date.now();
|
||||||
|
if (query.length >= 2 && (!window.lastMenuSearchEventAt || now - window.lastMenuSearchEventAt > 8000)) {
|
||||||
|
window.lastMenuSearchEventAt = now;
|
||||||
|
trackEvent("menu_search", { queryLength: query.length });
|
||||||
|
}
|
||||||
const categories = document.querySelectorAll('.rm-category');
|
const categories = document.querySelectorAll('.rm-category');
|
||||||
|
|
||||||
categories.forEach(category => {
|
categories.forEach(category => {
|
||||||
@@ -749,6 +861,12 @@ window.selectPayment = function (method) {
|
|||||||
window.selectDocument = function (docType) {
|
window.selectDocument = function (docType) {
|
||||||
billState.doc = docType;
|
billState.doc = docType;
|
||||||
if (docType === 'paragon') {
|
if (docType === 'paragon') {
|
||||||
|
trackEvent("bill_request_sent", { docType: "paragon" });
|
||||||
|
const queueMessage = `Prośba o rachunek | forma płatności: ${billState.payment || "nieznana"} | dokument: paragon`;
|
||||||
|
queueGuestAction("bill_request", queueMessage, {
|
||||||
|
payment: billState.payment || null,
|
||||||
|
docType: "paragon"
|
||||||
|
});
|
||||||
closeBillDialog();
|
closeBillDialog();
|
||||||
sendApiSimulated("CallWaiter_Bill", { table: tableParam, billId: billState.selectedBillId, payment: billState.payment, doc: 'paragon' });
|
sendApiSimulated("CallWaiter_Bill", { table: tableParam, billId: billState.selectedBillId, payment: billState.payment, doc: 'paragon' });
|
||||||
showToast("Kelner przyniesie paragon do opłacenia!");
|
showToast("Kelner przyniesie paragon do opłacenia!");
|
||||||
@@ -931,6 +1049,14 @@ window.confirmInvoice = function () {
|
|||||||
billState.company.city = document.getElementById("cmpCity").value;
|
billState.company.city = document.getElementById("cmpCity").value;
|
||||||
|
|
||||||
closeBillDialog();
|
closeBillDialog();
|
||||||
|
trackEvent("bill_request_sent", { docType: "faktura" });
|
||||||
|
const queueMessage = `Prośba o rachunek | forma płatności: ${billState.payment || "nieznana"} | dokument: faktura | NIP: ${billState.nip || "-"} | firma: ${billState.company?.name || "-"}`;
|
||||||
|
queueGuestAction("bill_request", queueMessage, {
|
||||||
|
payment: billState.payment || null,
|
||||||
|
docType: "faktura",
|
||||||
|
nip: billState.nip || null,
|
||||||
|
company: billState.company || null
|
||||||
|
});
|
||||||
sendApiSimulated("CallWaiter_Bill", {
|
sendApiSimulated("CallWaiter_Bill", {
|
||||||
table: tableParam,
|
table: tableParam,
|
||||||
billId: billState.selectedBillId,
|
billId: billState.selectedBillId,
|
||||||
@@ -964,6 +1090,7 @@ function haversineDistance(lat1, lon1, lat2, lon2) {
|
|||||||
function startApp() {
|
function startApp() {
|
||||||
document.getElementById("geoScreen").classList.add("hidden");
|
document.getElementById("geoScreen").classList.add("hidden");
|
||||||
document.getElementById("loadingScreen").classList.remove("hidden");
|
document.getElementById("loadingScreen").classList.remove("hidden");
|
||||||
|
trackEvent("session_start", { flow: "start_app" });
|
||||||
|
|
||||||
initUserProfile();
|
initUserProfile();
|
||||||
fetchOrders();
|
fetchOrders();
|
||||||
@@ -1010,6 +1137,7 @@ function shouldBypassGeolocationHost() {
|
|||||||
window.initGeolocation = function () {
|
window.initGeolocation = function () {
|
||||||
if (shouldBypassGeolocationHost()) {
|
if (shouldBypassGeolocationHost()) {
|
||||||
console.warn("Bypassing geolocation for trusted host.");
|
console.warn("Bypassing geolocation for trusted host.");
|
||||||
|
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "trusted_host" });
|
||||||
startApp();
|
startApp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1150,7 @@ window.initGeolocation = function () {
|
|||||||
const bypassHosts = ['localhost', '127.0.0.1', '192.168.20.84'];
|
const bypassHosts = ['localhost', '127.0.0.1', '192.168.20.84'];
|
||||||
if (window.location.protocol === 'http:' && bypassHosts.includes(window.location.hostname)) {
|
if (window.location.protocol === 'http:' && bypassHosts.includes(window.location.hostname)) {
|
||||||
console.warn("Bypassing geolocation on local HTTP environment.");
|
console.warn("Bypassing geolocation on local HTTP environment.");
|
||||||
|
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "local_http" });
|
||||||
startApp();
|
startApp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1048,6 +1177,7 @@ window.initGeolocation = function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
geoMsg.innerHTML = "Sprawdzamy Twoją lokalizację...";
|
geoMsg.innerHTML = "Sprawdzamy Twoją lokalizację...";
|
||||||
|
trackEvent("geo_check_started");
|
||||||
if (geoActionBtn) {
|
if (geoActionBtn) {
|
||||||
geoActionBtn.disabled = true;
|
geoActionBtn.disabled = true;
|
||||||
geoActionBtn.textContent = "Sprawdzanie...";
|
geoActionBtn.textContent = "Sprawdzanie...";
|
||||||
@@ -1064,9 +1194,11 @@ window.initGeolocation = function () {
|
|||||||
console.log(`[GEO] Lat: ${position.coords.latitude}, Lng: ${position.coords.longitude}, Dist: ${dist}m, Accuracy: ${accuracy}m`);
|
console.log(`[GEO] Lat: ${position.coords.latitude}, Lng: ${position.coords.longitude}, Dist: ${dist}m, Accuracy: ${accuracy}m`);
|
||||||
|
|
||||||
if (dist <= MAX_DISTANCE_METERS) {
|
if (dist <= MAX_DISTANCE_METERS) {
|
||||||
|
trackEvent("geo_check_passed", { distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) });
|
||||||
startApp();
|
startApp();
|
||||||
// setTimeout(() => showToast(`Lokalizacja zweryfikowana (Dystans: ${Math.round(dist)}m, Dokładność: ${Math.round(accuracy)}m)`), 2000);
|
// setTimeout(() => showToast(`Lokalizacja zweryfikowana (Dystans: ${Math.round(dist)}m, Dokładność: ${Math.round(accuracy)}m)`), 2000);
|
||||||
} else {
|
} else {
|
||||||
|
trackEvent("geo_check_failed", { reason: "outside_restaurant", distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) });
|
||||||
if (geoActionBtn) {
|
if (geoActionBtn) {
|
||||||
geoActionBtn.disabled = false;
|
geoActionBtn.disabled = false;
|
||||||
geoActionBtn.textContent = "Spróbuj ponownie";
|
geoActionBtn.textContent = "Spróbuj ponownie";
|
||||||
@@ -1077,6 +1209,7 @@ window.initGeolocation = function () {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
trackEvent("geo_check_failed", { reason: "browser_error", code: error.code || null, message: String(error.message || "") });
|
||||||
if (geoActionBtn) {
|
if (geoActionBtn) {
|
||||||
geoActionBtn.disabled = false;
|
geoActionBtn.disabled = false;
|
||||||
geoActionBtn.textContent = "Spróbuj ponownie";
|
geoActionBtn.textContent = "Spróbuj ponownie";
|
||||||
|
|||||||
54
public/staff/auth.php
Normal file
54
public/staff/auth.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
const ADMIN_USERNAME = 'admin';
|
||||||
|
const ADMIN_PASSWORD = 'karczma2026!';
|
||||||
|
|
||||||
|
function startAdminSession(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminLoggedIn(): bool
|
||||||
|
{
|
||||||
|
startAdminSession();
|
||||||
|
return !empty($_SESSION['staff_logged_in']) && $_SESSION['staff_logged_in'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdminAuth(bool $redirectToLogin = true): void
|
||||||
|
{
|
||||||
|
if (isAdminLoggedIn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($redirectToLogin) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptAdminLogin(string $username, string $password): bool
|
||||||
|
{
|
||||||
|
startAdminSession();
|
||||||
|
|
||||||
|
if (hash_equals(ADMIN_USERNAME, $username) && hash_equals(ADMIN_PASSWORD, $password)) {
|
||||||
|
$_SESSION['staff_logged_in'] = true;
|
||||||
|
$_SESSION['staff_username'] = ADMIN_USERNAME;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminLogout(): void
|
||||||
|
{
|
||||||
|
startAdminSession();
|
||||||
|
$_SESSION = [];
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,99 +1,111 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
requireAdminAuth(true);
|
||||||
require_once __DIR__ . '/../../config/database.php';
|
require_once __DIR__ . '/../../config/database.php';
|
||||||
|
|
||||||
$tsql = "SELECT ID, Nazwa FROM dbo.NGastroStolik ORDER BY Nazwa";
|
$tsql = "SELECT ID, Nazwa FROM dbo.NGastroStolik ORDER BY Nazwa";
|
||||||
$stmt = sqlsrv_query($conn, $tsql);
|
$stmt = sqlsrv_query($conn, $tsql);
|
||||||
|
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
$baseUrl = "https://$host/app/public/app.html?h=";
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/public/staff'));
|
||||||
echo "<!DOCTYPE html>
|
$publicDir = str_replace('\\', '/', dirname($scriptDir));
|
||||||
<html lang='pl'>
|
$basePath = rtrim($publicDir, '/') . '/app.html?h=';
|
||||||
|
$baseUrl = "{$scheme}://{$host}{$basePath}";
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset='UTF-8'>
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Generator Linków QR - Stoliki</title>
|
<title>Generator Linków QR - Stoliki</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: sans-serif; padding: 20px; }
|
body { font-family: Inter, Arial, sans-serif; padding: 20px; background: #0f172a; color: #e2e8f0; }
|
||||||
table { border-collapse: collapse; width: 100%; max-width: 800px; }
|
h1 { margin: 0 0 10px; }
|
||||||
th, td { border: 1px solid #ccc; padding: 10px; text-align: left; }
|
p { color: #94a3b8; }
|
||||||
th { background: #eee; }
|
table { border-collapse: collapse; width: 100%; max-width: 1100px; background: #111827; border: 1px solid #334155; }
|
||||||
a { color: #0066cc; text-decoration: none; }
|
th, td { border: 1px solid #334155; padding: 10px; text-align: left; }
|
||||||
|
th { background: #0b1220; }
|
||||||
|
a { color: #93c5fd; text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
.btn-qr { padding: 5px 10px; background: #3b82f6; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
|
.btn-qr { padding: 8px 10px; background: #3b82f6; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
|
||||||
.btn-qr:hover { background: #2563eb; }
|
.btn-qr:hover { background: #2563eb; }
|
||||||
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); align-items: center; justify-content: center; }
|
.btn-nav { display:inline-block; margin-right:8px; margin-bottom:14px; color:#cbd5e1; text-decoration:none; border:1px solid #334155; padding:8px 12px; border-radius:10px; }
|
||||||
.modal-content { background: #fff; padding: 20px; border-radius: 8px; text-align: center; max-width: 400px; width: 90%; }
|
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); align-items: center; justify-content: center; }
|
||||||
.close { float: right; font-size: 24px; font-weight: bold; cursor: pointer; line-height: 20px; }
|
.modal-content { background: #111827; border:1px solid #334155; padding: 20px; border-radius: 8px; text-align: center; max-width: 420px; width: 90%; }
|
||||||
|
.close { float: right; font-size: 24px; font-weight: bold; cursor: pointer; line-height: 20px; color:#cbd5e1; }
|
||||||
#qrcode { margin-top: 20px; display: flex; justify-content: center; }
|
#qrcode { margin-top: 20px; display: flex; justify-content: center; }
|
||||||
</style>
|
</style>
|
||||||
<script src='https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js'></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Linki do aplikacji (Kody QR)</h1>
|
<a class="btn-nav" href="index.php">Wróć do panelu admina</a>
|
||||||
|
<a class="btn-nav" href="logout.php">Wyloguj</a>
|
||||||
|
<h1>Linki do aplikacji (kody QR)</h1>
|
||||||
<p>Skopiuj poniższe linki lub wygeneruj z nich kody QR do umieszczenia na stolikach.</p>
|
<p>Skopiuj poniższe linki lub wygeneruj z nich kody QR do umieszczenia na stolikach.</p>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nazwa stolika</th>
|
<th>Nazwa stolika</th>
|
||||||
<th>Hash (ID z bazy)</th>
|
<th>Hash (ID z bazy)</th>
|
||||||
<th>Bezpieczny Link (KOD QR)</th>
|
<th>Bezpieczny link</th>
|
||||||
<th>Akcje</th>
|
<th>Akcje</th>
|
||||||
</tr>";
|
</tr>
|
||||||
|
<?php while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)): ?>
|
||||||
|
<?php
|
||||||
|
$id = strtoupper((string)$row['ID']);
|
||||||
|
$nazwa = htmlspecialchars((string)$row['Nazwa'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$link = $baseUrl . $id;
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= $nazwa ?></strong></td>
|
||||||
|
<td style="font-size:.85em;color:#94a3b8;"><?= $id ?></td>
|
||||||
|
<td><a href="<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>" target="_blank"><?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?></a></td>
|
||||||
|
<td><button class="btn-qr" onclick="openQR('<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>', '<?= $nazwa ?>')">Pokaż QR</button></td>
|
||||||
|
</tr>
|
||||||
|
<?php endwhile; ?>
|
||||||
|
</table>
|
||||||
|
|
||||||
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
|
<div id="qrModal" class="modal">
|
||||||
$id = strtoupper($row['ID']);
|
<div class="modal-content">
|
||||||
$nazwa = htmlspecialchars($row['Nazwa']);
|
<span class="close" onclick="closeQR()">×</span>
|
||||||
$link = $baseUrl . $id;
|
<h2 id="qrTitle">Kod QR</h2>
|
||||||
echo "<tr>
|
<div id="qrcode"></div>
|
||||||
<td><strong>$nazwa</strong></td>
|
<p style="margin-top:20px;"><a id="qrLink" href="" target="_blank">Otwórz link w nowym oknie</a></p>
|
||||||
<td style='font-size: 0.8em; color: #666;'>$id</td>
|
|
||||||
<td><a href='$link' target='_blank'>$link</a></td>
|
|
||||||
<td><button class='btn-qr' onclick='openQR(\"$link\", \"$nazwa\")'>Pokaż QR</button></td>
|
|
||||||
</tr>";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo " </table>
|
|
||||||
|
|
||||||
<div id='qrModal' class='modal'>
|
|
||||||
<div class='modal-content'>
|
|
||||||
<span class='close' onclick='closeQR()'>×</span>
|
|
||||||
<h2 id='qrTitle'>Kod QR</h2>
|
|
||||||
<div id='qrcode'></div>
|
|
||||||
<p style='margin-top:20px;'><a id='qrLink' href='' target='_blank' style='color: #3b82f6;'>Otwórz link w nowym oknie</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function openQR(link, nazwa) {
|
function openQR(link, nazwa) {
|
||||||
document.getElementById('qrModal').style.display = 'flex';
|
document.getElementById("qrModal").style.display = "flex";
|
||||||
document.getElementById('qrTitle').innerText = 'Stolik: ' + nazwa;
|
document.getElementById("qrTitle").innerText = "Stolik: " + nazwa;
|
||||||
document.getElementById('qrLink').href = link;
|
document.getElementById("qrLink").href = link;
|
||||||
|
|
||||||
var qrContainer = document.getElementById('qrcode');
|
const qrContainer = document.getElementById("qrcode");
|
||||||
qrContainer.innerHTML = ''; // Wyczyść poprzedni kod
|
qrContainer.innerHTML = "";
|
||||||
|
|
||||||
new QRCode(qrContainer, {
|
new QRCode(qrContainer, {
|
||||||
text: link,
|
text: link,
|
||||||
width: 250,
|
width: 250,
|
||||||
height: 250,
|
height: 250,
|
||||||
colorDark : '#000000',
|
colorDark: "#000000",
|
||||||
colorLight : '#ffffff',
|
colorLight: "#ffffff",
|
||||||
correctLevel : QRCode.CorrectLevel.H
|
correctLevel: QRCode.CorrectLevel.H
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeQR() {
|
function closeQR() {
|
||||||
document.getElementById('qrModal').style.display = 'none';
|
document.getElementById("qrModal").style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onclick = function(event) {
|
window.onclick = function (event) {
|
||||||
var modal = document.getElementById('qrModal');
|
const modal = document.getElementById("qrModal");
|
||||||
if (event.target == modal) {
|
if (event.target === modal) {
|
||||||
closeQR();
|
closeQR();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>";
|
</html>
|
||||||
|
<?php
|
||||||
sqlsrv_free_stmt($stmt);
|
sqlsrv_free_stmt($stmt);
|
||||||
sqlsrv_close($conn);
|
sqlsrv_close($conn);
|
||||||
|
|||||||
255
public/staff/index.php
Normal file
255
public/staff/index.php
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<?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>
|
||||||
|
<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; }
|
||||||
|
.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; }
|
||||||
|
@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 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">Wysłane prośby o podejście obsługi</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>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kiedy</th><th>Stolik</th><th>Strefa</th><th>Urządzenie</th><th>Przeglądarka</th><th>IP</th></tr></thead>
|
||||||
|
<tbody id="recentOpensBody"><tr><td colspan="6" class="muted">Ładowanie...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</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>Typ</th><th>Treść</th><th>Wysłane API</th><th>KDS gotowe</th></tr></thead>
|
||||||
|
<tbody id="guestQueueBody"><tr><td colspan="7" 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</strong> - ile razy gość poprosił o podejście obsługi.</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>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const chips = Array.from(document.querySelectorAll(".chip"));
|
||||||
|
let selectedDays = "7";
|
||||||
|
let isLoadingAnalytics = false;
|
||||||
|
|
||||||
|
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.funnel?.waiter_call_requested);
|
||||||
|
|
||||||
|
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</td><td>${n(f.session_start)}</td></tr>
|
||||||
|
<tr><td>Wejście do menu</td><td>${n(f.view_menu)}</td></tr>
|
||||||
|
<tr><td>Przywołanie kelnera</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 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 || "-";
|
||||||
|
return `<tr><td>${when}</td><td>${r.table_id || "-"}</td><td>${r.zone || "-"}</td><td>${device}</td><td>${browser}</td><td>${r.ip_address || "-"}</td></tr>`;
|
||||||
|
}).join("")
|
||||||
|
: `<tr><td colspan="6" class="muted">Brak danych</td></tr>`;
|
||||||
|
|
||||||
|
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, ">");
|
||||||
|
return `<tr><td>${row.id}</td><td>${when}</td><td>${row.table_id || "-"}</td><td>${typeLabel}</td><td>${messageText}</td><td>${apiSent}</td><td>${kdsDone}</td></tr>`;
|
||||||
|
}).join("")
|
||||||
|
: `<tr><td colspan="7" 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="6" class="muted">Nie udało się pobrać ostatnich otwarć.</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="7" 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>
|
||||||
|
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
requireAdminAuth(true);
|
||||||
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="pl">
|
<html lang="pl">
|
||||||
<head>
|
<head>
|
||||||
@@ -233,6 +237,10 @@
|
|||||||
<span id="last-sync">Łączenie z bazą...</span>
|
<span id="last-sync">Łączenie z bazą...</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div style="margin-bottom: 16px; display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<a href="index.php" style="color:#93c5fd; text-decoration:none; border:1px solid #334155; padding:8px 12px; border-radius:10px;">Wróć do panelu admina</a>
|
||||||
|
<a href="logout.php" style="color:#cbd5e1; text-decoration:none; border:1px solid #334155; padding:8px 12px; border-radius:10px;">Wyloguj</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="kds-grid" class="kds-grid">
|
<div id="kds-grid" class="kds-grid">
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
|
|||||||
55
public/staff/login.php
Normal file
55
public/staff/login.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
|
||||||
|
startAdminSession();
|
||||||
|
if (isAdminLoggedIn()) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$username = trim((string)($_POST['username'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
if (attemptAdminLogin($username, $password)) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = 'Nieprawidłowy login lub hasło.';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Panel Admina - Logowanie</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Inter, Arial, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; min-height: 100vh; display: grid; place-items: center; }
|
||||||
|
.card { width: min(420px, 92vw); background: #111827; border: 1px solid #334155; border-radius: 14px; padding: 24px; }
|
||||||
|
h1 { margin: 0 0 8px; font-size: 1.4rem; }
|
||||||
|
p { margin: 0 0 20px; color: #94a3b8; }
|
||||||
|
label { display: block; margin-bottom: 6px; font-size: .9rem; color: #cbd5e1; }
|
||||||
|
input { width: 100%; box-sizing: border-box; border: 1px solid #475569; background: #0b1220; color: #e2e8f0; border-radius: 10px; padding: 11px 12px; margin-bottom: 14px; }
|
||||||
|
button { width: 100%; border: 0; border-radius: 10px; padding: 11px 12px; background: #3b82f6; color: #fff; font-weight: 600; cursor: pointer; }
|
||||||
|
.error { margin-bottom: 12px; padding: 10px; border-radius: 9px; background: #451a1a; color: #fecaca; border: 1px solid #7f1d1d; }
|
||||||
|
.hint { margin-top: 14px; font-size: .82rem; color: #64748b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="card" method="post" action="">
|
||||||
|
<h1>Panel Admina</h1>
|
||||||
|
<p>Zaloguj się, aby przejść do KDS, generatora QR i analityki.</p>
|
||||||
|
<?php if ($error !== ''): ?>
|
||||||
|
<div class="error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<label for="username">Login</label>
|
||||||
|
<input id="username" name="username" required autocomplete="username">
|
||||||
|
<label for="password">Hasło</label>
|
||||||
|
<input id="password" name="password" type="password" required autocomplete="current-password">
|
||||||
|
<button type="submit">Zaloguj</button>
|
||||||
|
<div class="hint">Dane logowania są ustawione w pliku `public/staff/auth.php`.</div>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
6
public/staff/logout.php
Normal file
6
public/staff/logout.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
adminLogout();
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
|
||||||
64
scripts/analytics_aggregate_daily.php
Normal file
64
scripts/analytics_aggregate_daily.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
// Daily aggregation from analytics_events to analytics_daily_table_stats
|
||||||
|
// Usage:
|
||||||
|
// php scripts/analytics_aggregate_daily.php
|
||||||
|
// php scripts/analytics_aggregate_daily.php 2026-05-28
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/database.php';
|
||||||
|
|
||||||
|
$targetDate = $argv[1] ?? date('Y-m-d', strtotime('-1 day'));
|
||||||
|
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $targetDate)) {
|
||||||
|
fwrite(STDERR, "Invalid date format. Use YYYY-MM-DD\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = getAnalyticsPdo();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$deleteStmt = $pdo->prepare("DELETE FROM analytics_daily_table_stats WHERE stat_date = :stat_date");
|
||||||
|
$deleteStmt->execute([':stat_date' => $targetDate]);
|
||||||
|
|
||||||
|
$insertSql = "
|
||||||
|
INSERT INTO analytics_daily_table_stats (
|
||||||
|
stat_date,
|
||||||
|
table_id,
|
||||||
|
zone,
|
||||||
|
qr_scans,
|
||||||
|
sessions,
|
||||||
|
unique_devices,
|
||||||
|
geo_pass,
|
||||||
|
geo_fail,
|
||||||
|
bill_opened,
|
||||||
|
bill_completed
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) AS stat_date,
|
||||||
|
COALESCE(NULLIF(table_id, ''), 'unknown') AS table_id,
|
||||||
|
COALESCE(NULLIF(zone, ''), 'unknown') AS zone,
|
||||||
|
SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans,
|
||||||
|
SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions,
|
||||||
|
COUNT(DISTINCT ip_hash) AS unique_devices,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass,
|
||||||
|
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail,
|
||||||
|
SUM(CASE WHEN event_name = 'bill_dialog_opened' THEN 1 ELSE 0 END) AS bill_opened,
|
||||||
|
SUM(CASE WHEN event_name = 'bill_request_sent' THEN 1 ELSE 0 END) AS bill_completed
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE DATE(created_at) = :stat_date
|
||||||
|
GROUP BY DATE(created_at), COALESCE(NULLIF(table_id, ''), 'unknown'), COALESCE(NULLIF(zone, ''), 'unknown')
|
||||||
|
";
|
||||||
|
|
||||||
|
$insertStmt = $pdo->prepare($insertSql);
|
||||||
|
$insertStmt->execute([':stat_date' => $targetDate]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
echo "OK: Aggregation completed for {$targetDate}\n";
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
fwrite(STDERR, "Aggregation failed: {$e->getMessage()}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
15
scripts/guest_action_queue.sql
Normal file
15
scripts/guest_action_queue.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS guest_action_queue (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
table_id VARCHAR(32) NOT NULL,
|
||||||
|
message_type ENUM('waiter_call', 'bill_request') NOT NULL,
|
||||||
|
message_text TEXT NOT NULL,
|
||||||
|
api_sent TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
status_kds TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_guest_action_api_sent_created (api_sent, created_at),
|
||||||
|
KEY idx_guest_action_status_kds_created (status_kds, created_at),
|
||||||
|
KEY idx_guest_action_table_created (table_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
Reference in New Issue
Block a user