Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
478
CLAUDE_DESIGN/table-card.jsx
Normal file
478
CLAUDE_DESIGN/table-card.jsx
Normal file
@@ -0,0 +1,478 @@
|
||||
// Table Card — 3 variations
|
||||
// All cards share the same fixed dimensions and field positions so the grid
|
||||
// stays visually aligned even when fields are empty.
|
||||
|
||||
const STATUS = {
|
||||
open: { label: 'Open', tint: 'var(--open-50)', tintStrong: 'var(--open-100)', accent: 'var(--open-500)', ink: 'var(--open-700)' },
|
||||
occupied:{ label: 'Occupied', tint: 'var(--occ-50)', tintStrong: 'var(--occ-100)', accent: 'var(--occ-500)', ink: 'var(--occ-700)' },
|
||||
reserved:{ label: 'Reserved', tint: 'var(--res-50)', tintStrong: 'var(--res-100)', accent: 'var(--res-500)', ink: 'var(--res-700)' },
|
||||
alert: { label: 'Needs attention', tint: 'var(--alert-50)', tintStrong: 'var(--alert-100)', accent: 'var(--alert-500)', ink: 'var(--alert-700)' },
|
||||
dirty: { label: 'Needs cleaning', tint: 'var(--dirty-50)', tintStrong: 'var(--dirty-100)', accent: 'var(--dirty-500)', ink: 'var(--dirty-700)' },
|
||||
};
|
||||
|
||||
// ----- Shared helpers -----
|
||||
|
||||
function formatEuro(n) {
|
||||
if (n == null) return null;
|
||||
return '€' + n.toFixed(2).replace(/\.00$/, '.00');
|
||||
}
|
||||
|
||||
function formatDuration(mins) {
|
||||
if (mins == null) return null;
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function avatarColor(name) {
|
||||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'];
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return palette[h % palette.length];
|
||||
}
|
||||
|
||||
function Initials({ name, size = 28 }) {
|
||||
const parts = name.split(' ');
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Flag({ kind }) {
|
||||
const map = {
|
||||
VIP: { bg: '#fff4d6', fg: '#8a6d0b', label: 'VIP' },
|
||||
Allergy: { bg: '#fde3dc', fg: '#a5361b', label: 'Allergy' },
|
||||
Birthday: { bg: '#fbe2ee', fg: '#a8276b', label: 'Birthday' },
|
||||
};
|
||||
const s = map[kind] || { bg: '#eceff2', fg: '#3a4049', label: kind };
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: 22, padding: '0 8px',
|
||||
borderRadius: 999,
|
||||
background: s.bg, color: s.fg,
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{s.label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder dashes so empty fields keep their footprint but visually disappear
|
||||
function EmptyDash({ width = 40 }) {
|
||||
return <span style={{ color: 'var(--ink-300)', letterSpacing: 2, userSelect: 'none' }}>— —</span>;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// VARIATION 1 — Left accent border + tinted background
|
||||
// Stacked: header row, stats row, waiter row. Clean and quiet.
|
||||
// ===========================================================================
|
||||
function TableCardV1({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
|
||||
const s = STATUS[status];
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const [pressed, setPressed] = React.useState(false);
|
||||
|
||||
// Waiter display rules
|
||||
const showMulti = waiters.length >= 3;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPressed(false); }}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
style={{
|
||||
'--cardBg': s.tint,
|
||||
position: 'relative',
|
||||
width: 260, height: 180,
|
||||
padding: '16px 18px 16px 22px',
|
||||
background: s.tint,
|
||||
border: '1px solid ' + s.tintStrong,
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
|
||||
transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{/* left accent bar */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: 'var(--radius) 0 0 var(--radius)',
|
||||
}} />
|
||||
|
||||
{/* Header row: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 32, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: 'var(--ink-900)',
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row — fixed height whether or not flags exist */}
|
||||
<div style={{ marginTop: 8, height: 22, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{flags.map(f => <Flag key={f} kind={f} />)}
|
||||
</div>
|
||||
|
||||
{/* Stats row: amount + time. Fixed layout, empty dashes when missing */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink-900)', marginTop: 2, fontFamily: "'Geist Mono', 'Geist', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 20, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: 'var(--ink-900)',
|
||||
}}>
|
||||
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<Initials name={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
|
||||
background: 'white', border: '1px solid var(--ink-200)',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Initials name={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// VARIATION 2 — Top stripe + large name on left, stats on right
|
||||
// More "at-a-glance" — big name dominates.
|
||||
// ===========================================================================
|
||||
function TableCardV2({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
|
||||
const s = STATUS[status];
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const [pressed, setPressed] = React.useState(false);
|
||||
const showMulti = waiters.length >= 3;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPressed(false); }}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
style={{
|
||||
'--cardBg': s.tint,
|
||||
position: 'relative',
|
||||
width: 260, height: 180,
|
||||
padding: 0,
|
||||
background: s.tint,
|
||||
border: '1px solid ' + s.tintStrong,
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
|
||||
transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
overflow: 'hidden',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{/* top stripe */}
|
||||
<div style={{
|
||||
height: 8,
|
||||
background: s.accent,
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
|
||||
<div style={{ padding: '12px 16px 14px', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Top row: name BIG + status label */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{
|
||||
fontSize: 38, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -1,
|
||||
color: 'var(--ink-900)',
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 700,
|
||||
color: s.ink,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
textAlign: 'right',
|
||||
lineHeight: 1.2,
|
||||
paddingTop: 4,
|
||||
}}>{s.label}</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row — fixed height */}
|
||||
<div style={{ marginTop: 6, height: 22, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{flags.map(f => <Flag key={f} kind={f} />)}
|
||||
</div>
|
||||
|
||||
{/* Stats line */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 600,
|
||||
color: 'var(--ink-900)',
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
}}>
|
||||
{amount != null ? formatEuro(amount) : <EmptyDash />}
|
||||
</div>
|
||||
<div style={{ width: 1, height: 16, background: s.tintStrong }} />
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: 'var(--ink-700)',
|
||||
}}>
|
||||
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 10,
|
||||
height: 32,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<Initials name={w} size={26} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
|
||||
background: 'white', border: '1px solid var(--ink-200)',
|
||||
borderRadius: 999, padding: '3px 10px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Initials name={w} size={26} />
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// VARIATION 3 — Bold name badge, stats right-aligned
|
||||
// Name lives in a colored badge in the top-left like a table plaque.
|
||||
// Good for tablet thumb reach — name is easy to tap to open.
|
||||
// ===========================================================================
|
||||
function TableCardV3({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
|
||||
const s = STATUS[status];
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const [pressed, setPressed] = React.useState(false);
|
||||
const showMulti = waiters.length >= 3;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPressed(false); }}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
style={{
|
||||
'--cardBg': s.tint,
|
||||
position: 'relative',
|
||||
width: 260, height: 180,
|
||||
padding: 14,
|
||||
background: s.tint,
|
||||
border: '1px solid ' + s.tintStrong,
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
|
||||
transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Top row — plaque + status + flags */}
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
width: 68, height: 56,
|
||||
borderRadius: 10,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 26, fontWeight: 700,
|
||||
letterSpacing: -0.5,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
flexShrink: 0,
|
||||
}}>{name}</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 700,
|
||||
color: s.ink,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
lineHeight: 1,
|
||||
}}>{s.label}</div>
|
||||
{/* Flags row — fixed height so layout stays stable */}
|
||||
<div style={{ height: 22, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap', overflow: 'hidden' }}>
|
||||
{flags.map(f => <Flag key={f} kind={f} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 6,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink-900)', marginTop: 2, fontFamily: "'Geist Mono', 'Geist', monospace", lineHeight: 1.1 }}>
|
||||
{amount != null ? formatEuro(amount) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: 'var(--ink-900)',
|
||||
lineHeight: 1.1,
|
||||
}}>
|
||||
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 10,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 38,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<Initials name={w} size={26} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
|
||||
background: 'white', border: '1px solid var(--ink-200)',
|
||||
borderRadius: 999, padding: '3px 10px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Initials name={w} size={26} />
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Export to window
|
||||
Object.assign(window, { TableCardV1, TableCardV2, TableCardV3, STATUS });
|
||||
Reference in New Issue
Block a user