376 lines
13 KiB
JavaScript
376 lines
13 KiB
JavaScript
// Table cards at 5 densities. All share the same data model — each card type
|
||
// just renders a subset, sized for fast reading at-a-glance.
|
||
|
||
const { TABLE_STATUS, TABLE_BADGES } = window;
|
||
|
||
// ---------- shared bits ----------------------------------------------------
|
||
function fmtAmount(n) {
|
||
if (n == null || n === 0) return '0.00';
|
||
return n.toFixed(2);
|
||
}
|
||
// Splits "12.34" into ["12", ".34"] so we can typeset cents smaller
|
||
function splitAmount(n) {
|
||
const s = fmtAmount(n);
|
||
const [whole, cents] = s.split('.');
|
||
return [whole, '.' + cents];
|
||
}
|
||
|
||
function avatarHash(name) {
|
||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a'];
|
||
let h = 0;
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||
return palette[h % palette.length];
|
||
}
|
||
|
||
function WaiterDot({ name, size = 22, ring }) {
|
||
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
|
||
return (
|
||
<div style={{
|
||
width: size, height: size, borderRadius: '50%',
|
||
background: avatarHash(name),
|
||
color: 'white', fontSize: size * 0.42, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
flexShrink: 0,
|
||
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
|
||
}}>{initials}</div>
|
||
);
|
||
}
|
||
|
||
function StackedAvatars({ waiters, size = 22, ring }) {
|
||
if (!waiters?.length) return null;
|
||
if (waiters.length >= 3) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||
{waiters.slice(0, 2).map((w, i) => (
|
||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.35 }}>
|
||
<WaiterDot name={w} size={size} ring={ring} />
|
||
</div>
|
||
))}
|
||
<div style={{
|
||
marginLeft: -size * 0.35,
|
||
height: size, padding: '0 8px',
|
||
borderRadius: size,
|
||
background: ring || 'rgba(255,255,255,0.9)',
|
||
color: '#1a1a1f', fontSize: 11, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
|
||
}}>+{waiters.length - 2}</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div style={{ display: 'flex' }}>
|
||
{waiters.map((w, i) => (
|
||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.3 }}>
|
||
<WaiterDot name={w} size={size} ring={ring} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusPill({ status, size = 'md' }) {
|
||
const s = TABLE_STATUS[status];
|
||
const sizes = {
|
||
sm: { h: 18, px: 7, fs: 10 },
|
||
md: { h: 22, px: 9, fs: 11 },
|
||
lg: { h: 26, px: 11, fs: 12 },
|
||
};
|
||
const z = sizes[size];
|
||
return (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', height: z.h, padding: `0 ${z.px}px`,
|
||
borderRadius: 4,
|
||
background: s.pillBg, color: s.pillFg,
|
||
fontSize: z.fs, fontWeight: 800,
|
||
letterSpacing: 0.5, textTransform: 'uppercase',
|
||
whiteSpace: 'nowrap',
|
||
}}>{s.label}</span>
|
||
);
|
||
}
|
||
|
||
function BadgeChip({ kind, size = 'md' }) {
|
||
const b = TABLE_BADGES[kind];
|
||
if (!b) return null;
|
||
const sizes = {
|
||
sm: { h: 20, fs: 11, ic: 12 },
|
||
md: { h: 24, fs: 12, ic: 14 },
|
||
lg: { h: 28, fs: 13, ic: 16 },
|
||
};
|
||
const z = sizes[size];
|
||
return (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
height: z.h, padding: '0 8px',
|
||
borderRadius: z.h / 2,
|
||
background: 'rgba(255,255,255,0.95)',
|
||
color: b.tone,
|
||
fontSize: z.fs, fontWeight: 700,
|
||
}}>
|
||
<span style={{ fontSize: z.ic, lineHeight: 1 }}>{b.icon}</span>
|
||
{b.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function BadgeDot({ kind, size = 16 }) {
|
||
const b = TABLE_BADGES[kind];
|
||
if (!b) return null;
|
||
return (
|
||
<div title={b.label} style={{
|
||
width: size, height: size,
|
||
borderRadius: '50%',
|
||
background: 'rgba(255,255,255,0.95)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: size * 0.65,
|
||
lineHeight: 1,
|
||
}}>{b.icon}</div>
|
||
);
|
||
}
|
||
|
||
function Amount({ value, size = 22, color }) {
|
||
const [w, c] = splitAmount(value);
|
||
return (
|
||
<div style={{
|
||
fontFamily: "'Geist Mono', monospace",
|
||
fontWeight: 700,
|
||
lineHeight: 1,
|
||
color: color || 'inherit',
|
||
letterSpacing: -0.5,
|
||
}}>
|
||
<span style={{ fontSize: size }}>{w}</span>
|
||
<span style={{ fontSize: size * 0.55, opacity: 0.85 }}>{c}€</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- card shell -----------------------------------------------------
|
||
// All densities share this shell — just different content + dimensions.
|
||
function CardShell({ status, w, h, children, padding }) {
|
||
const s = TABLE_STATUS[status];
|
||
return (
|
||
<div style={{
|
||
width: w, height: h,
|
||
background: s.bg, color: s.fg,
|
||
borderRadius: 14,
|
||
padding: padding,
|
||
boxShadow: '0 1px 2px rgba(16,20,24,0.05)',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
display: 'flex', flexDirection: 'column',
|
||
cursor: 'pointer',
|
||
transition: 'transform 100ms ease',
|
||
}}>{children}</div>
|
||
);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 1×1 — tiniest. Just NAME. Status is purely the card color.
|
||
// ===========================================================================
|
||
function Card1x1({ table, w, h }) {
|
||
const t = table;
|
||
// Show one badge dot if present (very subtle, top-right)
|
||
const badge = t.badges[0];
|
||
return (
|
||
<CardShell status={t.status} w={w} h={h} padding={10}>
|
||
<div style={{
|
||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontFamily: "'Geist Mono', monospace",
|
||
fontWeight: 800, fontSize: 26,
|
||
letterSpacing: -1,
|
||
}}>{t.name}</div>
|
||
{badge && (
|
||
<div style={{ position: 'absolute', top: 6, right: 6 }}>
|
||
<BadgeDot kind={badge} size={14} />
|
||
</div>
|
||
)}
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 2×1 — wider. NAME + status PILL + maybe one badge dot.
|
||
// ===========================================================================
|
||
function Card2x1({ table, w, h }) {
|
||
const t = table;
|
||
return (
|
||
<CardShell status={t.status} w={w} h={h} padding={12}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: '100%', gap: 10 }}>
|
||
<div style={{
|
||
fontFamily: "'Geist Mono', monospace",
|
||
fontWeight: 800, fontSize: 26,
|
||
letterSpacing: -1, lineHeight: 1,
|
||
}}>{t.name}</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
|
||
<StatusPill status={t.status} size="sm" />
|
||
{t.badges.length > 0 && (
|
||
<div style={{ display: 'flex', gap: 3 }}>
|
||
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={14} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 2×2 — square. NAME big + status pill + amount + waiter dots + badges
|
||
// ===========================================================================
|
||
function Card2x2({ table, w, h }) {
|
||
const t = table;
|
||
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
|
||
return (
|
||
<CardShell status={t.status} w={w} h={h} padding={12}>
|
||
<div style={{ display: 'flex', height: '100%', gap: 8 }}>
|
||
{/* left column: name + pill (top), amount (bottom) */}
|
||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||
<div style={{
|
||
fontFamily: "'Geist Mono', monospace",
|
||
fontWeight: 800, fontSize: 30,
|
||
letterSpacing: -1, lineHeight: 1,
|
||
}}>{t.name}</div>
|
||
<div style={{ marginTop: 6 }}>
|
||
<StatusPill status={t.status} size="sm" />
|
||
</div>
|
||
<div style={{ marginTop: 'auto', minHeight: 24 }}>
|
||
{showAmount && <Amount value={t.amount} size={22} />}
|
||
</div>
|
||
</div>
|
||
{/* right column: badges stacked vertically, bottom-aligned */}
|
||
{t.badges.length > 0 && (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column-reverse',
|
||
gap: 4, alignItems: 'flex-end',
|
||
justifyContent: 'flex-start',
|
||
}}>
|
||
{t.badges.slice(0, 3).map(b => <BadgeDot key={b} kind={b} size={20} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 4×1 — wide horizontal. NAME · AMOUNT · status pill + waiter dots
|
||
// ===========================================================================
|
||
function Card4x1({ table, w, h }) {
|
||
const t = table;
|
||
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
|
||
return (
|
||
<CardShell status={t.status} w={w} h={h} padding={14}>
|
||
<div style={{ display: 'flex', alignItems: 'center', height: '100%', gap: 14 }}>
|
||
{/* name */}
|
||
<div style={{
|
||
fontFamily: "'Geist Mono', monospace",
|
||
fontWeight: 800, fontSize: 30,
|
||
letterSpacing: -1, lineHeight: 1,
|
||
minWidth: 70,
|
||
}}>{t.name}</div>
|
||
|
||
{/* amount (or spacer) */}
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
{showAmount && <Amount value={t.amount} size={22} />}
|
||
</div>
|
||
|
||
{/* badges */}
|
||
{t.badges.length > 0 && (
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={20} />)}
|
||
</div>
|
||
)}
|
||
|
||
{/* status pill */}
|
||
<StatusPill status={t.status} size="md" />
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 4×2 — full detail. Name + section + status pill + amount + badges + waiters with names
|
||
// ===========================================================================
|
||
function Card4x2({ table, w, h }) {
|
||
const t = table;
|
||
const s = TABLE_STATUS[t.status];
|
||
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
|
||
// First waiter name (or "Multiple")
|
||
const waiterCaption = t.waiters.length === 0
|
||
? 'Unassigned'
|
||
: t.waiters.length >= 3
|
||
? `${t.waiters.length} waiters`
|
||
: t.waiters.map(w => w.split(' ')[0]).join(', ');
|
||
|
||
return (
|
||
<CardShell status={t.status} w={w} h={h} padding={16}>
|
||
{/* top row: name + section + status pill | amount */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<div style={{
|
||
fontFamily: "'Geist Mono', monospace",
|
||
fontWeight: 800, fontSize: 38,
|
||
letterSpacing: -1.5, lineHeight: 1,
|
||
}}>{t.name}</div>
|
||
<div style={{
|
||
fontSize: 11, fontWeight: 700,
|
||
opacity: 0.7,
|
||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||
marginTop: 4,
|
||
}}>{t.section}</div>
|
||
<div style={{ marginTop: 8 }}>
|
||
<StatusPill status={t.status} size="lg" />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||
{showAmount && <Amount value={t.amount} size={38} />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* badges block — right-aligned, up to 4 in 2×2 grid, sits above waiter line */}
|
||
<div style={{
|
||
marginTop: 'auto',
|
||
display: 'flex', justifyContent: 'flex-end',
|
||
paddingBottom: 10,
|
||
minHeight: 24,
|
||
}}>
|
||
{t.badges.length > 0 && (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(2, max-content)',
|
||
gridAutoRows: 'min-content',
|
||
gap: 6,
|
||
justifyItems: 'end',
|
||
direction: 'rtl', // fill right column first, then wrap left
|
||
}}>
|
||
{t.badges.slice(0, 4).map(b => (
|
||
<div key={b} style={{ direction: 'ltr' }}>
|
||
<BadgeChip kind={b} size="sm" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* bottom: waiters with names */}
|
||
<div style={{
|
||
paddingTop: 10,
|
||
borderTop: '1px solid rgba(255,255,255,0.18)',
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
}}>
|
||
{t.waiters.length === 0 ? (
|
||
<span style={{ fontSize: 13, opacity: 0.7, fontWeight: 500 }}>Unassigned</span>
|
||
) : (
|
||
<>
|
||
<StackedAvatars waiters={t.waiters} size={26} ring={s.bg} />
|
||
<span style={{ fontSize: 14, fontWeight: 600 }}>{waiterCaption}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
window.TableCards = { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 };
|