Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc
This commit is contained in:
@@ -1,22 +1,34 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import TableCard from '../components/TableCard'
|
||||
import ConnectionBanner from '../components/ConnectionBanner'
|
||||
import EmergencyBar from '../components/EmergencyBar'
|
||||
import UserMenu from '../components/UserMenu'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useTableColourStore from '../store/tableColourStore'
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
import useTableViewStore from '../store/tableViewStore'
|
||||
import client from '../api/client'
|
||||
import db from '../db/posdb'
|
||||
import { queueOfflinePayment } from '../services/offlinePayments'
|
||||
import { useNotifications } from '../context/NotificationContext'
|
||||
import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons'
|
||||
|
||||
const FILTERS = ['all', 'mine', 'free']
|
||||
const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύθερα' }
|
||||
|
||||
function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
|
||||
|
||||
// ─── Notification history drawer ─────────────────────────────────────────────
|
||||
// ─── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationDrawer({ messages, onClose, onAck }) {
|
||||
function FilterIcon({ size = 20 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notification drawer ──────────────────────────────────────────────────────
|
||||
|
||||
function NotificationDrawer({ messages, onClose }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
|
||||
@@ -37,9 +49,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
|
||||
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{msg.sender_name && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>
|
||||
{msg.sender_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>{msg.sender_name}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
|
||||
{tableIds.length > 0 && (
|
||||
@@ -59,7 +69,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Table quick-view + actions popup (long-press) ────────────────────────────
|
||||
// ─── Table quick-view modal (long press) ──────────────────────────────────────
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
|
||||
@@ -77,25 +87,18 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
||||
const due = Math.max(0, total - paid)
|
||||
|
||||
const statusLabel = {
|
||||
open: 'Ανοιχτό',
|
||||
partially_paid: 'Μερικώς πληρωμένο',
|
||||
paid: 'Πληρωμένο',
|
||||
open: 'Ανοιχτό', partially_paid: 'Μερικώς πληρωμένο', paid: 'Πληρωμένο',
|
||||
}[order?.status] || 'Ελεύθερο'
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
{/* Status overview card */}
|
||||
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{
|
||||
background: 'var(--bg2)', borderRadius: '16px 16px 0 0',
|
||||
padding: '16px 20px', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="modal-handle" style={{ marginBottom: 12 }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
{order ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||
@@ -116,7 +119,6 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
|
||||
)}
|
||||
|
||||
{flags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{flags.map(f => (
|
||||
@@ -132,47 +134,24 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
style={{ width: '100%', marginTop: 14 }}
|
||||
onClick={() => { onClose(); onNavigate() }}
|
||||
>
|
||||
<button className="btn btn--primary" style={{ width: '100%', marginTop: 14 }} onClick={() => { onClose(); onNavigate() }}>
|
||||
Άνοιγμα τραπεζιού
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick actions card */}
|
||||
<div style={{
|
||||
background: 'var(--bg2)', borderRadius: '0 0 16px 16px',
|
||||
padding: '8px 20px 24px',
|
||||
borderTop: '2px solid var(--border)',
|
||||
}}>
|
||||
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>
|
||||
ACTIONS
|
||||
</p>
|
||||
<div style={{ background: 'var(--bg2)', borderRadius: '0 0 16px 16px', padding: '8px 20px 24px', borderTop: '2px solid var(--border)' }}>
|
||||
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>ACTIONS</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{QUICK_ACTIONS.map((a, i) => {
|
||||
const disabled = !order && a.key !== 'flags'
|
||||
return (
|
||||
<button
|
||||
key={a.key}
|
||||
disabled={disabled}
|
||||
onClick={() => { onClose(); onAction(a.key) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 0', background: 'none', border: 'none',
|
||||
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.35 : 1, textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
|
||||
background: a.iconBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: a.color,
|
||||
}}>
|
||||
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 0', background: 'none', border: 'none',
|
||||
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.35 : 1, textAlign: 'left',
|
||||
}}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: a.iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', color: a.color }}>
|
||||
<a.Icon width="18" height="18" />
|
||||
</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
|
||||
@@ -187,27 +166,225 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Emergency payment modal ──────────────────────────────────────────────────
|
||||
|
||||
function EmergencyPayModal({ table, order, onClose, onPay }) {
|
||||
const [paying, setPaying] = useState(false)
|
||||
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
||||
const total = activeItems.reduce((s, i) => s + (i.unit_price || 0) * (i.quantity || 1), 0)
|
||||
|
||||
async function handlePay() {
|
||||
setPaying(true)
|
||||
await onPay(order.id, activeItems.map(i => i.id), 'cash')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<div className="modal-handle" />
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🚨</div>
|
||||
<p style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>ΕΚΤΑΚΤΗ ΠΛΗΡΩΜΗ</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginTop: 4 }}>Τραπέζι: <strong>{table.label || `T${table.number}`}</strong></p>
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg3)', borderRadius: 12, padding: '12px 16px', marginBottom: 20 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 8 }}>Ενεργά αντικείμενα:</p>
|
||||
{activeItems.length === 0
|
||||
? <p style={{ fontSize: 13, color: 'var(--muted)', fontStyle: 'italic' }}>Δεν υπάρχουν δεδομένα (offline snapshot)</p>
|
||||
: activeItems.map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, marginBottom: 4 }}>
|
||||
<span style={{ color: 'var(--text)' }}>{item.product?.name || `#${item.product_id}`} ×{item.quantity}</span>
|
||||
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{((item.unit_price || 0) * (item.quantity || 1)).toFixed(2)} €</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div style={{ borderTop: '1px solid var(--border)', marginTop: 10, paddingTop: 10, display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 16 }}>
|
||||
<span>Σύνολο</span>
|
||||
<span style={{ color: '#ef4444' }}>{total.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
{total === 0
|
||||
? <p style={{ fontSize: 13, color: '#ef4444', marginBottom: 16, lineHeight: 1.5, fontWeight: 600 }}>
|
||||
Δεν είναι δυνατή η πληρωμή χωρίς offline δεδομένα. Άνοιξε το τραπέζι ενώ ο server ήταν online.
|
||||
</p>
|
||||
: <p style={{ fontSize: 12, color: '#f59e0b', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
⚠️ Μόνο μετρητά σε κατάσταση έκτακτης ανάγκης. Η πληρωμή συγχρονίζεται μόλις αποκατασταθεί η σύνδεση.
|
||||
</p>
|
||||
}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Ακύρωση</button>
|
||||
<button
|
||||
style={{ flex: 1, height: 44, borderRadius: 12, border: 'none', background: total === 0 ? '#64748b' : '#dc2626', color: '#fff', fontSize: 15, fontWeight: 700, cursor: (paying || total === 0) ? 'not-allowed' : 'pointer', opacity: (paying || total === 0) ? 0.5 : 1 }}
|
||||
onClick={handlePay} disabled={paying || total === 0}
|
||||
>
|
||||
{paying ? '⟳ Καταχώρηση…' : '✓ Πληρωμή'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Filters modal ────────────────────────────────────────────────────────────
|
||||
|
||||
function FiltersModal({ groups, onClose }) {
|
||||
const {
|
||||
ownerFilter, statusFilter, zoneFilter,
|
||||
setOwnerFilter, setStatusFilter, setZoneFilter,
|
||||
clearFilters, setActiveZoneTab,
|
||||
} = useTableViewStore()
|
||||
|
||||
function toggleZone(id) {
|
||||
const next = zoneFilter.includes(id)
|
||||
? zoneFilter.filter(z => z !== id)
|
||||
: [...zoneFilter, id]
|
||||
setZoneFilter(next)
|
||||
// if we remove a zone that is the active tab, reset to 'all'
|
||||
if (!next.length) setActiveZoneTab('all')
|
||||
}
|
||||
|
||||
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} style={{ alignItems: 'flex-end' }}>
|
||||
<div
|
||||
className="modal-sheet"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ borderRadius: '20px 20px 0 0', paddingBottom: 40, gap: 20 }}
|
||||
>
|
||||
<div className="modal-handle" />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 17, fontWeight: 700, color: 'var(--text)' }}>Φίλτρα</span>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => { clearFilters(); onClose() }}
|
||||
style={{ fontSize: 13, fontWeight: 600, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px' }}
|
||||
>
|
||||
Καθαρισμός
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Owner: ALL | MINE */}
|
||||
<div>
|
||||
<p style={sectionLabel}>Ανάθεση</p>
|
||||
<div style={segmentedWrap}>
|
||||
{[['all', 'Όλα'], ['mine', 'Δικά μου']].map(([key, lbl]) => (
|
||||
<button key={key} onClick={() => setOwnerFilter(key)} style={segBtn(ownerFilter === key)}>{lbl}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status: ALL | FREE | OPEN | PAID */}
|
||||
<div>
|
||||
<p style={sectionLabel}>Κατάσταση</p>
|
||||
<div style={{ ...segmentedWrap, display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
|
||||
{[['all', 'Όλα'], ['free', 'Ελεύθερα'], ['open', 'Ανοιχτά'], ['paid', 'Πληρωμένα']].map(([key, lbl]) => (
|
||||
<button key={key} onClick={() => setStatusFilter(key)} style={{ ...segBtn(statusFilter === key), borderRadius: 10 }}>{lbl}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zones: multi-select, one segmented container per zone */}
|
||||
{groups.length > 0 && (
|
||||
<div>
|
||||
<p style={sectionLabel}>Ζώνες {zoneFilter.length > 0 ? `(${zoneFilter.length} επιλεγμένες)` : ''}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||||
{groups.map(g => {
|
||||
const active = zoneFilter.includes(g.id)
|
||||
return (
|
||||
<div key={g.id} style={segmentedWrap}>
|
||||
<button
|
||||
onClick={() => toggleZone(g.id)}
|
||||
style={{
|
||||
...segBtn(active),
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
|
||||
}}
|
||||
>
|
||||
{g.color && (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: active ? 'currentColor' : g.color,
|
||||
flexShrink: 0, opacity: active ? 0.9 : 1,
|
||||
}} />
|
||||
)}
|
||||
{g.name}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Εντάξει</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionLabel = { fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 8 }
|
||||
const segmentedWrap = { display: 'flex', gap: 6, background: 'var(--bg3)', borderRadius: 12, padding: 4 }
|
||||
function segBtn(active) {
|
||||
return {
|
||||
flex: 1, padding: '9px 8px', borderRadius: 9, border: 'none',
|
||||
cursor: 'pointer', fontWeight: 600, fontSize: 14,
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
transition: 'background 0.12s',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TableListPage() {
|
||||
const { user } = useAuthStore()
|
||||
const { status: connStatus } = useConnectionStore()
|
||||
const isEmergency = connStatus === 'emergency'
|
||||
|
||||
const [tables, setTables] = useState([])
|
||||
const [groups, setGroups] = useState([])
|
||||
const [orders, setOrders] = useState([])
|
||||
const [flagDefs, setFlagDefs] = useState([])
|
||||
const [flagAssignments, setFlagAssignments] = useState([])
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [waiters, setWaiters] = useState([]) // waiter objects for avatar lookup
|
||||
const [offline, setOffline] = useState(false)
|
||||
const [zoneOpen, setZoneOpen] = useState(false)
|
||||
const [selectedZones, setSelectedZones] = useState(new Set())
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
const [quickModal, setQuickModal] = useState(null) // { table, order, flags }
|
||||
const zoneRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [quickModal, setQuickModal] = useState(null)
|
||||
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
|
||||
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
|
||||
|
||||
const { unreadCount, recentMessages, ackMessage, fetchRecent } = useNotifications() || {}
|
||||
// pull-to-refresh state
|
||||
const [pulling, setPulling] = useState(false)
|
||||
const [pullY, setPullY] = useState(0)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const pullStart = useRef(null)
|
||||
const scrollRef = useRef(null)
|
||||
const PULL_THRESHOLD = 72
|
||||
|
||||
const navigate = useNavigate()
|
||||
const filterBtnRef = useRef(null)
|
||||
|
||||
const { unreadCount, recentMessages, fetchRecent } = useNotifications() || {}
|
||||
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
|
||||
|
||||
const {
|
||||
density, ownerFilter, statusFilter, zoneFilter, activeZoneTab, setActiveZoneTab,
|
||||
} = useTableViewStore()
|
||||
|
||||
// ── Load from IndexedDB when offline ──────────────────────────────────────
|
||||
const loadFromDB = useCallback(async () => {
|
||||
const [dbTables, dbOrders] = await Promise.all([db.tables.toArray(), db.orders.toArray()])
|
||||
setTables(dbTables.filter(t => t.is_active !== false))
|
||||
setOrders(dbOrders)
|
||||
setOffline(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (isEmergency) loadFromDB() }, [isEmergency])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setOffline(true)
|
||||
window.addEventListener('backend-offline', handler)
|
||||
@@ -215,28 +392,37 @@ export default function TableListPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onClick(e) {
|
||||
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
const handler = () => load()
|
||||
window.addEventListener('sse-reconnected', handler)
|
||||
return () => window.removeEventListener('sse-reconnected', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (connStatus === 'online') setOffline(false) }, [connStatus])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes] = await Promise.all([
|
||||
const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes, waitersRes] = await Promise.all([
|
||||
client.get('/api/tables/'),
|
||||
client.get('/api/orders/active'),
|
||||
client.get('/api/tables/groups'),
|
||||
client.get('/api/flags/defs'),
|
||||
client.get('/api/flags/assignments'),
|
||||
client.get('/api/settings/'),
|
||||
client.get('/api/waiters/on-shift'),
|
||||
])
|
||||
setTables(tablesRes.data)
|
||||
setOrders(ordersRes.data)
|
||||
const fullOrders = await Promise.all(
|
||||
ordersRes.data.map(o =>
|
||||
client.get(`/api/orders/${o.id}`)
|
||||
.then(r => ({ ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? o.waiter_ids ?? [] }))
|
||||
.catch(() => o)
|
||||
)
|
||||
)
|
||||
setOrders(fullOrders)
|
||||
setGroups(groupsRes.data)
|
||||
setFlagDefs(flagDefsRes.data)
|
||||
setFlagAssignments(flagAssignRes.data)
|
||||
setWaiters(waitersRes.data)
|
||||
const raw = settingsRes.data?.['ui.table_colours']?.value
|
||||
if (raw) loadFromBackend(raw)
|
||||
setOffline(false)
|
||||
@@ -245,6 +431,48 @@ export default function TableListPage() {
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
// ── SSE live updates ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (isEmergency) return
|
||||
function onSSE(e) {
|
||||
const { type, data } = e.detail
|
||||
if (type === 'order_updated' || type === 'order_paid') {
|
||||
client.get(`/api/orders/${data.order_id}`)
|
||||
.then(r => {
|
||||
const full = { ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? [] }
|
||||
setOrders(prev => {
|
||||
const exists = prev.find(o => o.id === data.order_id)
|
||||
return exists ? prev.map(o => o.id === data.order_id ? full : o) : [...prev, full]
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
setOrders(prev => {
|
||||
const existing = prev.find(o => o.id === data.order_id)
|
||||
if (existing) return prev.map(o => o.id === data.order_id ? { ...o, status: data.status, table_id: data.table_id } : o)
|
||||
return [...prev, { id: data.order_id, table_id: data.table_id, status: data.status, waiter_ids: [] }]
|
||||
})
|
||||
})
|
||||
} else if (type === 'order_closed') {
|
||||
setOrders(prev => prev.filter(o => o.id !== data.order_id))
|
||||
} else if (type === 'table_flags_changed') {
|
||||
client.get('/api/flags/assignments').then(r => setFlagAssignments(r.data)).catch(() => {})
|
||||
} else if (type === 'table_list_changed') {
|
||||
client.get('/api/tables/').then(r => setTables(r.data)).catch(() => {})
|
||||
}
|
||||
}
|
||||
window.addEventListener('sse-event', onSSE)
|
||||
return () => window.removeEventListener('sse-event', onSSE)
|
||||
}, [isEmergency])
|
||||
|
||||
// ── Emergency payment ──────────────────────────────────────────────────────
|
||||
async function handleEmergencyPay(orderId, itemIds, paymentMethod) {
|
||||
await queueOfflinePayment({ orderId, itemIds, paymentMethod })
|
||||
setLocalPaidOrderIds(prev => new Set([...prev, orderId]))
|
||||
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: 'paid' } : o))
|
||||
await db.orders.where('id').equals(orderId).modify({ status: 'paid' })
|
||||
}
|
||||
|
||||
// ── Derived maps ───────────────────────────────────────────────────────────
|
||||
const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
|
||||
const tableFlagsMap = {}
|
||||
flagAssignments.forEach(a => {
|
||||
@@ -252,36 +480,88 @@ export default function TableListPage() {
|
||||
const def = flagDefMap[a.flag_id]
|
||||
if (def) tableFlagsMap[a.table_id].push(def)
|
||||
})
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w]))
|
||||
|
||||
function getOrder(tableId) {
|
||||
return orders.find(o => o.table_id === tableId)
|
||||
function getOrder(tableId) { return orders.find(o => o.table_id === tableId) }
|
||||
function isMyOrder(order) { return !!(order && user && order.waiter_ids?.includes(user.id)) }
|
||||
function getOrderWaiters(order) {
|
||||
if (!order) return []
|
||||
return (order.waiter_ids || []).map(id => waiterMap[id]).filter(Boolean)
|
||||
}
|
||||
|
||||
function isMyOrder(order) {
|
||||
if (!order || !user) return false
|
||||
return order.waiter_ids?.includes(user.id)
|
||||
}
|
||||
// ── Filtering logic ────────────────────────────────────────────────────────
|
||||
// Zones visible in top bar = those allowed by zoneFilter (or all if empty)
|
||||
const allowedZoneIds = zoneFilter.length > 0 ? new Set(zoneFilter) : null
|
||||
|
||||
function toggleZone(id) {
|
||||
setSelectedZones(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
// visibleGroups = groups shown in the top bar
|
||||
const visibleGroups = groups.filter(g => !allowedZoneIds || allowedZoneIds.has(g.id))
|
||||
|
||||
// Validate activeZoneTab against current allowedZoneIds
|
||||
// If the active tab is no longer visible, reset to 'all'
|
||||
const effectiveZoneTab = (
|
||||
activeZoneTab === 'all' ||
|
||||
visibleGroups.some(g => g.id === activeZoneTab)
|
||||
) ? activeZoneTab : 'all'
|
||||
|
||||
const filtered = tables.filter(t => {
|
||||
const order = getOrder(t.id)
|
||||
if (filter === 'free' && order) return false
|
||||
if (filter === 'mine' && !isMyOrder(order)) return false
|
||||
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
|
||||
|
||||
// Status filter
|
||||
if (statusFilter === 'free' && order) return false
|
||||
if (statusFilter === 'open' && (!order || order.status === 'paid' || order.status === 'partially_paid')) return false
|
||||
if (statusFilter === 'paid' && order?.status !== 'paid' && order?.status !== 'partially_paid') return false
|
||||
|
||||
// Owner filter
|
||||
if (ownerFilter === 'mine' && !isMyOrder(order)) return false
|
||||
|
||||
// Zone filter from modal (multi-select restricts which zones are allowed)
|
||||
if (allowedZoneIds && !allowedZoneIds.has(t.group_id ?? 'none')) return false
|
||||
|
||||
// Active zone tab (secondary, single-select within allowed)
|
||||
if (effectiveZoneTab !== 'all' && t.group_id !== effectiveZoneTab) return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const zoneActive = selectedZones.size > 0
|
||||
// ── Pull-to-refresh handlers ───────────────────────────────────────────────
|
||||
function onPullTouchStart(e) {
|
||||
if (scrollRef.current?.scrollTop > 0) return
|
||||
pullStart.current = e.touches[0].clientY
|
||||
}
|
||||
function onPullTouchMove(e) {
|
||||
if (pullStart.current === null) return
|
||||
const dy = e.touches[0].clientY - pullStart.current
|
||||
if (dy > 0 && scrollRef.current?.scrollTop <= 0) {
|
||||
e.preventDefault()
|
||||
setPulling(true)
|
||||
setPullY(Math.min(dy, PULL_THRESHOLD * 1.5))
|
||||
}
|
||||
}
|
||||
async function onPullTouchEnd() {
|
||||
if (!pulling) return
|
||||
if (pullY >= PULL_THRESHOLD) {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}
|
||||
setPulling(false)
|
||||
setPullY(0)
|
||||
pullStart.current = null
|
||||
}
|
||||
|
||||
// ── Grid columns per density ───────────────────────────────────────────────
|
||||
const gridCols = {
|
||||
'1x1': 'repeat(4, 1fr)',
|
||||
'2x1': 'repeat(2, 1fr)',
|
||||
'2x2': 'repeat(2, 1fr)',
|
||||
'4x1': '1fr',
|
||||
'4x2': '1fr',
|
||||
'4x3': '1fr',
|
||||
}[density] || 'repeat(2, 1fr)'
|
||||
|
||||
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
|
||||
|
||||
function handleQuickAction(tableId, actionKey) {
|
||||
// Navigate to table then trigger action via URL param so TableDetailPage can handle it
|
||||
navigate(`/tables/${tableId}?action=${actionKey}`)
|
||||
}
|
||||
|
||||
@@ -299,15 +579,14 @@ export default function TableListPage() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
||||
</svg>
|
||||
{(unreadCount || 0) > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#ef4444', color: 'white',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
@@ -319,109 +598,135 @@ export default function TableListPage() {
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
{offline && <ConnectionBanner />}
|
||||
{isEmergency ? <EmergencyBar /> : (offline && <ConnectionBanner />)}
|
||||
|
||||
<div className="filter-tabs">
|
||||
{FILTERS.map(f => (
|
||||
<button key={f} className={`filter-tab ${filter === f ? 'filter-tab--active' : ''}`} onClick={() => setFilter(f)}>
|
||||
{FILTER_LABELS[f]}
|
||||
</button>
|
||||
{/* ── Zone tab bar ─────────────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
overflowX: 'auto', scrollbarWidth: 'none',
|
||||
}}>
|
||||
{/* ALL tab */}
|
||||
<ZoneTab
|
||||
label="Όλα"
|
||||
active={effectiveZoneTab === 'all'}
|
||||
onClick={() => setActiveZoneTab('all')}
|
||||
/>
|
||||
|
||||
{/* Per-zone tabs */}
|
||||
{visibleGroups.map(g => (
|
||||
<ZoneTab
|
||||
key={g.id}
|
||||
label={g.name}
|
||||
color={g.color}
|
||||
active={effectiveZoneTab === g.id}
|
||||
onClick={() => setActiveZoneTab(effectiveZoneTab === g.id ? 'all' : g.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div ref={zoneRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
|
||||
onClick={() => setZoneOpen(o => !o)}
|
||||
>
|
||||
Ζώνη{zoneActive ? ` (${selectedZones.size})` : ''}
|
||||
</button>
|
||||
{zoneOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '110%', right: 0, zIndex: 100,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12,
|
||||
boxShadow: '0 4px 16px var(--shadow)', minWidth: 180, padding: 8,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSelectedZones(new Set())}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.size === 0 ? 'var(--primary-fg)' : 'var(--text)',
|
||||
background: selectedZones.size === 0 ? 'var(--primary)' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Όλες οι ζώνες
|
||||
</button>
|
||||
{groups.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
onClick={() => toggleZone(g.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.has(g.id) ? 'var(--primary-fg)' : 'var(--text)',
|
||||
background: selectedZones.has(g.id) ? 'var(--primary)' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{g.color && <span style={{ width: 12, height: 12, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
|
||||
{g.name}
|
||||
</button>
|
||||
))}
|
||||
{tables.some(t => !t.group_id) && (
|
||||
<button
|
||||
onClick={() => toggleZone('none')}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.has('none') ? 'var(--primary-fg)' : 'var(--text)',
|
||||
background: selectedZones.has('none') ? 'var(--primary)' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Χωρίς ζώνη
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
|
||||
<div className="table-grid">
|
||||
{/* ── Table grid ───────────────────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}
|
||||
onTouchStart={onPullTouchStart}
|
||||
onTouchMove={onPullTouchMove}
|
||||
onTouchEnd={onPullTouchEnd}
|
||||
>
|
||||
{/* Pull-to-refresh indicator */}
|
||||
{(pulling || refreshing) && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: Math.min(pullY, PULL_THRESHOLD),
|
||||
color: 'var(--muted)', fontSize: 13, fontWeight: 600,
|
||||
overflow: 'hidden', transition: pulling ? 'none' : 'height 0.2s',
|
||||
}}>
|
||||
{refreshing ? '⟳ Ανανέωση…' : pullY >= PULL_THRESHOLD ? '↑ Αφήστε για ανανέωση' : '↓ Τραβήξτε για ανανέωση'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: density === '1x1' ? 8 : 10,
|
||||
padding: '12px 12px 88px',
|
||||
alignContent: 'start',
|
||||
}}>
|
||||
{filtered.map(t => {
|
||||
const order = getOrder(t.id)
|
||||
const tableFlags = tableFlagsMap[t.id] || []
|
||||
const grp = groups.find(g => g.id === t.group_id)
|
||||
// Free tables go straight to the item picker; occupied tables go to detail
|
||||
const destination = order
|
||||
? `/tables/${t.id}`
|
||||
: `/tables/${t.id}/add?new=1`
|
||||
const alreadyPaidLocally = order && localPaidOrderIds.has(order.id)
|
||||
const orderWaiters = getOrderWaiters(order)
|
||||
|
||||
function handleClick() {
|
||||
if (isEmergency) {
|
||||
if (order && !alreadyPaidLocally && order.status !== 'paid' && order.status !== 'closed') {
|
||||
setEmergencyPayModal({ table: t, order })
|
||||
}
|
||||
return
|
||||
}
|
||||
const destination = order ? `/tables/${t.id}` : `/tables/${t.id}/add?new=1`
|
||||
navigate(destination)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCard
|
||||
key={t.id}
|
||||
table={t}
|
||||
order={order}
|
||||
order={alreadyPaidLocally ? { ...order, status: 'paid' } : order}
|
||||
isMine={isMyOrder(order)}
|
||||
flags={tableFlags}
|
||||
groupName={grp?.name || ''}
|
||||
onClick={() => navigate(destination)}
|
||||
onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })}
|
||||
waiterObjects={orderWaiters}
|
||||
density={density}
|
||||
onClick={handleClick}
|
||||
onLongPress={isEmergency ? undefined : () => setQuickModal({ table: t, order, flags: tableFlags })}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className="fab" onClick={load} title="Ανανέωση">↺</button>
|
||||
</div>
|
||||
|
||||
{/* ── Filter FAB ───────────────────────────────────────────────────────── */}
|
||||
<button
|
||||
ref={filterBtnRef}
|
||||
onClick={() => setShowFilters(true)}
|
||||
style={{
|
||||
position: 'fixed', bottom: 24, right: 24,
|
||||
width: 52, height: 52, borderRadius: '50%', border: 'none',
|
||||
background: hasActiveFilters ? '#ea6c00' : '#f97316',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.2)',
|
||||
zIndex: 40,
|
||||
transition: 'background 0.12s',
|
||||
}}
|
||||
>
|
||||
<FilterIcon size={20} />
|
||||
{hasActiveFilters && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
background: '#ef4444', color: '#fff',
|
||||
fontSize: 9, fontWeight: 800,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{(ownerFilter !== 'all' ? 1 : 0) + (statusFilter !== 'all' ? 1 : 0) + (zoneFilter.length > 0 ? 1 : 0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* ── Modals ────────────────────────────────────────────────────────────── */}
|
||||
{showNotifs && (
|
||||
<NotificationDrawer
|
||||
messages={recentMessages || []}
|
||||
onClose={() => setShowNotifs(false)}
|
||||
onAck={ackMessage}
|
||||
/>
|
||||
<NotificationDrawer messages={recentMessages || []} onClose={() => setShowNotifs(false)} />
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<FiltersModal groups={groups} onClose={() => setShowFilters(false)} anchorRef={filterBtnRef} />
|
||||
)}
|
||||
|
||||
{quickModal && (
|
||||
@@ -434,6 +739,43 @@ export default function TableListPage() {
|
||||
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{emergencyPayModal && (
|
||||
<EmergencyPayModal
|
||||
table={emergencyPayModal.table}
|
||||
order={emergencyPayModal.order}
|
||||
onClose={() => setEmergencyPayModal(null)}
|
||||
onPay={handleEmergencyPay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Zone tab pill ────────────────────────────────────────────────────────────
|
||||
|
||||
function ZoneTab({ label, color, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 12px', borderRadius: 20, border: 'none',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||
fontWeight: 600, fontSize: 13,
|
||||
background: active ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
{color && (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: color, flexShrink: 0,
|
||||
opacity: active ? 1 : 0.7,
|
||||
}} />
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user