feat: add better tab view
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-28 00:37:16 +01:00
parent bca6e88138
commit ca3af671cf
37 changed files with 740 additions and 79 deletions
@@ -2,7 +2,8 @@
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
import { Fragment, useEffect, useState } from 'react';
import { tabsApi, type PaidTab } from '@/lib/api/client';
import { tabsApi } from '@/lib/api/client';
import { TabOrders } from '@/components/tab-orders';
import { PageHeader } from '@/components/page-header';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -152,7 +153,7 @@ export default function HistoryPage() {
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
<PaidLines items={paid.items} />
<TabOrders orders={paid.orders} />
</div>
</TableCell>
</TableRow>
@@ -184,25 +185,3 @@ export default function HistoryPage() {
</div>
);
}
/** The paid lines, e.g. "10× Cola · €2.50 each · €25.00", with what was taken off before paying. */
function PaidLines({ items }: { items: PaidTab['items'] }) {
const { t } = useLang();
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
}
return (
<ul className="space-y-1 text-sm">
{items.map((item, i) => (
<li key={i} className="flex justify-between gap-4">
<span className={item.quantity === 0 ? 'text-muted-foreground line-through' : undefined}>
<span className="font-medium">{item.quantity}×</span> {item.productName}
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
{item.removed > 0 && <span className="text-destructive text-xs"> · {t('history.removed', { count: item.removed })}</span>}
</span>
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
</li>
))}
</ul>
);
}
@@ -6,13 +6,14 @@ import { devicesApi, venuesApi, type Device } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { canAdministerVenue, useSession } from '@/lib/session';
import { Monitor, Copy, Plus } from 'lucide-react';
import { canAdministerVenue, canManageVenue, useSession } from '@/lib/session';
import { Monitor, Copy, Plus, Mail, X } from 'lucide-react';
export default function SettingsPage() {
const { venueId, role, viewing } = useSession();
@@ -81,6 +82,8 @@ export default function SettingsPage() {
</CardContent>
</Card>
<CashPaymentEmails emails={venue?.cashPaymentEmails} canEdit={canManageVenue(role)} />
<Card>
<CardHeader>
<CardTitle>{t('settings.device')}</CardTitle>
@@ -176,4 +179,84 @@ export default function SettingsPage() {
)}
</div>
);
}
}
/** Who gets an email for every tab paid in cash. */
function CashPaymentEmails({ emails, canEdit }: { emails: string[] | undefined; canEdit: boolean }) {
const { venueId } = useSession();
const queryClient = useQueryClient();
const { t, te } = useLang();
const [draft, setDraft] = useState('');
const [invalid, setInvalid] = useState(false);
const saveMutation = useMutation({
mutationFn: (next: string[]) => venuesApi.updateCashPaymentEmails(venueId, next),
onSuccess: () => toast({ title: t('settings.cash_emails_saved'), variant: 'success' }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['venue', venueId] }),
});
const current = emails ?? [];
function add() {
const email = draft.trim().toLowerCase();
if (!email) return;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setInvalid(true);
return;
}
if (!current.includes(email)) saveMutation.mutate([...current, email]);
setDraft('');
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />{t('settings.cash_emails')}</CardTitle>
<CardDescription>{t('settings.cash_emails_desc')}</CardDescription>
</CardHeader>
<CardContent>
{canEdit && (
<div className="mb-3">
<div className="flex gap-2 flex-wrap">
<Input
type="email"
placeholder={t('settings.cash_emails_placeholder')}
value={draft}
onChange={(e) => { setDraft(e.target.value); setInvalid(false); }}
onKeyDown={(e) => e.key === 'Enter' && add()}
className="max-w-xs"
aria-label={t('settings.cash_emails_placeholder')}
aria-invalid={invalid}
/>
<Button size="sm" onClick={add} disabled={!draft.trim() || saveMutation.isPending}>
<Plus className="h-4 w-4 mr-2" />{t('settings.cash_emails_add')}
</Button>
</div>
{invalid && <p className="text-xs text-destructive mt-1">{t('settings.cash_emails_invalid')}</p>}
</div>
)}
<div className="flex flex-wrap gap-2">
{emails && current.length === 0 && (
<p className="text-sm text-muted-foreground">{t('settings.cash_emails_empty')}</p>
)}
{current.map((email) => (
<div key={email} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{email}
{canEdit && (
<button
onClick={() => saveMutation.mutate(current.filter((e) => e !== email))}
disabled={saveMutation.isPending}
className="text-muted-foreground hover:text-destructive ml-0.5"
aria-label={t('settings.cash_emails_remove', { email })}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</CardContent>
</Card>
);
}
+4 -27
View File
@@ -2,7 +2,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { commandsApi, plannedTabsApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client';
import { commandsApi, plannedTabsApi, tabsApi, type Tab } from '@/lib/api/client';
import { TabOrders } from '@/components/tab-orders';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -303,7 +304,7 @@ export default function TabsPage() {
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
<TabLines items={tab.items} />
<TabOrders orders={tab.orders} />
</div>
</TableCell>
</TableRow>
@@ -348,7 +349,7 @@ export default function TabsPage() {
<div className="space-y-3 py-4">
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.amount')}</span><span className="font-semibold">{formatCents(closeTab.totalInCents)}</span></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.items')}</span><span>{closeTab.itemCount}</span></div>
<div className="rounded-md border p-3"><TabLines items={closeTab.items} /></div>
<div className="rounded-md border p-3"><TabOrders orders={closeTab.orders} /></div>
<div className="space-y-3 pt-2">
<Label className="text-base font-semibold">{t('tabs.payment')}</Label>
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
@@ -369,27 +370,3 @@ export default function TabsPage() {
</div>
);
}
/** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */
function TabLines({ items }: { items: TabLine[] | null | undefined }) {
const { t } = useLang();
if (!items) {
return <p className="text-sm text-muted-foreground">{t('tabs.lines_unknown')}</p>;
}
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
}
return (
<ul className="space-y-1 text-sm">
{items.map((item, i) => (
<li key={i} className="flex justify-between gap-4">
<span>
<span className="font-medium">{item.quantity}×</span> {item.productName}
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
</span>
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,46 @@
'use client';
import type { TabOrder } from '@/lib/api/client';
import { formatCents } from '@/lib/utils';
import { useLang } from '@/lib/i18n';
/**
* A tab's drinks per order, as the terminal shows them: "—— 12:32 today ——" above each, e.g. "2× Duvel · €4.00 each
* · €8.00". Drinks taken off again show when the order lists them.
*/
export function TabOrders({ orders }: { orders: TabOrder[] | null | undefined }) {
const { t, orderTime } = useLang();
if (!orders) {
return <p className="text-sm text-muted-foreground">{t('tabs.lines_unknown')}</p>;
}
if (orders.length === 0) {
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
}
return (
<div className="space-y-2">
{orders.map((order) => (
<div key={order.orderId ?? order.startedAt}>
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground mb-1">
<span className="h-px flex-1 bg-border" />
<span>{orderTime(order.startedAt)}</span>
<span className="h-px flex-1 bg-border" />
</div>
<ul className="space-y-1 text-sm">
{order.items.map((item, i) => (
<li key={i} className="flex justify-between gap-4">
<span className={item.quantity === 0 ? 'text-muted-foreground line-through' : undefined}>
<span className="font-medium">{item.quantity}×</span> {item.productName}
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
{(item.removed ?? 0) > 0 && (
<span className="text-destructive text-xs"> · {t('history.removed', { count: item.removed! })}</span>
)}
</span>
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
</li>
))}
</ul>
</div>
))}
</div>
);
}
+15 -1
View File
@@ -13,6 +13,15 @@ export type Role = 'admin' | 'manager' | 'superadmin';
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
/** Drinks ordered together on a terminal, shown under the time the order started. */
export interface TabOrder {
// Null for drinks from older app versions, grouped by time instead
orderId: string | null;
startedAt: string;
// `removed`: drinks taken off again, only in the history
items: (TabLine & { removed?: number })[];
}
/** An open tab as the server holds it: every terminal of the venue shows the same. */
export interface Tab {
tabId: string;
@@ -21,6 +30,7 @@ export interface Tab {
totalInCents: number;
itemCount: number;
items: TabLine[] | null;
orders: TabOrder[];
lastChangedAt: string;
plannedTabId: string | null;
email: string | null;
@@ -36,6 +46,7 @@ export interface PaidTab {
amountInCents: number;
itemCount: number;
items: (TabLine & { removed: number })[];
orders: TabOrder[];
settledBy: string | null;
onDashboard: boolean;
}
@@ -360,7 +371,10 @@ export const commandsApi = {
// Venues API
export const venuesApi = {
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
get: (id: string) => api<{ id: string; name: string; cashPaymentEmails: string[] }>(`/venues/${id}`),
/** Who gets an email for every tab paid in cash, on a terminal or here */
updateCashPaymentEmails: (id: string, emails: string[]) =>
api<{ emails: string[] }>(`/venues/${id}/cash-payment-emails`, { method: 'PUT', body: JSON.stringify({ emails }) }),
};
// Planned Tabs API
+31
View File
@@ -54,6 +54,8 @@ const translations: Record<Lang, Record<string, string>> = {
'error_page.desc': 'This page could not be shown. Try again, or go back to the start.',
'error_page.retry': 'Try again',
'time.just_now': 'just now',
'orders.today': '{time} today',
'orders.yesterday': '{time} yesterday',
'time.minutes_ago': '{count}m ago',
'time.hours_ago': '{count}h ago',
'time.days_ago': '{count}d ago',
@@ -212,6 +214,14 @@ const translations: Record<Lang, Record<string, string>> = {
'commands.page': 'Page {page} of {total}',
'settings.title': 'Settings',
'settings.desc': 'Venue and terminal',
'settings.cash_emails': 'Cash payment emails',
'settings.cash_emails_desc': 'These addresses get an email every time a tab is paid in cash, on a terminal or here.',
'settings.cash_emails_empty': 'Nobody gets an email yet.',
'settings.cash_emails_placeholder': 'name@example.com',
'settings.cash_emails_add': 'Add',
'settings.cash_emails_remove': 'Remove {email}',
'settings.cash_emails_invalid': 'Enter a valid email address',
'settings.cash_emails_saved': 'Cash payment emails saved',
'settings.venue': 'Venue',
'settings.device': 'Terminal',
'settings.paired': 'Paired as "{name}" — last seen {time}',
@@ -636,6 +646,8 @@ const translations: Record<Lang, Record<string, string>> = {
'error_page.desc': 'Deze pagina kon niet getoond worden. Probeer het opnieuw of ga terug naar het begin.',
'error_page.retry': 'Opnieuw proberen',
'time.just_now': 'zonet',
'orders.today': '{time} vandaag',
'orders.yesterday': '{time} gisteren',
'time.minutes_ago': '{count} min geleden',
'time.hours_ago': '{count} u geleden',
'time.days_ago': '{count} d geleden',
@@ -794,6 +806,14 @@ const translations: Record<Lang, Record<string, string>> = {
'commands.page': 'Pagina {page} van {total}',
'settings.title': 'Instellingen',
'settings.desc': 'Zaak en terminal',
'settings.cash_emails': 'E-mails bij cashbetaling',
'settings.cash_emails_desc': 'Deze adressen krijgen een e-mail telkens een poef cash betaald wordt, op een kassa of hier.',
'settings.cash_emails_empty': 'Nog niemand krijgt een e-mail.',
'settings.cash_emails_placeholder': 'naam@voorbeeld.be',
'settings.cash_emails_add': 'Toevoegen',
'settings.cash_emails_remove': '{email} verwijderen',
'settings.cash_emails_invalid': 'Geef een geldig e-mailadres in',
'settings.cash_emails_saved': 'E-mails bij cashbetaling opgeslagen',
'settings.venue': 'Zaak',
'settings.device': 'Terminal',
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
@@ -1186,6 +1206,8 @@ interface LangContextType {
relativeTime: (date: string | Date) => string;
dateTime: (date: string | Date) => string;
time: (date: string | Date) => string;
/** When an order started: "12:32 today", "12:32 yesterday", or the date and time. */
orderTime: (date: string | Date) => string;
}
function translate(lang: Lang, key: string, params?: Params): string {
@@ -1226,6 +1248,15 @@ function helpers(lang: Lang): Omit<LangContextType, 'setLang'> {
dateTime: (value) =>
new Date(value).toLocaleString(locale, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
time: (value) => new Date(value).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
orderTime: (value) => {
const date = new Date(value);
const time = date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOfDay(new Date()) - startOfDay(date)) / (24 * 60 * 60 * 1000));
if (days === 0) return t('orders.today', { time });
if (days === 1) return t('orders.yesterday', { time });
return `${date.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })} ${time}`;
},
};
}