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('')
|
const [pin, setPin] = useState('')
|
||||||
|
|
||||||
function press(digit) {
|
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() {
|
function backspace() {
|
||||||
@@ -15,7 +18,7 @@ export default function PinPad({ onSubmit, loading }) {
|
|||||||
if (pin.length > 0 && !loading) onSubmit(pin)
|
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>
|
<span key={i} style={{ fontSize: 20, color: i < pin.length ? '#f59e0b' : '#334155' }}>●</span>
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,58 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import ItemOptionsModal from './ItemOptionsModal'
|
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 }) {
|
export default function ProductPicker({ categories, products, onAdd }) {
|
||||||
const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
|
const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
|
||||||
const [selectedProduct, setSelectedProduct] = useState(null)
|
const [selectedProduct, setSelectedProduct] = useState(null)
|
||||||
|
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||||
|
|
||||||
const filtered = products.filter(p => p.category_id === activeCat)
|
const filtered = products.filter(p => p.category_id === activeCat)
|
||||||
|
|
||||||
|
function selectCategory(id) {
|
||||||
|
setActiveCat(id)
|
||||||
|
setViewAllOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="product-picker">
|
<div className="product-picker">
|
||||||
<div className="category-tabs">
|
<div className="category-tabs">
|
||||||
{categories.map(cat => (
|
{/* 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
|
<button
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
className={`cat-tab ${activeCat === cat.id ? 'cat-tab--active' : ''}`}
|
className="cat-tab"
|
||||||
|
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
|
||||||
onClick={() => setActiveCat(cat.id)}
|
onClick={() => setActiveCat(cat.id)}
|
||||||
>
|
>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="product-grid">
|
<div className="product-grid">
|
||||||
@@ -35,6 +69,39 @@ export default function ProductPicker({ categories, products, onAdd }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{selectedProduct && (
|
||||||
<ItemOptionsModal
|
<ItemOptionsModal
|
||||||
product={selectedProduct}
|
product={selectedProduct}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ export default function TableCard({ table, order, currentUserId, onClick }) {
|
|||||||
cardClass = 'table-card table-card--active'
|
cardClass = 'table-card table-card--active'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = table.label || `T${table.number}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={cardClass} onClick={onClick}>
|
<button className={cardClass} onClick={onClick}>
|
||||||
<span className="table-card__number">{table.number}</span>
|
<span className="table-card__number">{displayName}</span>
|
||||||
{table.name && <span className="table-card__name">{table.name}</span>}
|
|
||||||
<span className="table-card__status">{statusLabel}</span>
|
<span className="table-card__status">{statusLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ body { background: var(--bg); }
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
flex: 1;
|
align-content: start;
|
||||||
}
|
}
|
||||||
.table-card {
|
.table-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -177,7 +177,8 @@ body { background: var(--bg); }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 110px;
|
min-height: 132px;
|
||||||
|
max-height: 132px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -239,15 +240,82 @@ body { background: var(--bg); }
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: none;
|
border: 2px solid transparent;
|
||||||
background: var(--bg3);
|
background: var(--bg3);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: filter 0.12s;
|
||||||
}
|
}
|
||||||
.cat-tab--active { background: var(--accent); color: #1c1400; }
|
.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 Grid ────────────────────────────────────────── */
|
||||||
.product-picker { display: flex; flex-direction: column; flex: 1; }
|
.product-picker { display: flex; flex-direction: column; flex: 1; }
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default function AddItemsPage() {
|
|||||||
const [orderId, setOrderId] = useState(null)
|
const [orderId, setOrderId] = useState(null)
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
// null = not yet sent, { allOk, results } = sent
|
||||||
|
const [printAck, setPrintAck] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -40,11 +42,19 @@ export default function AddItemsPage() {
|
|||||||
if (cart.length === 0 || !orderId) return
|
if (cart.length === 0 || !orderId) return
|
||||||
setSending(true)
|
setSending(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setPrintAck(null)
|
||||||
try {
|
try {
|
||||||
await client.post(`/api/orders/${orderId}/items`, {
|
const res = await client.post(`/api/orders/${orderId}/items`, {
|
||||||
items: cart.map(({ _key, ...item }) => item),
|
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) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
|
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,6 +66,76 @@ export default function AddItemsPage() {
|
|||||||
return products.find(p => p.id === id)?.name || `#${id}`
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="top-bar">
|
<header className="top-bar">
|
||||||
@@ -91,6 +171,17 @@ export default function AddItemsPage() {
|
|||||||
|
|
||||||
{error && <p className="error-msg">{error}</p>}
|
{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
|
<button
|
||||||
className="btn btn--primary btn--lg"
|
className="btn btn--primary btn--lg"
|
||||||
style={{ width: '100%', marginTop: 16 }}
|
style={{ width: '100%', marginTop: 16 }}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function TableListPage() {
|
|||||||
onClick={() => setSelectedZones(new Set())}
|
onClick={() => setSelectedZones(new Set())}
|
||||||
style={{
|
style={{
|
||||||
display: 'block', width: '100%', textAlign: 'left',
|
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',
|
color: selectedZones.size === 0 ? '#fff' : '#374151',
|
||||||
background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
|
background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
|
||||||
border: 'none', cursor: 'pointer',
|
border: 'none', cursor: 'pointer',
|
||||||
@@ -126,14 +126,14 @@ export default function TableListPage() {
|
|||||||
key={g.id}
|
key={g.id}
|
||||||
onClick={() => toggleZone(g.id)}
|
onClick={() => toggleZone(g.id)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||||
textAlign: 'left', padding: '8px 12px', borderRadius: 8, fontSize: 14,
|
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||||
color: selectedZones.has(g.id) ? '#fff' : '#374151',
|
color: selectedZones.has(g.id) ? '#fff' : '#374151',
|
||||||
background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
|
background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
|
||||||
border: 'none', cursor: 'pointer',
|
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}
|
{g.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -142,7 +142,7 @@ export default function TableListPage() {
|
|||||||
onClick={() => toggleZone('none')}
|
onClick={() => toggleZone('none')}
|
||||||
style={{
|
style={{
|
||||||
display: 'block', width: '100%', textAlign: 'left',
|
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',
|
color: selectedZones.has('none') ? '#fff' : '#374151',
|
||||||
background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
|
background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
|
||||||
border: 'none', cursor: 'pointer',
|
border: 'none', cursor: 'pointer',
|
||||||
|
|||||||
Reference in New Issue
Block a user