This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user