feat: rework adding a customer
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-28 01:33:52 +01:00
parent ca3af671cf
commit 6342b8451e
2 changed files with 120 additions and 71 deletions
+104 -65
View File
@@ -5,7 +5,6 @@ import { Fragment, useState } from 'react';
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';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { SortableHead } from '@/components/ui/sortable-head';
@@ -20,7 +19,7 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils';
import { RefreshCw, Plus, X, Users, Mail, Upload, ChevronRight, ChevronDown, Search, Trash2 } from 'lucide-react';
import { RefreshCw, Plus, X, Clock, Mail, Upload, ChevronRight, ChevronDown, Search, Trash2 } from 'lucide-react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
@@ -44,6 +43,8 @@ export default function TabsPage() {
const [plannedName, setPlannedName] = useState('');
const [plannedEmail, setPlannedEmail] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [adding, setAdding] = useState(false);
const { t, tn, te, relativeTime } = useLang();
const { data, isLoading, error } = useQuery({
@@ -65,7 +66,7 @@ export default function TabsPage() {
refetchInterval: live ? false : 10000,
});
// Tiles only show customers the terminal has not opened a tab for yet
// Customers the terminal has not opened a tab for yet; they get one on its next sync
const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt);
const createCommandMutation = useMutation({
@@ -110,17 +111,25 @@ export default function TabsPage() {
createCommandMutation.mutate({ tab: closeTab, paymentMethod });
}
function openAddDialog() {
setPlannedName('');
setPlannedEmail('');
setAddOpen(true);
}
async function addPlannedName() {
const name = plannedName.trim();
if (!name) return;
if (!name || adding) return;
setAdding(true);
try {
await plannedTabsApi.create(venueId, name, plannedEmail.trim() || undefined);
setPlannedName('');
setPlannedEmail('');
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
toast({ title: t('planned.added'), description: t('planned.added_desc', { name }) });
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally {
setAdding(false);
}
}
@@ -139,7 +148,8 @@ export default function TabsPage() {
const tabs = data?.tabs ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const query = search.trim().toLowerCase();
const visibleTabs = sort.apply(tabs.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)), {
const visiblePlanned = pendingPlanned.filter((pt) => !query || pt.customerName.toLowerCase().includes(query));
const visibleTabs =sort.apply(tabs.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)), {
customer: (tab) => tab.customerName,
email: (tab) => tab.email,
amount: (tab) => tab.totalInCents,
@@ -151,65 +161,25 @@ export default function TabsPage() {
<div>
<PageHeader
title={t('tabs.title')}
description={`${tn('tabs.count', tabs.length)} · ${t('tabs.outstanding', { amount: formatCents(outstanding) })}`}
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
description={[
tn('tabs.count', tabs.length),
t('tabs.outstanding', { amount: formatCents(outstanding) }),
...(pendingPlanned.length > 0 ? [tn('planned.pending_count', pendingPlanned.length)] : []),
].join(' · ')}
actions={
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>
{canManage && (
<>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}><Upload className="h-4 w-4 mr-2" />{t('import.button')}</Button>
<Button size="sm" onClick={openAddDialog}><Plus className="h-4 w-4 mr-2" />{t('planned.add')}</Button>
</>
)}
</div>
}
/>
{/* Planned Customers */}
<Card className="mb-6">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> {t('planned.title')}</CardTitle>
{canManage && (
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
</Button>
)}
</div>
</CardHeader>
<CardContent>
{canManage && (
<div className="flex gap-2 mb-3 flex-wrap">
<Input
placeholder={t('planned.name_placeholder')}
value={plannedName}
onChange={(e) => setPlannedName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Input
placeholder={t('planned.email_placeholder')}
type="email"
value={plannedEmail}
onChange={(e) => setPlannedEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()} aria-label={t('planned.add')}>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex flex-wrap gap-2">
{plannedQuery.data && pendingPlanned.length === 0 && (
<p className="text-sm text-muted-foreground">{t('planned.waiting_empty')}</p>
)}
{pendingPlanned.map((pt) => (
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{pt.customerName}
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
{canManage && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5" aria-label={t('planned.remove', { name: pt.customerName })}>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</CardContent>
</Card>
{tabs.length === 0 ? (
{tabs.length === 0 && pendingPlanned.length === 0 ? (
<EmptyState title={t('tabs.empty')} description={t('tabs.empty_desc')} />
) : (
<>
@@ -240,13 +210,52 @@ export default function TabsPage() {
</TableRow>
</TableHeader>
<TableBody>
{visibleTabs.length === 0 && (
{visibleTabs.length === 0 && visiblePlanned.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-6 text-center text-sm text-muted-foreground">
{t('tabs.no_matches', { query: search.trim() })}
</TableCell>
</TableRow>
)}
{/* Not on the terminal yet: listed first so new customers are easy to spot until the sync picks them up */}
{visiblePlanned.map((pt) => (
<TableRow key={pt.id} className="bg-blue-50/50 dark:bg-blue-950/20 text-muted-foreground">
<TableCell className="font-medium">
<span className="flex items-center gap-1 pl-5 italic">{pt.customerName}</span>
</TableCell>
<TableCell>
{pt.email ? (
<div className="flex items-center gap-1.5 text-sm">
<Mail className="h-3 w-3" />
<span className="truncate max-w-[200px]">{pt.email}</span>
</div>
) : (
<span>—</span>
)}
</TableCell>
<TableCell>—</TableCell>
<TableCell>—</TableCell>
<TableCell>
<Badge variant="info" title={t('planned.pending_hint')} className="gap-1 whitespace-nowrap">
<Clock className="h-3 w-3" />{t('planned.pending')}
</Badge>
</TableCell>
<TableCell className="text-right">
{canManage && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => removePlannedName(pt.id)}
title={t('planned.remove', { name: pt.customerName })}
aria-label={t('planned.remove', { name: pt.customerName })}
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
{visibleTabs.map((tab) => {
const expanded = expandedTabs.has(tab.tabId);
const toggle = () => setExpandedTabs((current) => {
@@ -325,6 +334,36 @@ export default function TabsPage() {
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
/>
<Dialog open={addOpen} onOpenChange={(open) => !adding && setAddOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('planned.add')}</DialogTitle>
<DialogDescription>{t('planned.add_desc')}</DialogDescription>
</DialogHeader>
<form
id="add-customer"
className="space-y-4 py-2"
onSubmit={(e) => {
e.preventDefault();
addPlannedName();
}}
>
<div className="space-y-2">
<Label htmlFor="planned-name">{t('planned.name')}</Label>
<Input id="planned-name" autoFocus placeholder={t('planned.name_placeholder')} value={plannedName} onChange={(e) => setPlannedName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="planned-email">{t('planned.email')}</Label>
<Input id="planned-email" type="email" placeholder={t('planned.email_placeholder')} value={plannedEmail} onChange={(e) => setPlannedEmail(e.target.value)} />
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={adding}>{t('common.cancel')}</Button>
<Button type="submit" form="add-customer" disabled={adding || !plannedName.trim()}>{t('planned.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTab !== null}
onOpenChange={(open) => !open && !deleteTabMutation.isPending && setDeleteTab(null)}
+16 -6
View File
@@ -133,12 +133,17 @@ const translations: Record<Lang, Record<string, string>> = {
'history.previous': 'Previous',
'history.next': 'Next',
'history.page': 'Page {page} of {total}',
'planned.title': 'Planned Customers',
'planned.name_placeholder': 'Add customer name...',
'planned.name_placeholder': 'Customer name',
'planned.email_placeholder': 'Email (optional)',
'planned.name': 'Name',
'planned.email': 'Email',
'planned.add': 'Add customer',
'planned.add_desc': 'The customer gets a tab on the terminal at its next sync.',
'planned.remove': 'Remove {name}',
'planned.waiting_empty': 'No customers waiting for the terminal.',
'planned.pending': 'Next sync',
'planned.pending_hint': 'Gets a tab on the terminal at its next sync',
'planned.pending_count': '{count} waiting for next sync',
'planned.pending_count_plural': '{count} waiting for next sync',
'planned.added': 'Added',
'planned.added_desc': '"{name}" will get a tab on the terminal',
'import.button': 'Import from Excel',
@@ -725,12 +730,17 @@ const translations: Record<Lang, Record<string, string>> = {
'history.previous': 'Vorige',
'history.next': 'Volgende',
'history.page': 'Pagina {page} van {total}',
'planned.title': 'Geplande klanten',
'planned.name_placeholder': 'Naam van de klant...',
'planned.name_placeholder': 'Naam van de klant',
'planned.email_placeholder': 'E-mail (optioneel)',
'planned.name': 'Naam',
'planned.email': 'E-mail',
'planned.add': 'Klant toevoegen',
'planned.add_desc': 'De klant krijgt een poef op de terminal bij de volgende synchronisatie.',
'planned.remove': '{name} verwijderen',
'planned.waiting_empty': 'Geen klanten die op de terminal wachten.',
'planned.pending': 'Volgende sync',
'planned.pending_hint': 'Krijgt een poef op de terminal bij de volgende synchronisatie',
'planned.pending_count': '{count} wacht op volgende sync',
'planned.pending_count_plural': '{count} wachten op volgende sync',
'planned.added': 'Toegevoegd',
'planned.added_desc': '"{name}" krijgt een poef op de terminal',
'import.button': 'Importeren uit Excel',