679 lines
25 KiB
JavaScript
679 lines
25 KiB
JavaScript
import { useRef, useState } from 'react'
|
||
import useThemeStore from '../store/themeStore'
|
||
import useTableColourStore from '../store/tableColourStore'
|
||
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const STATUS_LABELS = {
|
||
free: 'ΕΛΕΥΘΕΡΟ',
|
||
open: 'ΑΝΟΙΧΤΟ',
|
||
mine: 'ΔΙΚΟ ΜΟΥ',
|
||
paid: 'ΠΛΗΡΩΜΕΝΟ',
|
||
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
|
||
}
|
||
|
||
const DRAG_THRESHOLD = 8
|
||
const HOLD_MS = 480
|
||
|
||
// ─── Avatar helpers ───────────────────────────────────────────────────────────
|
||
|
||
const AVATAR_PALETTE = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a']
|
||
|
||
function avatarColor(name = '') {
|
||
let h = 0
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||
return AVATAR_PALETTE[h % AVATAR_PALETTE.length]
|
||
}
|
||
|
||
function WaiterAvatar({ waiter, size = 22, ring }) {
|
||
const displayName = waiter.nickname || waiter.full_name || waiter.username || '?'
|
||
const initials = displayName.trim().split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
|
||
const ringStyle = ring ? { boxShadow: `0 0 0 2px ${ring}` } : {}
|
||
|
||
if (waiter.avatar_url) {
|
||
return (
|
||
<img
|
||
src={API_URL + waiter.avatar_url}
|
||
alt={displayName}
|
||
style={{
|
||
width: size, height: size, borderRadius: '50%',
|
||
objectFit: 'cover', flexShrink: 0,
|
||
...ringStyle,
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
return (
|
||
<div style={{
|
||
width: size, height: size, borderRadius: '50%',
|
||
background: avatarColor(displayName),
|
||
color: 'white', fontSize: size * 0.4, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
flexShrink: 0,
|
||
...ringStyle,
|
||
}}>{initials}</div>
|
||
)
|
||
}
|
||
|
||
// Renders [icon] Name, [icon] Name inline. Falls back to icons + "X Waiters" if they don't fit
|
||
// (we approximate "don't fit" as > 2 waiters for the compact footer height).
|
||
function WaiterRow({ waiters, size = 22, cfg }) {
|
||
if (!waiters?.length) return null
|
||
const textColor = cfg.nameText
|
||
|
||
// ≤ 2 waiters: show icon + name pairs
|
||
if (waiters.length <= 2) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'nowrap', overflow: 'hidden', minWidth: 0 }}>
|
||
{waiters.map((w, i) => {
|
||
const name = w.nickname || w.full_name || w.username || '?'
|
||
return (
|
||
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0, overflow: 'hidden' }}>
|
||
{i > 0 && <span style={{ color: textColor, opacity: 0.3, fontSize: 14, flexShrink: 0 }}>·</span>}
|
||
<WaiterAvatar waiter={w} size={size} />
|
||
<span style={{
|
||
fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.85,
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}>{name}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// > 2 waiters: icons only + "X Waiters" label
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
{waiters.slice(0, 3).map((w, i) => (
|
||
<div key={w.id} style={{ marginLeft: i === 0 ? 0 : -(size * 0.28) }}>
|
||
<WaiterAvatar waiter={w} size={size} ring={cfg.cardBg} />
|
||
</div>
|
||
))}
|
||
{waiters.length > 3 && (
|
||
<div style={{
|
||
marginLeft: -(size * 0.28), height: size, padding: '0 6px',
|
||
borderRadius: size, background: `${cfg.nameText}20`,
|
||
color: cfg.nameText, fontSize: 10, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center',
|
||
}}>+{waiters.length - 3}</div>
|
||
)}
|
||
<span style={{ fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.7, marginLeft: 4 }}>
|
||
{waiters.length} σερβιτόροι
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Status pill ──────────────────────────────────────────────────────────────
|
||
|
||
function StatusPill({ label, badgeBg, badgeText, small }) {
|
||
return (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center',
|
||
height: small ? 18 : 20,
|
||
padding: small ? '0 6px' : '0 8px',
|
||
borderRadius: 4,
|
||
background: badgeBg,
|
||
color: badgeText,
|
||
fontSize: small ? 9 : 10,
|
||
fontWeight: 800,
|
||
letterSpacing: 0.4,
|
||
whiteSpace: 'nowrap',
|
||
}}>{label}</span>
|
||
)
|
||
}
|
||
|
||
// ─── Flag dot ─────────────────────────────────────────────────────────────────
|
||
|
||
function FlagDot({ flag, size = 22 }) {
|
||
const textColor = flag.text_color || '#ffffff'
|
||
return (
|
||
<div
|
||
title={flag.name}
|
||
style={{
|
||
width: size, height: size, borderRadius: '50%',
|
||
background: flag.color || '#6295F3',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: size * 0.55,
|
||
flexShrink: 0,
|
||
color: textColor,
|
||
}}
|
||
>
|
||
{flag.emoji || '🏷️'}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Flag overflow row: show up to maxShow dots, then +N bubble ───────────────
|
||
|
||
function FlagDots({ flags, size, maxShow }) {
|
||
if (!flags.length) return null
|
||
const visible = flags.slice(0, maxShow)
|
||
const overflow = flags.length - maxShow
|
||
return (
|
||
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||
{visible.map(f => <FlagDot key={f.id} flag={f} size={size} />)}
|
||
{overflow > 0 && (
|
||
<div style={{
|
||
width: size, height: size, borderRadius: '50%',
|
||
background: 'rgba(0,0,0,0.18)',
|
||
color: '#fff', fontSize: size * 0.44, fontWeight: 800,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
flexShrink: 0,
|
||
}}>+{overflow}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Flag chip (icon + label) ─────────────────────────────────────────────────
|
||
|
||
function FlagChip({ flag }) {
|
||
const textColor = flag.text_color || '#ffffff'
|
||
return (
|
||
<div
|
||
title={flag.name}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||
height: 26, padding: '0 9px',
|
||
borderRadius: 13,
|
||
background: flag.color || '#6295F3',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, lineHeight: 1 }}>{flag.emoji || '🏷️'}</span>
|
||
<span style={{ fontSize: 11, fontWeight: 700, color: textColor, whiteSpace: 'nowrap' }}>
|
||
{flag.name}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Amount display ───────────────────────────────────────────────────────────
|
||
|
||
function Amount({ value, size = 22, color }) {
|
||
const s = Number(value || 0).toFixed(2)
|
||
const [whole, cents] = s.split('.')
|
||
const isNum = typeof size === 'number'
|
||
const centsSize = isNum ? size * 0.56 : `calc(${size} * 0.56)`
|
||
return (
|
||
<div style={{ lineHeight: 1, color: color || 'inherit' }}>
|
||
<span style={{ fontSize: size, fontWeight: 800, letterSpacing: -0.5 }}>{whole}</span>
|
||
<span style={{ fontSize: centsSize, fontWeight: 800, opacity: 0.8 }}>.{cents}€</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Card variants ────────────────────────────────────────────────────────────
|
||
|
||
// 1x1 — square-ish, 4 per row. Badges top (up to 2 + +N), name center, status bottom.
|
||
function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||
return (
|
||
<div style={{
|
||
width: '100%', aspectRatio: '1 / 1.05',
|
||
background: cfg.cardBg, borderRadius: 14,
|
||
position: 'relative', overflow: 'hidden',
|
||
display: 'flex', flexDirection: 'column',
|
||
padding: 8,
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||
}}>
|
||
{/* top strip: badges up to 2, then +N */}
|
||
<div style={{ height: '20%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||
<FlagDots flags={flags} size={16} maxShow={2} />
|
||
</div>
|
||
|
||
{/* center: name */}
|
||
<div style={{
|
||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontWeight: 800, fontSize: 'clamp(18px, 5vw, 26px)',
|
||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1,
|
||
}}>
|
||
{table.label || `T${table.number}`}
|
||
</div>
|
||
|
||
{/* bottom strip: status */}
|
||
<div style={{ height: '20%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
|
||
<span style={{
|
||
fontSize: 7, fontWeight: 800, letterSpacing: 0.3,
|
||
color: cfg.badgeText, textTransform: 'uppercase',
|
||
background: cfg.badgeBg, borderRadius: 3,
|
||
padding: '1px 4px', whiteSpace: 'nowrap',
|
||
}}>
|
||
{STATUS_LABELS[statusKey]}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 2x1 — half width, compact horizontal. Name left, status + badges (up to 3 + +N) right.
|
||
function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||
return (
|
||
<div style={{
|
||
width: '100%', height: 64,
|
||
background: cfg.cardBg, borderRadius: 14,
|
||
padding: '10px 12px',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
gap: 10, overflow: 'hidden',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||
}}>
|
||
<div style={{
|
||
fontWeight: 800, fontSize: 'clamp(18px, 4.5vw, 24px)',
|
||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||
}}>
|
||
{table.label || `T${table.number}`}
|
||
</div>
|
||
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column',
|
||
alignItems: 'flex-end', justifyContent: 'center', gap: 4,
|
||
}}>
|
||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||
{flags.length > 0 && (
|
||
<FlagDots flags={flags} size={18} maxShow={3} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 2x2 — current-style square. Name top-left, status (slightly smaller) below, amount bottom-left, flags right.
|
||
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||
const isFree = !order
|
||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||
const showAmount = !isFree
|
||
|
||
return (
|
||
<div style={{
|
||
width: '100%', minHeight: 116,
|
||
background: cfg.cardBg, borderRadius: 16,
|
||
padding: '12px 12px 12px',
|
||
display: 'flex', gap: 8, overflow: 'hidden',
|
||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||
}}>
|
||
{/* left column */}
|
||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||
<span style={{
|
||
fontSize: 'clamp(22px, 5.5vw, 36px)', fontWeight: 800,
|
||
lineHeight: 1.05, color: cfg.nameText, letterSpacing: -0.5,
|
||
}}>
|
||
{table.label || `T${table.number}`}
|
||
</span>
|
||
<div style={{ marginTop: 5 }}>
|
||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||
</div>
|
||
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28 }}>
|
||
{showAmount && <Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* right column: flags — show 2, then +N */}
|
||
{flags.length > 0 && (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column-reverse',
|
||
gap: 4, alignItems: 'flex-end', justifyContent: 'flex-start',
|
||
}}>
|
||
<FlagDots flags={flags} size={26} maxShow={2} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 4x1 — full width horizontal. Name + amount left-center, badges (up to 3 + +N) + status right.
|
||
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||
const isFree = !order
|
||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||
const showAmount = !isFree
|
||
|
||
return (
|
||
<div style={{
|
||
width: '100%', height: 68,
|
||
background: cfg.cardBg, borderRadius: 14,
|
||
padding: '12px 14px',
|
||
display: 'flex', alignItems: 'center', gap: 14, overflow: 'hidden',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||
}}>
|
||
{/* name */}
|
||
<div style={{
|
||
fontWeight: 800, fontSize: 'clamp(20px, 4.5vw, 28px)',
|
||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||
}}>
|
||
{table.label || `T${table.number}`}
|
||
</div>
|
||
|
||
{/* separator dot */}
|
||
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
|
||
|
||
{/* amount */}
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||
{showAmount && <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />}
|
||
</div>
|
||
|
||
{/* flags up to 3 + +N */}
|
||
{flags.length > 0 && (
|
||
<FlagDots flags={flags} size={24} maxShow={3} />
|
||
)}
|
||
|
||
{/* status */}
|
||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 4x2 — full width, tall. One main row: name+zone left, status center, amount+flags right. Flag chips below. Waiter footer.
|
||
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
|
||
const isFree = !order
|
||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||
const showAmount = !isFree
|
||
const showWaiters = !isFree && waiterObjects.length > 0
|
||
|
||
return (
|
||
<div style={{
|
||
width: '100%',
|
||
background: cfg.cardBg, borderRadius: 16,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||
display: 'flex', flexDirection: 'column',
|
||
}}>
|
||
{/* main body */}
|
||
<div style={{ padding: '14px 14px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{/* top row: name LEFT | status CENTER | amount RIGHT — all top-aligned */}
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||
{/* left: name + zone */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontWeight: 800, fontSize: 'clamp(30px, 7vw, 44px)',
|
||
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
|
||
}}>
|
||
{table.label || `T${table.number}`}
|
||
</div>
|
||
{groupName && (
|
||
<div style={{
|
||
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
|
||
color: cfg.nameText, opacity: 0.6,
|
||
textTransform: 'uppercase', marginTop: 3,
|
||
}}>
|
||
{groupName}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* center: status pill — top-aligned via paddingTop to optically align with name cap */}
|
||
<div style={{ paddingTop: 4, flexShrink: 0 }}>
|
||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||
</div>
|
||
|
||
{/* right: amount — top-aligned */}
|
||
{showAmount && (
|
||
<div style={{ flexShrink: 0 }}>
|
||
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* flag chips row — right-aligned */}
|
||
{flags.length > 0 && (
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', flexWrap: 'wrap', gap: 6 }}>
|
||
{flags.slice(0, 4).map(f => <FlagChip key={f.id} flag={f} />)}
|
||
{flags.length > 4 && (
|
||
<div style={{
|
||
height: 26, padding: '0 9px', borderRadius: 13,
|
||
background: 'rgba(0,0,0,0.18)', color: '#fff',
|
||
fontSize: 11, fontWeight: 800,
|
||
display: 'flex', alignItems: 'center',
|
||
}}>+{flags.length - 4}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* footer: waiters */}
|
||
<div style={{
|
||
borderTop: `1px solid ${cfg.nameText}22`,
|
||
padding: '10px 14px', minHeight: 40,
|
||
display: 'flex', alignItems: 'center',
|
||
}}>
|
||
{showWaiters
|
||
? <WaiterRow waiters={waiterObjects} size={24} cfg={cfg} />
|
||
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}>—</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 4x3 — full width, two-column detail card. Left: name/zone/status/amount. Right: order items list. Footer: waiters.
|
||
function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
|
||
const isFree = !order
|
||
const activeItems = order?.items?.filter(i => i.status === 'active') ?? []
|
||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||
const showWaiters = !isFree && waiterObjects.length > 0
|
||
|
||
return (
|
||
<div style={{
|
||
width: '100%',
|
||
background: cfg.cardBg, borderRadius: 16,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||
display: 'flex', flexDirection: 'column',
|
||
}}>
|
||
<div style={{ display: 'flex', padding: '14px 14px 10px', gap: 14, minWidth: 0, overflow: 'hidden' }}>
|
||
{/* left column: name, zone, amount, status, flags */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 100, flexShrink: 0, justifyContent: 'space-between' }}>
|
||
<div>
|
||
<div style={{
|
||
fontWeight: 800, fontSize: 'clamp(28px, 6vw, 40px)',
|
||
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
|
||
}}>
|
||
{table.label || `T${table.number}`}
|
||
</div>
|
||
{groupName && (
|
||
<div style={{
|
||
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
|
||
color: cfg.nameText, opacity: 0.6,
|
||
textTransform: 'uppercase', marginTop: 3,
|
||
}}>
|
||
{groupName}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: 10 }}>
|
||
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />}
|
||
</div>
|
||
|
||
<div style={{ marginTop: 8 }}>
|
||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||
</div>
|
||
|
||
{flags.length > 0 && (
|
||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||
<FlagDots flags={flags} size={22} maxShow={3} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* divider */}
|
||
<div style={{ width: 1, background: `${cfg.nameText}20`, alignSelf: 'stretch', flexShrink: 0 }} />
|
||
|
||
{/* right column: order items */}
|
||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||
{isFree ? (
|
||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Ελεύθερο</span>
|
||
</div>
|
||
) : activeItems.length === 0 ? (
|
||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Κανένα είδος</span>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
|
||
{activeItems.slice(0, 7).map(item => (
|
||
<div key={item.id} style={{ display: 'flex', alignItems: 'baseline', gap: 5, overflow: 'hidden', minWidth: 0 }}>
|
||
<span style={{
|
||
fontSize: 11, fontWeight: 700, color: cfg.nameText,
|
||
background: `${cfg.nameText}18`, borderRadius: 3,
|
||
padding: '1px 5px', flexShrink: 0,
|
||
}}>{item.quantity}×</span>
|
||
<span style={{
|
||
fontSize: 12, fontWeight: 500, color: cfg.nameText,
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
|
||
}}>{item.product?.name || `#${item.product_id}`}</span>
|
||
<span style={{ fontSize: 11, fontWeight: 700, color: cfg.nameText, opacity: 0.7, flexShrink: 0 }}>
|
||
{(item.unit_price * item.quantity).toFixed(2)}€
|
||
</span>
|
||
</div>
|
||
))}
|
||
{activeItems.length > 7 && (
|
||
<div style={{ fontSize: 11, color: cfg.nameText, opacity: 0.5, marginTop: 2 }}>
|
||
+{activeItems.length - 7} ακόμα…
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* footer: waiters */}
|
||
<div style={{
|
||
borderTop: `1px solid ${cfg.nameText}22`,
|
||
padding: '10px 14px', minHeight: 38,
|
||
display: 'flex', alignItems: 'center',
|
||
}}>
|
||
{showWaiters
|
||
? <WaiterRow waiters={waiterObjects} size={22} cfg={cfg} />
|
||
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}>—</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Main export ──────────────────────────────────────────────────────────────
|
||
|
||
export default function TableCard({
|
||
table,
|
||
order,
|
||
isMine,
|
||
flags = [],
|
||
groupName = '',
|
||
waiterObjects = [],
|
||
density = '2x2',
|
||
onClick,
|
||
onLongPress,
|
||
}) {
|
||
const holdTimer = useRef(null)
|
||
const startPos = useRef({ x: 0, y: 0 })
|
||
const didFire = useRef(false)
|
||
const [showTip, setShowTip] = useState(false)
|
||
|
||
const dark = useThemeStore(s => s.dark)
|
||
const colours = useTableColourStore(s => s.colours)
|
||
|
||
let statusKey = 'free'
|
||
if (order?.status === 'paid') statusKey = 'paid'
|
||
else if (order?.status === 'partially_paid') statusKey = 'partially_paid'
|
||
else if (order && isMine) statusKey = 'mine'
|
||
else if (order) statusKey = 'open'
|
||
|
||
const mode = dark ? 'dark' : 'light'
|
||
const cfg = colours[mode][statusKey]
|
||
|
||
function cancel() {
|
||
clearTimeout(holdTimer.current)
|
||
holdTimer.current = null
|
||
}
|
||
|
||
function onTouchStart(e) {
|
||
const t = e.touches[0]
|
||
startPos.current = { x: t.clientX, y: t.clientY }
|
||
didFire.current = false
|
||
holdTimer.current = setTimeout(() => {
|
||
didFire.current = true
|
||
if (onLongPress) onLongPress()
|
||
else setShowTip(true)
|
||
}, HOLD_MS)
|
||
}
|
||
|
||
function onTouchMove(e) {
|
||
if (!holdTimer.current) return
|
||
const t = e.touches[0]
|
||
const dx = Math.abs(t.clientX - startPos.current.x)
|
||
const dy = Math.abs(t.clientY - startPos.current.y)
|
||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
|
||
}
|
||
|
||
function onTouchEnd() { cancel(); setShowTip(false) }
|
||
|
||
function onMouseDown(e) {
|
||
startPos.current = { x: e.clientX, y: e.clientY }
|
||
didFire.current = false
|
||
holdTimer.current = setTimeout(() => {
|
||
didFire.current = true
|
||
if (onLongPress) onLongPress()
|
||
else setShowTip(true)
|
||
}, HOLD_MS)
|
||
}
|
||
function onMouseMove(e) {
|
||
if (!holdTimer.current) return
|
||
const dx = Math.abs(e.clientX - startPos.current.x)
|
||
const dy = Math.abs(e.clientY - startPos.current.y)
|
||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
|
||
}
|
||
function onMouseUp() { cancel(); setShowTip(false) }
|
||
function onMouseLeave() { cancel(); setShowTip(false) }
|
||
|
||
function handleClick(e) {
|
||
if (didFire.current) { e.preventDefault(); return }
|
||
onClick?.()
|
||
}
|
||
|
||
const cardProps = { table, order, flags, waiterObjects, groupName, cfg, statusKey }
|
||
|
||
const CardComponent = {
|
||
'1x1': Card1x1,
|
||
'2x1': Card2x1,
|
||
'2x2': Card2x2,
|
||
'4x1': Card4x1,
|
||
'4x2': Card4x2,
|
||
'4x3': Card4x3,
|
||
}[density] || Card2x2
|
||
|
||
return (
|
||
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
||
<button
|
||
style={{ display: 'block', width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
|
||
onClick={handleClick}
|
||
onTouchStart={onTouchStart}
|
||
onTouchMove={onTouchMove}
|
||
onTouchEnd={onTouchEnd}
|
||
onMouseDown={onMouseDown}
|
||
onMouseMove={onMouseMove}
|
||
onMouseUp={onMouseUp}
|
||
onMouseLeave={onMouseLeave}
|
||
>
|
||
<CardComponent {...cardProps} />
|
||
</button>
|
||
|
||
{showTip && flags.length > 0 && (
|
||
<div style={{
|
||
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
|
||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||
borderRadius: 10, padding: '8px 12px', zIndex: 50,
|
||
boxShadow: '0 4px 16px var(--shadow)',
|
||
minWidth: 160, pointerEvents: 'none',
|
||
}}>
|
||
{flags.map(f => (
|
||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||
<span style={{ fontSize: 15 }}>{f.emoji || '🏷️'}</span>
|
||
<span style={{ fontSize: 13, color: 'var(--text)' }}>{f.name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|