Naprawa lokalizacji - odczytu
This commit is contained in:
291
public/waiter/app.css
Normal file
291
public/waiter/app.css
Normal file
@@ -0,0 +1,291 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--border: #334155;
|
||||
--text: #f8fafc;
|
||||
--muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--success: #22c55e;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
padding-bottom: max(24px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.top-bar h1 {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sync-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-dot.ok {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.sync-dot.err {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 8px var(--danger);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.75; transform: scale(0.95); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.notify-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #1e293b 100%);
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.notify-banner p {
|
||||
margin-top: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feed-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
animation: slideIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
.feed-card.is-new {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.feed-card.waiter {
|
||||
border-left: 5px solid var(--danger);
|
||||
}
|
||||
|
||||
.feed-card.bill {
|
||||
border-left: 5px solid var(--warning);
|
||||
}
|
||||
|
||||
.feed-card.done {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.feed-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.feed-card.waiter .feed-title {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.feed-card.bill .feed-title {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.feed-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feed-table {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.feed-operator {
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feed-operator strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.feed-msg {
|
||||
font-size: 0.9rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.45;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.feed-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #fca5a5;
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.badge-done {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #86efac;
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
body {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
249
public/waiter/app.js
Normal file
249
public/waiter/app.js
Normal file
@@ -0,0 +1,249 @@
|
||||
const API_URL = '../../api/waiter_feed.php';
|
||||
const POLL_MS = 15000;
|
||||
|
||||
const feedList = document.getElementById('feedList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const syncDot = document.getElementById('syncDot');
|
||||
const syncLabel = document.getElementById('syncLabel');
|
||||
const notifyBanner = document.getElementById('notifyBanner');
|
||||
const enableNotifyBtn = document.getElementById('enableNotifyBtn');
|
||||
|
||||
const statPending = document.getElementById('statPending');
|
||||
const statWaiter = document.getElementById('statWaiter');
|
||||
const statBill = document.getElementById('statBill');
|
||||
const statTotal = document.getElementById('statTotal');
|
||||
|
||||
let knownIds = new Set();
|
||||
let feedInitialized = false;
|
||||
let pollTimer = null;
|
||||
let lastPayload = '';
|
||||
let swRegistration = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function formatTime(isoLike) {
|
||||
if (!isoLike) return '';
|
||||
const dt = new Date(String(isoLike).replace(' ', 'T'));
|
||||
if (Number.isNaN(dt.getTime())) return '';
|
||||
return dt.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatOperator(row) {
|
||||
const imie = (row.otwierajacy_imie || '').trim();
|
||||
const nazwisko = (row.otwierajacy_nazwisko || '').trim();
|
||||
return `${imie} ${nazwisko}`.trim();
|
||||
}
|
||||
|
||||
function updateNotifyBanner() {
|
||||
if (!('Notification' in window)) {
|
||||
notifyBanner.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'default') {
|
||||
notifyBanner.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
notifyBanner.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
swRegistration = await navigator.serviceWorker.register('sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
return swRegistration;
|
||||
} catch (err) {
|
||||
console.warn('[waiter] SW registration failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestNotifications() {
|
||||
if (!('Notification' in window)) {
|
||||
alert('Ta przeglądarka nie obsługuje powiadomień.');
|
||||
return;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
updateNotifyBanner();
|
||||
|
||||
if (permission === 'granted') {
|
||||
await registerServiceWorker();
|
||||
}
|
||||
}
|
||||
|
||||
function showNotification(row) {
|
||||
const isWaiter = row.message_type === 'waiter_call';
|
||||
const title = isWaiter ? 'Wezwanie kelnera' : 'Prośba o rachunek';
|
||||
const firstLine = (row.message_text || '').split('\n')[0].trim();
|
||||
const body = `Stolik ${row.table_id || '?'}` + (firstLine ? `\n${firstLine}` : '');
|
||||
|
||||
const payload = {
|
||||
type: 'notify',
|
||||
title,
|
||||
body,
|
||||
tag: `waiter-${row.id}`,
|
||||
};
|
||||
|
||||
if (swRegistration && swRegistration.active) {
|
||||
swRegistration.active.postMessage(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title, {
|
||||
body,
|
||||
tag: payload.tag,
|
||||
renotify: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewRows(rows) {
|
||||
const fresh = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const id = Number(row.id);
|
||||
if (!feedInitialized) {
|
||||
knownIds.add(id);
|
||||
continue;
|
||||
}
|
||||
if (!knownIds.has(id)) {
|
||||
knownIds.add(id);
|
||||
fresh.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
feedInitialized = true;
|
||||
|
||||
for (const row of fresh) {
|
||||
showNotification(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFeed(rows) {
|
||||
if (!rows.length) {
|
||||
feedList.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
feedList.innerHTML = rows.map((row) => {
|
||||
const isWaiter = row.message_type === 'waiter_call';
|
||||
const typeLabel = isWaiter ? 'Wezwanie kelnera' : 'Prośba o rachunek';
|
||||
const operator = formatOperator(row);
|
||||
const operatorHtml = operator
|
||||
? `<div class="feed-operator">Kelner stolika: <strong>${escapeHtml(operator)}</strong></div>`
|
||||
: '';
|
||||
const pending = Number(row.status_kds) === 0;
|
||||
const cardClass = [
|
||||
'feed-card',
|
||||
isWaiter ? 'waiter' : 'bill',
|
||||
pending ? '' : 'done',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return `
|
||||
<article class="${cardClass}" data-id="${row.id}">
|
||||
<div class="feed-head">
|
||||
<div class="feed-title">${escapeHtml(typeLabel)}</div>
|
||||
<div class="feed-time">${escapeHtml(formatTime(row.created_at))}</div>
|
||||
</div>
|
||||
<div class="feed-table">Stolik ${escapeHtml(row.table_id || '?')}</div>
|
||||
${operatorHtml}
|
||||
<div class="feed-msg">${escapeHtml(row.message_text || '')}</div>
|
||||
<div class="feed-badges">
|
||||
<span class="badge ${pending ? 'badge-pending' : 'badge-done'}">
|
||||
${pending ? 'Aktywne w KDS' : 'Obsłużone w KDS'}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateStats(summary, total) {
|
||||
statPending.textContent = String(summary?.pending ?? 0);
|
||||
statWaiter.textContent = String(summary?.waiter_calls ?? 0);
|
||||
statBill.textContent = String(summary?.bill_requests ?? 0);
|
||||
statTotal.textContent = String(total ?? 0);
|
||||
}
|
||||
|
||||
function setSyncState(ok, message) {
|
||||
syncDot.classList.toggle('ok', ok);
|
||||
syncDot.classList.toggle('err', !ok);
|
||||
syncLabel.textContent = message;
|
||||
}
|
||||
|
||||
async function pollFeed() {
|
||||
try {
|
||||
const response = await fetch(API_URL, { cache: 'no-store' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status !== 'success') {
|
||||
setSyncState(false, 'Błąd API');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(result.data || []);
|
||||
const rows = result.data || [];
|
||||
|
||||
handleNewRows(rows);
|
||||
|
||||
if (payload !== lastPayload) {
|
||||
renderFeed(rows);
|
||||
lastPayload = payload;
|
||||
}
|
||||
|
||||
updateStats(result.summary, result.count);
|
||||
|
||||
const now = new Date();
|
||||
setSyncState(true, `Sync ${now.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`);
|
||||
} catch {
|
||||
setSyncState(false, 'Brak połączenia');
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
pollFeed();
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(pollFeed, POLL_MS);
|
||||
}
|
||||
|
||||
enableNotifyBtn?.addEventListener('click', () => {
|
||||
requestNotifications();
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
pollFeed();
|
||||
}
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
updateNotifyBanner();
|
||||
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
await registerServiceWorker();
|
||||
} else if ('Notification' in window && Notification.permission === 'default') {
|
||||
notifyBanner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
startPolling();
|
||||
})();
|
||||
66
public/waiter/index.php
Normal file
66
public/waiter/index.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/asset_version.php';
|
||||
$waiterDir = __DIR__;
|
||||
$vCss = publicAssetVersion($waiterDir, 'app.css');
|
||||
$vJs = publicAssetVersion($waiterDir, 'app.js');
|
||||
?><!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>Kelner – wezwania</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="app.css?v=<?= assetVersionAttr($vCss) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-bar">
|
||||
<div>
|
||||
<h1>Panel kelnera</h1>
|
||||
<p class="subtitle">Wezwania i prośby o rachunek · dziś</p>
|
||||
</div>
|
||||
<div class="sync-pill" id="syncPill">
|
||||
<span class="sync-dot" id="syncDot"></span>
|
||||
<span id="syncLabel">Łączenie…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="notify-banner hidden" id="notifyBanner">
|
||||
<div>
|
||||
<strong>Powiadomienia wyłączone</strong>
|
||||
<p>Włącz je, aby dostać alert przy nowym wezwaniu, nawet gdy ekran jest zablokowany w tle.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="enableNotifyBtn">Włącz powiadomienia</button>
|
||||
</section>
|
||||
|
||||
<section class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Aktywne</span>
|
||||
<span class="stat-value" id="statPending">–</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Kelner</span>
|
||||
<span class="stat-value" id="statWaiter">–</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Rachunek</span>
|
||||
<span class="stat-value" id="statBill">–</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Razem dziś</span>
|
||||
<span class="stat-value" id="statTotal">–</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main id="feedList" class="feed-list"></main>
|
||||
|
||||
<div class="empty-state hidden" id="emptyState">
|
||||
<div class="empty-icon">🛎️</div>
|
||||
<p>Brak wezwań na dziś. Czekam na nowe…</p>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=<?= assetVersionAttr($vJs) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
public/waiter/sw.js
Normal file
23
public/waiter/sw.js
Normal file
@@ -0,0 +1,23 @@
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (!data || data.type !== 'notify') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Panel kelnera', {
|
||||
body: data.body || '',
|
||||
tag: data.tag || 'waiter-alert',
|
||||
renotify: true,
|
||||
requireInteraction: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user