@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user