Waiter PWA: UX polish — table names, category colours, print ack, PIN fix
- TableCard: show table.label (display name) instead of internal number - TableListPage: zone filter rows 50% taller; table cards capped at 132px max-height so single-table zones don't stretch; grid aligns to top - ProductPicker: category tabs use their configured colour (inactive=35% opacity); new View All button opens a full-screen category tile modal - AddItemsPage: show per-printer print acknowledgement after sending order; print failures keep items as drafts and show a clear error screen - PinPad: reduced to 4 dots/digits with auto-submit on 4th digit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,10 @@ export default function PinPad({ onSubmit, loading }) {
|
||||
const [pin, setPin] = useState('')
|
||||
|
||||
function press(digit) {
|
||||
if (pin.length < 8) setPin(p => p + digit)
|
||||
if (pin.length >= 4) return
|
||||
const next = pin + digit
|
||||
setPin(next)
|
||||
if (next.length === 4 && !loading) onSubmit(next)
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
@@ -15,7 +18,7 @@ export default function PinPad({ onSubmit, loading }) {
|
||||
if (pin.length > 0 && !loading) onSubmit(pin)
|
||||
}
|
||||
|
||||
const dots = Array.from({ length: 8 }, (_, i) => (
|
||||
const dots = Array.from({ length: 4 }, (_, i) => (
|
||||
<span key={i} style={{ fontSize: 20, color: i < pin.length ? '#f59e0b' : '#334155' }}>●</span>
|
||||
))
|
||||
|
||||
|
||||
@@ -1,24 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import ItemOptionsModal from './ItemOptionsModal'
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
if (!hex) return null
|
||||
const h = hex.replace('#', '')
|
||||
const r = parseInt(h.substring(0, 2), 16)
|
||||
const g = parseInt(h.substring(2, 4), 16)
|
||||
const b = parseInt(h.substring(4, 6), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
export default function ProductPicker({ categories, products, onAdd }) {
|
||||
const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
|
||||
const [selectedProduct, setSelectedProduct] = useState(null)
|
||||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||
|
||||
const filtered = products.filter(p => p.category_id === activeCat)
|
||||
|
||||
function selectCategory(id) {
|
||||
setActiveCat(id)
|
||||
setViewAllOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="product-picker">
|
||||
<div className="category-tabs">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-tab ${activeCat === cat.id ? 'cat-tab--active' : ''}`}
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
{/* View All button — always first */}
|
||||
<button
|
||||
className="cat-tab cat-tab--viewall"
|
||||
onClick={() => setViewAllOpen(true)}
|
||||
title="Εμφάνιση όλων"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
|
||||
{categories.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color
|
||||
? isActive ? cat.color : hexToRgba(cat.color, 0.35)
|
||||
: isActive ? 'var(--accent)' : 'var(--bg3)'
|
||||
const color = cat.color
|
||||
? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
|
||||
: isActive ? '#1c1400' : 'var(--muted)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="cat-tab"
|
||||
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="product-grid">
|
||||
@@ -35,6 +69,39 @@ export default function ProductPicker({ categories, products, onAdd }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All modal */}
|
||||
{viewAllOpen && (
|
||||
<div className="modal-overlay" onClick={() => setViewAllOpen(false)}>
|
||||
<div
|
||||
className="cat-all-modal"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="cat-all-modal__header">
|
||||
<span className="cat-all-modal__title">Κατηγορίες</span>
|
||||
<button className="icon-btn" onClick={() => setViewAllOpen(false)}>✕</button>
|
||||
</div>
|
||||
<div className="cat-all-grid">
|
||||
{categories.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color || 'var(--bg3)'
|
||||
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-all-tile ${isActive ? 'cat-all-tile--active' : ''}`}
|
||||
style={{ background: bg, boxShadow: isActive ? `0 0 0 3px #fff` : undefined }}
|
||||
onClick={() => selectCategory(cat.id)}
|
||||
>
|
||||
<span className="cat-all-tile__overlay" style={{ background: overlay }} />
|
||||
<span className="cat-all-tile__name">{cat.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProduct && (
|
||||
<ItemOptionsModal
|
||||
product={selectedProduct}
|
||||
|
||||
@@ -13,10 +13,11 @@ export default function TableCard({ table, order, currentUserId, onClick }) {
|
||||
cardClass = 'table-card table-card--active'
|
||||
}
|
||||
|
||||
const displayName = table.label || `T${table.number}`
|
||||
|
||||
return (
|
||||
<button className={cardClass} onClick={onClick}>
|
||||
<span className="table-card__number">{table.number}</span>
|
||||
{table.name && <span className="table-card__name">{table.name}</span>}
|
||||
<span className="table-card__number">{displayName}</span>
|
||||
<span className="table-card__status">{statusLabel}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -169,7 +169,7 @@ body { background: var(--bg); }
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
}
|
||||
.table-card {
|
||||
display: flex;
|
||||
@@ -177,7 +177,8 @@ body { background: var(--bg); }
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
min-height: 132px;
|
||||
max-height: 132px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
@@ -239,15 +240,82 @@ body { background: var(--bg); }
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
border: 2px solid transparent;
|
||||
background: var(--bg3);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.12s;
|
||||
}
|
||||
.cat-tab--active { background: var(--accent); color: #1c1400; }
|
||||
.cat-tab--viewall {
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ── Category All Modal ──────────────────────────────────── */
|
||||
.cat-all-modal {
|
||||
position: fixed;
|
||||
inset: 20px;
|
||||
background: var(--bg2);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cat-all-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cat-all-modal__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.cat-all-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.cat-all-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
.cat-all-tile--active { outline: 3px solid #fff; }
|
||||
.cat-all-tile__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cat-all-tile__name {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ── Product Grid ────────────────────────────────────────── */
|
||||
.product-picker { display: flex; flex-direction: column; flex: 1; }
|
||||
|
||||
@@ -13,6 +13,8 @@ export default function AddItemsPage() {
|
||||
const [orderId, setOrderId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
// null = not yet sent, { allOk, results } = sent
|
||||
const [printAck, setPrintAck] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -40,11 +42,19 @@ export default function AddItemsPage() {
|
||||
if (cart.length === 0 || !orderId) return
|
||||
setSending(true)
|
||||
setError('')
|
||||
setPrintAck(null)
|
||||
try {
|
||||
await client.post(`/api/orders/${orderId}/items`, {
|
||||
const res = await client.post(`/api/orders/${orderId}/items`, {
|
||||
items: cart.map(({ _key, ...item }) => item),
|
||||
})
|
||||
navigate(`/tables/${tableId}`)
|
||||
const printResults = res.data.print_results ?? []
|
||||
const allOk = printResults.length === 0 || printResults.every(r => r.success)
|
||||
setPrintAck({ allOk, results: printResults })
|
||||
if (allOk) {
|
||||
// All printed fine — navigate back after a short moment
|
||||
setTimeout(() => navigate(`/tables/${tableId}`), 1200)
|
||||
}
|
||||
// If there were print failures, stay on page — waiter sees the ack panel
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
|
||||
} finally {
|
||||
@@ -56,6 +66,76 @@ export default function AddItemsPage() {
|
||||
return products.find(p => p.id === id)?.name || `#${id}`
|
||||
}
|
||||
|
||||
// If we have a print ack with failures, show the ack overlay instead of the normal UI
|
||||
if (printAck && !printAck.allOk) {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`)}>←</button>
|
||||
<span className="top-bar__title">Αποτέλεσμα εκτύπωσης</span>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: 20 }}>
|
||||
<div style={{
|
||||
background: '#7f1d1d', borderRadius: 14, padding: '16px 18px',
|
||||
border: '1px solid #ef4444',
|
||||
}}>
|
||||
<p style={{ fontWeight: 700, fontSize: 16, color: '#fca5a5', marginBottom: 8 }}>
|
||||
⚠ Πρόβλημα εκτύπωσης
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: '#fca5a5' }}>
|
||||
Η παραγγελία αποθηκεύτηκε αλλά ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.
|
||||
Τα αντικείμενα παραμένουν ως "σχέδιο" — δεν έχουν σταλεί στην κουζίνα/μπαρ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{printAck.results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
background: r.success ? '#14532d' : '#7f1d1d',
|
||||
border: `1px solid ${r.success ? '#22c55e' : '#ef4444'}`,
|
||||
borderRadius: 12, padding: '12px 16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22 }}>{r.success ? '✓' : '✗'}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontWeight: 600, fontSize: 15, color: r.success ? '#86efac' : '#fca5a5' }}>
|
||||
{r.printer_name}
|
||||
</p>
|
||||
{r.error && (
|
||||
<p style={{ fontSize: 12, color: '#fca5a5', marginTop: 2 }}>{r.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn--secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => navigate(`/tables/${tableId}`)}
|
||||
>
|
||||
Επιστροφή στο τραπέζι
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={async () => {
|
||||
setPrintAck(null)
|
||||
setCart([])
|
||||
navigate(`/tables/${tableId}`)
|
||||
}}
|
||||
>
|
||||
Εντάξει, συνέχεια
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
@@ -91,6 +171,17 @@ export default function AddItemsPage() {
|
||||
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
|
||||
{/* Success flash when all printers OK */}
|
||||
{printAck?.allOk && (
|
||||
<div style={{
|
||||
background: '#14532d', border: '1px solid #22c55e',
|
||||
borderRadius: 10, padding: '10px 14px',
|
||||
color: '#86efac', fontWeight: 600, fontSize: 14, textAlign: 'center',
|
||||
}}>
|
||||
✓ Εκτυπώθηκε επιτυχώς — μεταφορά…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function TableListPage() {
|
||||
onClick={() => setSelectedZones(new Set())}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '8px 12px', borderRadius: 8, fontSize: 14,
|
||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.size === 0 ? '#fff' : '#374151',
|
||||
background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
@@ -126,14 +126,14 @@ export default function TableListPage() {
|
||||
key={g.id}
|
||||
onClick={() => toggleZone(g.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
textAlign: 'left', padding: '8px 12px', borderRadius: 8, fontSize: 14,
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.has(g.id) ? '#fff' : '#374151',
|
||||
background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{g.color && <span style={{ width: 10, height: 10, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
|
||||
{g.color && <span style={{ width: 12, height: 12, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
|
||||
{g.name}
|
||||
</button>
|
||||
))}
|
||||
@@ -142,7 +142,7 @@ export default function TableListPage() {
|
||||
onClick={() => toggleZone('none')}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '8px 12px', borderRadius: 8, fontSize: 14,
|
||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.has('none') ? '#fff' : '#374151',
|
||||
background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
|
||||
Reference in New Issue
Block a user