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:
2026-04-24 17:35:22 +03:00
parent 26c4818aa1
commit ee51e52acf
6 changed files with 253 additions and 23 deletions

View File

@@ -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>
))

View File

@@ -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}

View File

@@ -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>
)

View File

@@ -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; }

View File

@@ -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 }}

View File

@@ -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',