feat: make server own tabs
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 16:18:36 +02:00
parent 1e4438ec5a
commit 0ca9f4d16a
31 changed files with 2101 additions and 892 deletions
+12 -3
View File
@@ -27,7 +27,7 @@ export default function DashboardPage() {
const deviceQuery = useQuery({
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId),
queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000,
});
@@ -45,7 +45,12 @@ export default function DashboardPage() {
});
const tabs = tabsQuery.data?.tabs ?? [];
const device = deviceQuery.data?.device;
const devices = deviceQuery.data?.devices ?? [];
// The terminal heard from most recently
const device = devices.reduce<(typeof devices)[number] | undefined>(
(latest, d) => (!latest || d.lastSeenAt > latest.lastSeenAt ? d : latest),
undefined,
);
const commands = recentQuery.data?.commands ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
@@ -88,7 +93,11 @@ export default function DashboardPage() {
<span className="text-2xl font-bold truncate">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
</div>
<p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>
<p className="text-xs text-muted-foreground mt-1">
{devices.length > 1
? t('overview.terminals', { count: devices.length, time: relativeTime(device.lastSeenAt) })
: t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}
</p>
</>
) : (
<div className="text-2xl font-bold">{t('overview.not_connected')}</div>
@@ -1,13 +1,14 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { devicesApi, venuesApi } from '@/lib/api/client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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 { 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 { useSession } from '@/lib/session';
@@ -17,6 +18,8 @@ export default function SettingsPage() {
const { venueId, role } = useSession();
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false);
const [unpairing, setUnpairing] = useState<Device | null>(null);
const queryClient = useQueryClient();
const { t, te, relativeTime, dateTime, time } = useLang();
const { data: venue } = useQuery({
@@ -26,12 +29,22 @@ export default function SettingsPage() {
const { data: deviceData } = useQuery({
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId),
queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000,
});
const device = deviceData?.device;
const devices = deviceData?.devices ?? [];
const unpairMutation = useMutation({
mutationFn: (device: Device) => devicesApi.unpair(venueId, device.id),
onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
setUnpairing(null);
queryClient.invalidateQueries({ queryKey: ['device'] });
},
});
async function handleGenerateCode() {
setGenerating(true);
@@ -71,17 +84,29 @@ export default function SettingsPage() {
<CardHeader>
<CardTitle>{t('settings.device')}</CardTitle>
<CardDescription>
{device
? t('settings.paired', { name: device.name, time: relativeTime(device.lastSeenAt) })
: t('settings.not_paired')}
{devices.length === 0
? t('settings.not_paired')
: devices.length === 1
? t('settings.paired', { name: devices[0].name, time: relativeTime(devices[0].lastSeenAt) })
: t('settings.terminals', { count: devices.length })}
</CardDescription>
</CardHeader>
<CardContent>
{device ? (
<div className="flex items-center gap-2 text-sm">
<Monitor className="h-4 w-4 text-muted-foreground" />
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
{devices.length > 0 ? (
<div className="space-y-2">
{devices.map((device) => (
<div key={device.id} className="flex flex-wrap items-center gap-2 text-sm">
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
{role === 'admin' && (
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setUnpairing(device)}>
{t('settings.unpair')}
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground mb-3">
@@ -91,7 +116,7 @@ export default function SettingsPage() {
{role === 'admin' && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" />
{generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')}
{generating ? t('settings.generating') : devices.length > 0 ? t('settings.repair') : t('settings.pair')}
</Button>
)}
</CardContent>
@@ -99,6 +124,18 @@ export default function SettingsPage() {
</div>
<ConfirmDialog
open={unpairing !== null}
onOpenChange={(open) => !open && setUnpairing(null)}
title={t('settings.unpair_title', { name: unpairing?.name ?? '' })}
description={t('settings.unpair_desc')}
confirmLabel={t('settings.unpair')}
cancelLabel={t('common.cancel')}
variant="destructive"
loading={unpairMutation.isPending}
onConfirm={() => unpairing && unpairMutation.mutate(unpairing)}
/>
{regDialog && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
+9 -33
View File
@@ -1,7 +1,7 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useEffect, useState } from 'react';
import { Fragment, useState } from 'react';
import { commandsApi, plannedTabsApi, remindersApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -40,7 +40,6 @@ export default function TabsPage() {
const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [paymentMethod, setPaymentMethod] = useState('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [watchedCommandIds, setWatchedCommandIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortKey>('name');
const [plannedName, setPlannedName] = useState('');
@@ -61,14 +60,6 @@ export default function TabsPage() {
});
const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId));
// Only needed to report the outcome of close requests sent from this page
const resultsQuery = useQuery({
queryKey: ['commands', venueId, 'results'],
queryFn: () => commandsApi.list(venueId, { limit: 50 }),
enabled: watchedCommandIds.size > 0,
refetchInterval: live ? false : 3000,
});
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId),
@@ -78,31 +69,16 @@ export default function TabsPage() {
// Tiles only show customers the terminal has not opened a tab for yet
const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt);
useEffect(() => {
const finished = (resultsQuery.data?.commands ?? []).filter((c) => watchedCommandIds.has(c.id) && c.status !== 'pending');
if (finished.length === 0) return;
for (const cmd of finished) {
const status = t(`status.${cmd.status}`);
toast({
title: status,
description: t('tabs.request_result', { name: cmd.customerName ?? cmd.tabId.substring(0, 8), status }),
variant: cmd.status === 'completed' ? 'success' : 'destructive',
});
}
setWatchedCommandIds((current) => {
const next = new Set(current);
for (const cmd of finished) next.delete(cmd.id);
return next;
});
queryClient.invalidateQueries({ queryKey: ['tabs'] });
}, [resultsQuery.data, watchedCommandIds, queryClient, t]);
const createCommandMutation = useMutation({
mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) =>
commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }),
onSuccess: (result) => {
setWatchedCommandIds((current) => new Set(current).add(result.id));
toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' });
onSuccess: (result, { tab }) => {
toast({
title: t('tabs.settled'),
description: t('tabs.settled_desc', { name: tab.customerName, amount: formatCents(result.amountInCents) }),
variant: 'success',
});
queryClient.invalidateQueries({ queryKey: ['tabs'] });
},
onError: (err) => {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
@@ -325,7 +301,7 @@ export default function TabsPage() {
</TableCell>
<TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
<TableCell className="text-right">
{closingTabIds.has(tab.tabId) ? (
<Badge variant="warning">{t('tabs.closing')}</Badge>
+9 -9
View File
@@ -9,6 +9,7 @@ export type Role = 'admin' | 'viewer';
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
/** An open tab as the server holds it: every terminal of the venue shows the same. */
export interface Tab {
tabId: string;
customerName: string;
@@ -16,11 +17,7 @@ export interface Tab {
totalInCents: number;
itemCount: number;
items: TabLine[] | null;
deviceId: string;
deviceName: string;
deviceLastSeenAt: string;
deviceRevokedAt: string | null;
lastReportedAt: string;
lastChangedAt: string;
plannedTabId: string | null;
email: string | null;
}
@@ -40,7 +37,8 @@ export interface Command {
createdAt: string;
processedAt: string | null;
result: string | null;
deviceId: string;
/** Null for a tab the server settled itself */
deviceId: string | null;
}
export interface Device {
@@ -259,8 +257,10 @@ export const tabsApi = {
// Devices API
export const devicesApi = {
get: (venueId: string) =>
api<{ device: Device | null }>(`/venues/${venueId}/devices`),
list: (venueId: string) =>
api<{ devices: Device[] }>(`/venues/${venueId}/devices`),
unpair: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }),
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
};
@@ -285,7 +285,7 @@ export const commandsApi = {
);
},
create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) =>
api<{ id: string; status: string; warning?: string }>(`/venues/${venueId}/commands`, {
api<{ id: string; status: string; amountInCents: number }>(`/venues/${venueId}/commands`, {
method: 'POST',
body: JSON.stringify(data),
}),
+36 -22
View File
@@ -29,6 +29,8 @@ const translations: Record<Lang, Record<string, string>> = {
'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.',
'errors.command_already_pending': 'A close request for this tab is already waiting for the terminal',
'errors.tab_not_owned': 'This tab is no longer open on the terminal',
'errors.tab_not_found': 'This tab is no longer open',
'errors.tab_empty': 'There is nothing on this tab to settle',
'errors.device_revoked': 'The terminal for this tab has been unpaired',
'errors.name_exists': 'This customer already exists',
'errors.email_exists': 'A user with this email already exists',
@@ -90,14 +92,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.lines_empty': 'No items on this tab.',
'tabs.each': 'each',
'tabs.close_title': "Close {name}'s tab?",
'tabs.close_desc': 'Send a close request to the terminal.',
'tabs.close_desc': 'Settles everything on the tab now. The terminals show it after their next sync.',
'tabs.payment': 'Payment method',
'tabs.select_payment': 'Please select a payment method',
'tabs.send': 'Send close request',
'tabs.sending': 'Sending...',
'tabs.request_sent': 'Close request sent',
'tabs.request_sent_desc': 'Sent to the terminal',
'tabs.request_result': 'Close request for {name}: {status}',
'tabs.settled': 'Tab settled',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Send test email',
'tabs.test_sent': 'Test email sent',
'tabs.test_sent_desc': 'Sent to {email}',
@@ -185,7 +186,12 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Terminal',
'settings.repair': 'Re-pair Terminal',
'settings.repair': 'Pair Another Terminal',
'settings.unpair': 'Unpair',
'settings.unpair_title': 'Unpair {name}?',
'settings.unpair_desc': 'The terminal stops syncing and has to be paired again to reconnect. Its changes that have not been synced yet are lost.',
'settings.unpaired': '{name} is unpaired',
'settings.terminals': '{count} terminals paired',
'settings.generating': 'Generating...',
'settings.pair_title': 'Pair a Terminal',
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
@@ -208,6 +214,7 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected',
'overview.last_seen': 'Last seen {time}',
'overview.terminals': '{count} terminals, last seen {time}',
'auth.login': 'Sign in',
'auth.logging_in': 'Signing in...',
'auth.email': 'Email',
@@ -293,27 +300,27 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.out': 'Out',
'stock.adjust': 'Adjust',
'stock.start_tracking': 'Track stock',
'stock.start_tracking_desc': "The terminal doesn't track stock for this product yet. Enter how many you have to start tracking it.",
'stock.start_tracking_desc': "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.",
'stock.cancel_adjustment': 'Cancel {change} for {name}',
'stock.cancelled': 'Change cancelled',
'stock.current': 'Currently {stock} in stock on the terminal.',
'stock.current': 'Currently {stock} in stock.',
'stock.mode_add': 'Add (delivery)',
'stock.mode_remove': 'Take out (breakage, own use…)',
'stock.mode_count': 'Count (set the stock to what you counted)',
'stock.quantity': 'Quantity',
'stock.counted': 'Counted',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Drinks sold between your count and the terminal receiving it are not subtracted.',
'stock.count_hint': 'Drinks rung up before your count are in it: they are not subtracted again, also not when an offline terminal sends them later.',
'stock.note': 'Note (optional)',
'stock.note_placeholder': 'e.g. delivery from the brewery',
'stock.threshold': 'Warn when stock is at or below',
'stock.threshold_none': 'No warning',
'stock.threshold_save': 'Save',
'stock.threshold_saved': 'Warning level saved',
'stock.send': 'Send to terminal',
'stock.send': 'Save',
'stock.sending': 'Sending…',
'stock.sent': 'Sent to the terminal',
'stock.sent_desc': 'The stock changes on the terminal at its next sync.',
'stock.sent': 'Stock updated',
'stock.sent_desc': 'The terminals show it after their next sync.',
'stock.delivery': 'Register delivery',
'stock.delivery_title': 'Register delivery',
'stock.delivery_desc': 'Enter how many of each product were delivered. Products you leave empty are not changed.',
@@ -355,6 +362,8 @@ const translations: Record<Lang, Record<string, string>> = {
'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.',
'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa',
'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa',
'errors.tab_not_found': 'Deze poef staat niet meer open',
'errors.tab_empty': 'Er staat niets op deze poef om af te rekenen',
'errors.device_revoked': 'De kassa van deze poef is ontkoppeld',
'errors.name_exists': 'Deze klant bestaat al',
'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres',
@@ -416,14 +425,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.lines_empty': 'Geen items op deze poef.',
'tabs.each': 'per stuk',
'tabs.close_title': 'Poef van {name} sluiten?',
'tabs.close_desc': 'Stuur een verzoek naar de terminal om deze poef te sluiten.',
'tabs.close_desc': 'Rekent alles op de poef nu af. De terminals tonen het na hun volgende synchronisatie.',
'tabs.payment': 'Betaalmethode',
'tabs.select_payment': 'Kies een betaalmethode',
'tabs.send': 'Sluitverzoek versturen',
'tabs.sending': 'Bezig met versturen...',
'tabs.request_sent': 'Sluitverzoek verstuurd',
'tabs.request_sent_desc': 'Verstuurd naar de terminal',
'tabs.request_result': 'Sluitverzoek voor {name}: {status}',
'tabs.settled': 'Poef afgerekend',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Testmail versturen',
'tabs.test_sent': 'Testmail verstuurd',
'tabs.test_sent_desc': 'Verstuurd naar {email}',
@@ -511,7 +519,12 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
'settings.pair': 'Terminal koppelen',
'settings.repair': 'Terminal opnieuw koppelen',
'settings.repair': 'Nog een terminal koppelen',
'settings.unpair': 'Ontkoppelen',
'settings.unpair_title': '{name} ontkoppelen?',
'settings.unpair_desc': 'De terminal synchroniseert niet meer en moet opnieuw gekoppeld worden. Wijzigingen die nog niet gesynchroniseerd zijn, gaan verloren.',
'settings.unpaired': '{name} is ontkoppeld',
'settings.terminals': '{count} terminals gekoppeld',
'settings.generating': 'Code aanmaken...',
'settings.pair_title': 'Terminal koppelen',
'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.',
@@ -534,6 +547,7 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden',
'overview.last_seen': 'Laatst gezien {time}',
'overview.terminals': '{count} terminals, laatst gezien {time}',
'auth.login': 'Inloggen',
'auth.logging_in': 'Bezig met inloggen...',
'auth.email': 'E-mail',
@@ -619,27 +633,27 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.out': 'Op',
'stock.adjust': 'Aanpassen',
'stock.start_tracking': 'Voorraad bijhouden',
'stock.start_tracking_desc': 'De kassa houdt voor dit product nog geen voorraad bij. Vul in hoeveel je er hebt om te beginnen.',
'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.',
'stock.cancel_adjustment': '{change} voor {name} annuleren',
'stock.cancelled': 'Wijziging geannuleerd',
'stock.current': 'Nu {stock} in voorraad op de kassa.',
'stock.current': 'Nu {stock} in voorraad.',
'stock.mode_add': 'Bijvullen (levering)',
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
'stock.mode_count': 'Tellen (voorraad wordt wat je telde)',
'stock.quantity': 'Aantal',
'stock.counted': 'Geteld',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Wat verkocht wordt tussen je telling en het moment dat de kassa ze ontvangt, wordt niet afgetrokken.',
'stock.count_hint': 'Wat voor je telling aangeslagen werd, zit erin: het wordt niet nog eens afgetrokken, ook niet als een offline kassa het later doorstuurt.',
'stock.note': 'Notitie (optioneel)',
'stock.note_placeholder': 'bv. levering brouwerij',
'stock.threshold': 'Waarschuw vanaf',
'stock.threshold_none': 'Geen waarschuwing',
'stock.threshold_save': 'Opslaan',
'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen',
'stock.send': 'Naar kassa sturen',
'stock.send': 'Opslaan',
'stock.sending': 'Versturen…',
'stock.sent': 'Naar de kassa gestuurd',
'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de volgende synchronisatie.',
'stock.sent': 'Voorraad bijgewerkt',
'stock.sent_desc': 'De kassa\'s tonen het na hun volgende synchronisatie.',
'stock.delivery': 'Levering invoeren',
'stock.delivery_title': 'Levering invoeren',
'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.',