feat: fix dutch translation
This commit is contained in:
@@ -13,22 +13,23 @@ import { PaymentMethodBadge } from '@/components/payment-method-badge';
|
||||
import { EmptyState } from '@/components/empty-state';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
const statusFilters = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'conflict', label: 'Conflict' },
|
||||
{ value: 'expired', label: 'Expired' },
|
||||
{ value: '', label: 'commands.all' },
|
||||
{ value: 'pending', label: 'status.pending' },
|
||||
{ value: 'completed', label: 'status.completed' },
|
||||
{ value: 'failed', label: 'status.failed' },
|
||||
{ value: 'conflict', label: 'status.conflict' },
|
||||
{ value: 'expired', label: 'status.expired' },
|
||||
];
|
||||
|
||||
export default function CommandsPage() {
|
||||
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||
const { connected: live } = useRealtime();
|
||||
const { t, tn, relativeTime } = useLang();
|
||||
const router = useRouter();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
@@ -61,13 +62,13 @@ export default function CommandsPage() {
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={(error as any).message || 'Failed to load commands'} />;
|
||||
if (error) return <ErrorState message={(error as any).message || t('commands.load_failed')} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Command History"
|
||||
description={`${total} command${total !== 1 ? 's' : ''} total`}
|
||||
title={t('commands.title')}
|
||||
description={tn('commands.count', total)}
|
||||
actions={
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{statusFilters.map((f) => (
|
||||
@@ -77,7 +78,7 @@ export default function CommandsPage() {
|
||||
size="sm"
|
||||
onClick={() => { setStatusFilter(f.value); setPage(0); }}
|
||||
>
|
||||
{f.label}
|
||||
{t(f.label)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -85,20 +86,20 @@ export default function CommandsPage() {
|
||||
/>
|
||||
|
||||
{commands.length === 0 ? (
|
||||
<EmptyState title="No commands found" description="Commands will appear here when you create close requests." />
|
||||
<EmptyState title={t('commands.empty')} description={t('commands.empty_desc')} />
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border mb-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tab</TableHead>
|
||||
<TableHead>Requested By</TableHead>
|
||||
<TableHead>Payment</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Processed</TableHead>
|
||||
<TableHead>Result</TableHead>
|
||||
<TableHead>{t('commands.tab')}</TableHead>
|
||||
<TableHead>{t('commands.requested')}</TableHead>
|
||||
<TableHead>{t('commands.payment')}</TableHead>
|
||||
<TableHead>{t('commands.created')}</TableHead>
|
||||
<TableHead>{t('commands.status')}</TableHead>
|
||||
<TableHead>{t('commands.processed')}</TableHead>
|
||||
<TableHead>{t('commands.result')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -107,9 +108,9 @@ export default function CommandsPage() {
|
||||
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
|
||||
<TableCell>{cmd.requestedBy}</TableCell>
|
||||
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
|
||||
<TableCell>{formatRelativeTime(cmd.createdAt)}</TableCell>
|
||||
<TableCell>{relativeTime(cmd.createdAt)}</TableCell>
|
||||
<TableCell><CommandStatusBadge status={cmd.status} /></TableCell>
|
||||
<TableCell>{cmd.processedAt ? formatRelativeTime(cmd.processedAt) : '-'}</TableCell>
|
||||
<TableCell>{cmd.processedAt ? relativeTime(cmd.processedAt) : '-'}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{cmd.result || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -120,7 +121,7 @@ export default function CommandsPage() {
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page + 1} of {totalPages}
|
||||
{t('commands.page', { page: page + 1, total: totalPages })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -129,7 +130,7 @@ export default function CommandsPage() {
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Previous
|
||||
{t('commands.previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -137,7 +138,7 @@ export default function CommandsPage() {
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Next
|
||||
{t('commands.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function DashboardLayout({
|
||||
|
||||
function handleLogout() {
|
||||
authApi.logout();
|
||||
toast({ title: 'Logged out', variant: 'default' });
|
||||
toast({ title: t('common.logged_out'), variant: 'default' });
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { CommandStatusBadge } from '@/components/command-status-badge';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
export default function DashboardPage() {
|
||||
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||
const { connected: live } = useRealtime();
|
||||
const { t, relativeTime } = useLang();
|
||||
const router = useRouter();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
|
||||
@@ -64,7 +65,7 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Overview" description="" />
|
||||
<PageHeader title={t('overview.title')} description="" />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||
@@ -76,24 +77,24 @@ export default function DashboardPage() {
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Open Tabs</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('overview.open_tabs')}</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Device</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('overview.device')}</CardTitle>
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{device?.name || 'Not connected'}</div>
|
||||
{device && <p className="text-xs text-muted-foreground mt-1">Last seen {formatRelativeTime(device.lastSeenAt)}</p>}
|
||||
<div className="text-2xl font-bold">{device?.name || t('overview.not_connected')}</div>
|
||||
{device && <p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Pending Commands</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('overview.pending')}</CardTitle>
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{pendingCommands.length}</div></CardContent>
|
||||
@@ -102,10 +103,10 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Recent Commands</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle className="text-lg">{t('overview.recent')}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{commands.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No commands yet</p>
|
||||
<p className="text-sm text-muted-foreground">{t('overview.no_commands')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{commands.map((cmd: any) => (
|
||||
@@ -116,7 +117,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandStatusBadge status={cmd.status} />
|
||||
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
|
||||
<span className="text-muted-foreground">{relativeTime(cmd.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -14,14 +14,14 @@ import { EmptyState } from '@/components/empty-state';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
||||
import { formatCents } from '@/lib/utils';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { Send, History } from 'lucide-react';
|
||||
|
||||
export default function RemindersPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useLang();
|
||||
const { t, relativeTime } = useLang();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
@@ -51,7 +51,7 @@ export default function RemindersPage() {
|
||||
setSendDialogOpen(false);
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
const firstError = result.errors[0];
|
||||
toast({ title: `${result.sent}/${result.total} sent`, description: `Error: ${firstError.error}`, variant: 'destructive' });
|
||||
toast({ title: t('reminders.partial', { sent: result.sent, total: result.total }), description: t('reminders.error_detail', { error: firstError.error }), variant: 'destructive' });
|
||||
} else if (result.sent > 0) {
|
||||
toast({ title: t('reminders.sent_title'), description: t('reminders.success', { sent: result.sent }), variant: 'success' });
|
||||
} else {
|
||||
@@ -61,7 +61,7 @@ export default function RemindersPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
|
||||
toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,8 +111,8 @@ export default function RemindersPage() {
|
||||
<TableCell className="font-medium">{r.customerName}</TableCell>
|
||||
<TableCell>{r.email}</TableCell>
|
||||
<TableCell>{formatCents(r.amountInCents)}</TableCell>
|
||||
<TableCell>{formatRelativeTime(r.sentAt)}</TableCell>
|
||||
<TableCell><Badge variant="success">{r.status}</Badge></TableCell>
|
||||
<TableCell>{relativeTime(r.sentAt)}</TableCell>
|
||||
<TableCell><Badge variant="success">{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -135,7 +135,7 @@ export default function RemindersPage() {
|
||||
) : candidatesQuery.error ? (
|
||||
<p className="text-sm text-destructive">{t('common.error')}</p>
|
||||
) : candidates.length === 0 ? (
|
||||
<EmptyState title={t('reminders.no_candidates')} description='' />
|
||||
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border mb-4">
|
||||
@@ -176,12 +176,12 @@ export default function RemindersPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('reminders.send')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('reminders.result', { sent: 0, total: candidates.length })}
|
||||
{t('reminders.candidates_desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will send payment reminder emails to <strong>{candidates.length}</strong> customer(s) with open tabs.
|
||||
{t('reminders.confirm', { count: candidates.length })}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{candidates.map((c: any) => (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { Monitor, Copy, Plus } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -19,6 +19,7 @@ export default function SettingsPage() {
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const { t, relativeTime, dateTime, time } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||
@@ -50,7 +51,7 @@ export default function SettingsPage() {
|
||||
const result = await devicesApi.generateCode();
|
||||
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
|
||||
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -59,50 +60,50 @@ export default function SettingsPage() {
|
||||
function copyCode() {
|
||||
if (regDialog) {
|
||||
navigator.clipboard.writeText(regDialog.code);
|
||||
toast({ title: 'Copied', description: 'Registration code copied to clipboard' });
|
||||
toast({ title: t('common.copied'), description: t('settings.copied_desc') });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Settings" description="Venue and device management" />
|
||||
<PageHeader title={t('settings.title')} description={t('settings.desc')} />
|
||||
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Venue</CardTitle>
|
||||
<CardTitle>{t('settings.venue')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<p className="font-medium">{venue?.name || 'Loading...'}</p>
|
||||
<p className="font-medium">{venue?.name || t('common.loading')}</p>
|
||||
<p className="text-muted-foreground font-mono text-xs mt-1">ID: {venueId}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Device</CardTitle>
|
||||
<CardTitle>{t('settings.device')}</CardTitle>
|
||||
<CardDescription>
|
||||
{device
|
||||
? `Paired as "${device.name}" — last seen ${formatRelativeTime(device.lastSeenAt)}`
|
||||
: 'No device paired yet'}
|
||||
? t('settings.paired', { name: device.name, time: relativeTime(device.lastSeenAt) })
|
||||
: t('settings.not_paired')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{device ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Monitor className="h-4 w-4 text-green-600" />
|
||||
<Badge variant="success">Connected</Badge>
|
||||
<span className="text-muted-foreground">Since {formatDateTime(device.createdAt)}</span>
|
||||
<Badge variant="success">{t('settings.connected')}</Badge>
|
||||
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Pair a bar terminal to start receiving tabs.
|
||||
{t('settings.pair_desc')}
|
||||
</p>
|
||||
)}
|
||||
{userRole === 'admin' && (
|
||||
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{generating ? 'Generating...' : device ? 'Re-pair Device' : 'Pair Device'}
|
||||
{generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -114,35 +115,35 @@ export default function SettingsPage() {
|
||||
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pair a Device</DialogTitle>
|
||||
<DialogTitle>{t('settings.pair_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter this code on the bar terminal to connect it to your venue.
|
||||
{t('settings.pair_instructions')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-6 space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">Registration Code</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">{t('settings.pair_code')}</p>
|
||||
<div className="text-4xl font-bold tracking-widest text-primary bg-primary/5 py-4 rounded-lg">
|
||||
{regDialog.code}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()}
|
||||
{t('settings.pair_expires', { time: time(regDialog.expiresAt) })}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium">Instructions:</p>
|
||||
<p className="font-medium">{t('settings.pair_steps')}</p>
|
||||
<ol className="list-decimal list-inside text-muted-foreground space-y-1">
|
||||
<li>Open the bar terminal app</li>
|
||||
<li>Select your venue from the list</li>
|
||||
<li>Enter this registration code</li>
|
||||
<li>Give the terminal a name</li>
|
||||
<li>Complete registration</li>
|
||||
<li>{t('settings.pair_step1')}</li>
|
||||
<li>{t('settings.pair_step2')}</li>
|
||||
<li>{t('settings.pair_step3')}</li>
|
||||
<li>{t('settings.pair_step4')}</li>
|
||||
<li>{t('settings.pair_step5')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setRegDialog(null)}>Close</Button>
|
||||
<Button onClick={copyCode}><Copy className="h-4 w-4 mr-2" />Copy Code</Button>
|
||||
<Button variant="outline" onClick={() => setRegDialog(null)}>{t('common.close')}</Button>
|
||||
<Button onClick={copyCode}><Copy className="h-4 w-4 mr-2" />{t('settings.copy')}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { EmptyState } from '@/components/empty-state';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
||||
import { formatCents } from '@/lib/utils';
|
||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
@@ -37,7 +37,7 @@ export default function TabsPage() {
|
||||
const [plannedEmail, setPlannedEmail] = useState('');
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const { t } = useLang();
|
||||
const { t, tn, relativeTime } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||
@@ -78,24 +78,25 @@ export default function TabsPage() {
|
||||
const newSet = new Set(pendingCommandIds);
|
||||
newSet.delete(cmd.id);
|
||||
setPendingCommandIds(newSet);
|
||||
toast({ title: `Command ${cmd.status}`, description: `Close request for tab ${cmd.tabId?.substring(0, 8)} is now ${cmd.status}`, variant: cmd.status === 'completed' ? 'success' : 'destructive' });
|
||||
const customerName = data?.tabs?.find((tab: any) => tab.tabId === cmd.tabId)?.customerName ?? cmd.tabId?.substring(0, 8);
|
||||
toast({ title: t(`status.${cmd.status}`), description: t('tabs.request_result', { name: customerName, status: t(`status.${cmd.status}`) }), variant: cmd.status === 'completed' ? 'success' : 'destructive' });
|
||||
queryClient.invalidateQueries({ queryKey: ['tabs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [commandsQuery.data, pendingCommandIds, queryClient]);
|
||||
}, [commandsQuery.data, pendingCommandIds, queryClient, data, t]);
|
||||
|
||||
const createCommandMutation = useMutation({
|
||||
mutationFn: (data: { tabId: string; expectedVersion: number; paymentMethod: string }) =>
|
||||
commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
|
||||
onSuccess: (result) => {
|
||||
setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
|
||||
toast({ title: 'Close request sent', description: result.warning || 'Command sent to the terminal', variant: result.warning ? 'default' : 'success' });
|
||||
toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' });
|
||||
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
|
||||
toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,9 +115,9 @@ export default function TabsPage() {
|
||||
setPlannedName('');
|
||||
setPlannedEmail('');
|
||||
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
|
||||
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
|
||||
toast({ title: t('planned.added'), description: t('planned.added_desc', { name: plannedName.trim() }) });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
|
||||
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,39 +126,39 @@ export default function TabsPage() {
|
||||
await plannedTabsApi.remove(venueId!, id);
|
||||
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
|
||||
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId),
|
||||
onSuccess: (result) => {
|
||||
toast({ title: 'Test email sent', description: `Sent to ${result.email}`, variant: 'success' });
|
||||
toast({ title: t('tabs.test_sent'), description: t('tabs.test_sent_desc', { email: result.email }), variant: 'success' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Test email failed', description: err.message || 'An error occurred', variant: 'destructive' });
|
||||
toast({ title: t('tabs.test_failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={(error as any).message || 'Failed to load tabs'} />;
|
||||
if (error) return <ErrorState message={(error as any).message || t('tabs.load_failed')} />;
|
||||
|
||||
const tabs = data?.tabs || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Open Tabs"
|
||||
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''}`}
|
||||
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />Refresh</Button>}
|
||||
title={t('tabs.title')}
|
||||
description={tn('tabs.count', tabs.length)}
|
||||
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
|
||||
/>
|
||||
|
||||
{/* 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" /> Planned Customers</CardTitle>
|
||||
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> {t('planned.title')}</CardTitle>
|
||||
{userRole === 'admin' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
|
||||
@@ -169,28 +170,28 @@ export default function TabsPage() {
|
||||
{userRole === 'admin' && (
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input
|
||||
placeholder="Add customer name..."
|
||||
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="Email (optional)"
|
||||
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()}>
|
||||
<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">No customers waiting for the terminal.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('planned.waiting_empty')}</p>
|
||||
)}
|
||||
{pendingPlanned.map((pt: any) => (
|
||||
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
|
||||
@@ -201,13 +202,14 @@ export default function TabsPage() {
|
||||
onClick={() => sendTestMutation.mutate(pt.id)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="text-muted-foreground hover:text-primary ml-0.5"
|
||||
title="Send test email"
|
||||
title={t('tabs.send_test')}
|
||||
aria-label={t('tabs.send_test')}
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{userRole === 'admin' && (
|
||||
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5">
|
||||
<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>
|
||||
)}
|
||||
@@ -218,19 +220,19 @@ export default function TabsPage() {
|
||||
</Card>
|
||||
|
||||
{tabs.length === 0 ? (
|
||||
<EmptyState title="No open tabs" description="" />
|
||||
<EmptyState title={t('tabs.empty')} description={t('tabs.empty_desc')} />
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Items</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Last Reported</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead>{t('tabs.customer')}</TableHead>
|
||||
<TableHead>{t('tabs.email')}</TableHead>
|
||||
<TableHead>{t('tabs.amount')}</TableHead>
|
||||
<TableHead>{t('tabs.items')}</TableHead>
|
||||
<TableHead>{t('tabs.version')}</TableHead>
|
||||
<TableHead>{t('tabs.reported')}</TableHead>
|
||||
<TableHead className="text-right">{t('tabs.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -245,7 +247,7 @@ export default function TabsPage() {
|
||||
<Fragment key={tab.tabId}>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">
|
||||
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={`${expanded ? 'Hide' : 'Show'} items for ${tab.customerName}`}>
|
||||
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={t(expanded ? 'tabs.hide_items' : 'tabs.show_items', { name: tab.customerName })}>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
{tab.customerName}
|
||||
</button>
|
||||
@@ -260,7 +262,8 @@ export default function TabsPage() {
|
||||
onClick={() => sendTestMutation.mutate(tab.plannedTabId)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="hover:text-primary"
|
||||
title="Send test email"
|
||||
title={t('tabs.send_test')}
|
||||
aria-label={t('tabs.send_test')}
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -273,10 +276,10 @@ export default function TabsPage() {
|
||||
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
|
||||
<TableCell>{tab.itemCount}</TableCell>
|
||||
<TableCell>{tab.version}</TableCell>
|
||||
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
|
||||
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{/* Closing settles the tab on the terminal; a tab without a balance has nothing to settle */}
|
||||
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
|
||||
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>{t('tabs.close')}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
@@ -310,27 +313,27 @@ export default function TabsPage() {
|
||||
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle>
|
||||
<DialogDescription>Send a close request to the terminal.</DialogDescription>
|
||||
<DialogTitle>{t('tabs.close_title', { name: closeDialog.tab.customerName })}</DialogTitle>
|
||||
<DialogDescription>{t('tabs.close_desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.amount')}</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.items')}</span><span>{closeDialog.tab.itemCount}</span></div>
|
||||
<div className="rounded-md border p-3"><TabLines items={closeDialog.tab.items} /></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.version')}</span><span>{closeDialog.tab.version}</span></div>
|
||||
<div className="space-y-3 pt-2">
|
||||
<Label className="text-base font-semibold">Payment method</Label>
|
||||
<Label className="text-base font-semibold">{t('tabs.payment')}</Label>
|
||||
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
<div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">Cash</Label></div>
|
||||
<div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">Payconiq</Label></div>
|
||||
<div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">Payment pending</Label></div>
|
||||
<div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">{t('payment.cash')}</Label></div>
|
||||
<div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">{t('payment.payconiq')}</Label></div>
|
||||
<div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">{t('payment.payment_pending')}</Label></div>
|
||||
</RadioGroup>
|
||||
{!paymentMethod && <p className="text-xs text-destructive">Please select a payment method</p>}
|
||||
{!paymentMethod && <p className="text-xs text-destructive">{t('tabs.select_payment')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>Cancel</Button>
|
||||
<Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? 'Sending...' : 'Send close request'}</Button>
|
||||
<Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>{t('common.cancel')}</Button>
|
||||
<Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? t('tabs.sending') : t('tabs.send')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -343,11 +346,12 @@ type TabLine = { productName: string; quantity: number; unitPriceInCents: number
|
||||
|
||||
/** 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">Items aren't reported by this terminal yet. Update the app on the tablet to see them.</p>;
|
||||
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">No items on this tab.</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1 text-sm">
|
||||
@@ -355,7 +359,7 @@ function TabLines({ items }: { items: TabLine[] | null | undefined }) {
|
||||
<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)} each</span>
|
||||
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
|
||||
</li>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { EmptyState } from '@/components/empty-state';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { RefreshCw, Plus } from 'lucide-react';
|
||||
|
||||
export default function UsersPage() {
|
||||
@@ -27,6 +27,7 @@ export default function UsersPage() {
|
||||
const [addDialog, setAddDialog] = useState(false);
|
||||
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { t, tn, relativeTime } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie
|
||||
@@ -51,12 +52,12 @@ export default function UsersPage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await usersApi.create(venueId!, form);
|
||||
toast({ title: 'User created', description: `${form.email} added as ${form.role}`, variant: 'success' });
|
||||
toast({ title: t('users.created_toast'), description: t('users.created_toast_desc', { email: form.email, role: t(`users.role.${form.role}`) }), variant: 'success' });
|
||||
setAddDialog(false);
|
||||
setForm({ email: '', password: '', name: '', role: 'viewer' });
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Failed to create user', description: err.message, variant: 'destructive' });
|
||||
toast({ title: t('users.create_failed'), description: err.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -64,21 +65,21 @@ export default function UsersPage() {
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={(error as any).message || 'Failed to load users'} />;
|
||||
if (error) return <ErrorState message={(error as any).message || t('users.load_failed')} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description={`${users.length} user${users.length !== 1 ? 's' : ''} registered`}
|
||||
title={t('users.title')}
|
||||
description={tn('users.count', users.length)}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
{userRole === 'admin' && (
|
||||
<Button size="sm" onClick={() => setAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Add User
|
||||
<Plus className="h-4 w-4 mr-2" /> {t('users.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -86,16 +87,16 @@ export default function UsersPage() {
|
||||
/>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<EmptyState title="No users" description="Add users who can manage tabs from this dashboard." />
|
||||
<EmptyState title={t('users.empty')} description={t('users.empty_desc')} />
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>{t('users.name')}</TableHead>
|
||||
<TableHead>{t('users.email')}</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -103,8 +104,8 @@ export default function UsersPage() {
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{u.role}</Badge></TableCell>
|
||||
<TableCell>{formatRelativeTime(u.createdAt)}</TableCell>
|
||||
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
|
||||
<TableCell>{relativeTime(u.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -115,37 +116,37 @@ export default function UsersPage() {
|
||||
<Dialog open={addDialog} onOpenChange={setAddDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
<DialogDescription>Create a new user who can access the dashboard.</DialogDescription>
|
||||
<DialogTitle>{t('users.add_title')}</DialogTitle>
|
||||
<DialogDescription>{t('users.add_desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Bartender name" />
|
||||
<Label htmlFor="name">{t('users.name')}</Label>
|
||||
<Input id="name" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder={t('users.name_placeholder')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('users.email')}</Label>
|
||||
<Input id="email" type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder="Min 6 characters" />
|
||||
<Label htmlFor="password">{t('users.password')}</Label>
|
||||
<Input id="password" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder={t('users.password_placeholder')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Label htmlFor="role">{t('users.role')}</Label>
|
||||
<Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">Viewer (can close tabs)</SelectItem>
|
||||
<SelectItem value="admin">Admin (full access)</SelectItem>
|
||||
<SelectItem value="viewer">{t('users.role_viewer')}</SelectItem>
|
||||
<SelectItem value="admin">{t('users.role_admin')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleCreateUser} disabled={submitting || !form.email || !form.password || !form.name}>
|
||||
{submitting ? 'Creating...' : 'Create User'}
|
||||
{submitting ? t('users.creating') : t('users.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Providers } from './providers';
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'KoolTap - Remote Tab Control',
|
||||
title: 'KoolTap',
|
||||
description: 'Manage tabs remotely across your venue',
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LanguageSelector } from '@/components/language-selector';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,6 +16,7 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLang();
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -25,7 +28,7 @@ export default function LoginPage() {
|
||||
setTokens(result.accessToken, result.refreshToken);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Invalid email or password');
|
||||
setError(err.message || t('auth.invalid'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -40,7 +43,7 @@ export default function LoginPage() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('auth.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
@@ -51,7 +54,7 @@ export default function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -67,9 +70,12 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{loading ? t('auth.logging_in') : t('auth.login')}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
'use client';
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'warning' | 'success' | 'destructive' | 'secondary' | 'info' }> = {
|
||||
pending: { label: 'Pending', variant: 'warning' },
|
||||
completed: { label: 'Completed', variant: 'success' },
|
||||
failed: { label: 'Failed', variant: 'destructive' },
|
||||
conflict: { label: 'Conflict', variant: 'destructive' },
|
||||
expired: { label: 'Expired', variant: 'secondary' },
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
const statusVariants: Record<string, 'default' | 'warning' | 'success' | 'destructive' | 'secondary' | 'info'> = {
|
||||
pending: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'destructive',
|
||||
conflict: 'destructive',
|
||||
expired: 'secondary',
|
||||
};
|
||||
|
||||
export function CommandStatusBadge({ status }: { status: string }) {
|
||||
const config = statusConfig[status] || { label: status, variant: 'default' as const };
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
}
|
||||
const { t } = useLang();
|
||||
const variant = statusVariants[status];
|
||||
return <Badge variant={variant ?? 'default'}>{variant ? t(`status.${status}`) : status}</Badge>;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getDeviceStatus, DeviceStatus } from '@/lib/device-status';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
const statusConfig: Record<DeviceStatus, { label: string; variant: 'success' | 'warning' | 'destructive' | 'secondary' }> = {
|
||||
online: { label: 'Online', variant: 'success' },
|
||||
recently_seen: { label: 'Recently seen', variant: 'secondary' },
|
||||
stale: { label: 'Stale', variant: 'warning' },
|
||||
offline: { label: 'Offline', variant: 'destructive' },
|
||||
revoked: { label: 'Revoked', variant: 'destructive' },
|
||||
const statusVariants: Record<DeviceStatus, 'success' | 'warning' | 'destructive' | 'secondary'> = {
|
||||
online: 'success',
|
||||
recently_seen: 'secondary',
|
||||
stale: 'warning',
|
||||
offline: 'destructive',
|
||||
revoked: 'destructive',
|
||||
};
|
||||
|
||||
export function DeviceStatusBadge({
|
||||
@@ -16,7 +19,7 @@ export function DeviceStatusBadge({
|
||||
lastSeenAt: string | Date;
|
||||
revokedAt?: string | Date | null;
|
||||
}) {
|
||||
const { t } = useLang();
|
||||
const status = getDeviceStatus(lastSeenAt, revokedAt);
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
}
|
||||
return <Badge variant={statusVariants[status]}>{t(`device_status.${status}`)}</Badge>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
const { t } = useLang();
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-destructive">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<h3 className="text-lg font-semibold mb-1">Something went wrong</h3>
|
||||
<h3 className="text-lg font-semibold mb-1">{t('common.error')}</h3>
|
||||
<p className="text-sm">{message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -206,7 +206,7 @@ export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
|
||||
{t('tabs.cancel')}
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}>
|
||||
{submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
'use client';
|
||||
|
||||
const methodLabels: Record<string, string> = {
|
||||
cash: 'Cash',
|
||||
payconiq: 'Payconiq',
|
||||
payment_pending: 'Payment pending',
|
||||
};
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
const KNOWN_METHODS = ['cash', 'payconiq', 'payment_pending'];
|
||||
|
||||
export function PaymentMethodBadge({ method }: { method: string }) {
|
||||
const label = methodLabels[method] || method;
|
||||
return <Badge variant="outline">{label}</Badge>;
|
||||
}
|
||||
const { t } = useLang();
|
||||
return <Badge variant="outline">{KNOWN_METHODS.includes(method) ? t(`payment.${method}`) : method}</Badge>;
|
||||
}
|
||||
|
||||
+257
-124
@@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react
|
||||
|
||||
type Lang = 'en' | 'nl';
|
||||
|
||||
// Dutch wording follows the bar app: a tab is a "poef", informal "je"
|
||||
const translations: Record<Lang, Record<string, string>> = {
|
||||
en: {
|
||||
'nav.overview': 'Overview',
|
||||
@@ -13,31 +14,72 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'nav.logout': 'Logout',
|
||||
'realtime.live': 'Live updates on',
|
||||
'realtime.offline': 'Live updates unavailable — refreshing periodically',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Something went wrong',
|
||||
'common.unknown_error': 'An error occurred',
|
||||
'common.failed': 'Failed',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.copied': 'Copied',
|
||||
'common.logged_out': 'Logged out',
|
||||
'time.just_now': 'just now',
|
||||
'time.minutes_ago': '{count}m ago',
|
||||
'time.hours_ago': '{count}h ago',
|
||||
'time.days_ago': '{count}d ago',
|
||||
'status.pending': 'Pending',
|
||||
'status.completed': 'Completed',
|
||||
'status.failed': 'Failed',
|
||||
'status.conflict': 'Conflict',
|
||||
'status.expired': 'Expired',
|
||||
'device_status.online': 'Online',
|
||||
'device_status.recently_seen': 'Recently seen',
|
||||
'device_status.stale': 'Stale',
|
||||
'device_status.offline': 'Offline',
|
||||
'device_status.revoked': 'Revoked',
|
||||
'payment.cash': 'Cash',
|
||||
'payment.payconiq': 'Payconiq',
|
||||
'payment.payment_pending': 'Payment pending',
|
||||
'tabs.title': 'Open Tabs',
|
||||
'tabs.count': '{count} open tab',
|
||||
'tabs.count_plural': '{count} open tabs',
|
||||
'tabs.empty': 'No open tabs',
|
||||
'tabs.empty_desc': 'Open tabs will appear here when the terminal syncs.',
|
||||
'tabs.empty_desc': 'Open tabs appear here when the terminal syncs.',
|
||||
'tabs.load_failed': 'Failed to load tabs',
|
||||
'tabs.customer': 'Customer',
|
||||
'tabs.email': 'Email',
|
||||
'tabs.amount': 'Amount',
|
||||
'tabs.items': 'Items',
|
||||
'tabs.version': 'Version',
|
||||
'tabs.reported': 'Last Reported',
|
||||
'tabs.reported': 'Last updated',
|
||||
'tabs.actions': 'Actions',
|
||||
'tabs.close': 'Close',
|
||||
'tabs.show_items': 'Show items for {name}',
|
||||
'tabs.hide_items': 'Hide items for {name}',
|
||||
'tabs.lines_unknown': "Items aren't reported by this terminal yet. Update the app on the tablet to see them.",
|
||||
'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.payment': 'Payment method',
|
||||
'tabs.cash': 'Cash',
|
||||
'tabs.payconiq': 'Payconiq',
|
||||
'tabs.pending_payment': 'Payment pending',
|
||||
'tabs.select_payment': 'Please select a payment method',
|
||||
'tabs.send': 'Send close request',
|
||||
'tabs.sending': 'Sending...',
|
||||
'tabs.cancel': 'Cancel',
|
||||
'tabs.planned_title': 'Planned Customers',
|
||||
'tabs.planned_desc': 'These names are synced to the terminal. It will create tabs for these customers automatically.',
|
||||
'tabs.planned_add': 'Add customer name...',
|
||||
'tabs.planned_empty': 'No planned customers. Add names above and they\'ll sync to the terminal.',
|
||||
'tabs.request_sent': 'Close request sent',
|
||||
'tabs.request_sent_desc': 'Sent to the terminal',
|
||||
'tabs.request_result': 'Close request for {name}: {status}',
|
||||
'tabs.send_test': 'Send test email',
|
||||
'tabs.test_sent': 'Test email sent',
|
||||
'tabs.test_sent_desc': 'Sent to {email}',
|
||||
'tabs.test_failed': 'Test email failed',
|
||||
'planned.title': 'Planned Customers',
|
||||
'planned.name_placeholder': 'Add customer name...',
|
||||
'planned.email_placeholder': 'Email (optional)',
|
||||
'planned.add': 'Add customer',
|
||||
'planned.remove': 'Remove {name}',
|
||||
'planned.waiting_empty': 'No customers waiting for the terminal.',
|
||||
'planned.added': 'Added',
|
||||
'planned.added_desc': '"{name}" will get a tab on the terminal',
|
||||
'import.button': 'Import from Excel',
|
||||
'import.title': 'Import customers',
|
||||
'import.description': 'Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.',
|
||||
@@ -64,6 +106,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'users.title': 'Users',
|
||||
'users.count': '{count} user',
|
||||
'users.count_plural': '{count} users',
|
||||
'users.load_failed': 'Failed to load users',
|
||||
'users.empty': 'No users',
|
||||
'users.empty_desc': 'Add users who can manage tabs from this dashboard.',
|
||||
'users.add': 'Add User',
|
||||
@@ -73,13 +116,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'users.created': 'Created',
|
||||
'users.add_title': 'Add User',
|
||||
'users.add_desc': 'Create a new user who can access the dashboard.',
|
||||
'users.name_placeholder': 'Bartender name',
|
||||
'users.password': 'Password',
|
||||
'users.password_placeholder': 'At least 6 characters',
|
||||
'users.role_viewer': 'Viewer (can close tabs)',
|
||||
'users.role_admin': 'Admin (full access)',
|
||||
'users.role.viewer': 'Viewer',
|
||||
'users.role.admin': 'Admin',
|
||||
'users.creating': 'Creating...',
|
||||
'users.create': 'Create User',
|
||||
'users.created_toast': 'User created',
|
||||
'users.created_toast_desc': '{email} added as {role}',
|
||||
'users.create_failed': 'Failed to create user',
|
||||
'commands.title': 'Command History',
|
||||
'commands.count': '{count} command',
|
||||
'commands.count_plural': '{count} commands',
|
||||
'commands.load_failed': 'Failed to load commands',
|
||||
'commands.empty': 'No commands found',
|
||||
'commands.empty_desc': 'Commands appear here when you send close requests.',
|
||||
'commands.tab': 'Tab',
|
||||
'commands.requested': 'Requested By',
|
||||
'commands.payment': 'Payment',
|
||||
@@ -92,46 +146,37 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'commands.next': 'Next',
|
||||
'commands.page': 'Page {page} of {total}',
|
||||
'settings.title': 'Settings',
|
||||
'settings.desc': 'Venue and device management',
|
||||
'settings.desc': 'Venue and terminal',
|
||||
'settings.venue': 'Venue',
|
||||
'settings.device': 'Device',
|
||||
'settings.device': 'Terminal',
|
||||
'settings.paired': 'Paired as "{name}" — last seen {time}',
|
||||
'settings.not_paired': 'No device paired yet',
|
||||
'settings.not_paired': 'No terminal paired yet',
|
||||
'settings.connected': 'Connected',
|
||||
'settings.since': 'Since {date}',
|
||||
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
|
||||
'settings.pair': 'Pair Device',
|
||||
'settings.repair': 'Re-pair Device',
|
||||
'settings.pair_title': 'Pair a Device',
|
||||
'settings.pair': 'Pair Terminal',
|
||||
'settings.repair': 'Re-pair Terminal',
|
||||
'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.',
|
||||
'settings.pair_code': 'Registration Code',
|
||||
'settings.pair_expires': 'Expires at {time}',
|
||||
'settings.pair_steps': 'Instructions:',
|
||||
'settings.pair_step1': 'Open the bar terminal app',
|
||||
'settings.pair_step2': 'Select your venue from the list',
|
||||
'settings.pair_step3': 'Enter this registration code',
|
||||
'settings.pair_step4': 'Give the terminal a name',
|
||||
'settings.pair_step5': 'Complete registration',
|
||||
'settings.copy': 'Copy Code',
|
||||
'settings.close': 'Close',
|
||||
'settings.api': 'API',
|
||||
'settings.base_url': 'Base URL',
|
||||
'settings.swagger': 'Swagger',
|
||||
'settings.copied_desc': 'Registration code copied to clipboard',
|
||||
'overview.title': 'Overview',
|
||||
'overview.desc': 'Real-time overview of your venue',
|
||||
'overview.open_tabs': 'Open Tabs',
|
||||
'overview.device': 'Device',
|
||||
'overview.device': 'Terminal',
|
||||
'overview.pending': 'Pending Commands',
|
||||
'overview.recent': 'Recent Commands',
|
||||
'overview.no_commands': 'No commands yet',
|
||||
'overview.not_connected': 'Not connected',
|
||||
'overview.last_seen': 'Last seen {time}',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Something went wrong',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.processing': 'Processing...',
|
||||
'common.copied': 'Copied',
|
||||
'auth.login': 'Sign in',
|
||||
'auth.logging_in': 'Signing in...',
|
||||
'auth.email': 'Email',
|
||||
@@ -141,15 +186,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'lang.nl': 'Nederlands',
|
||||
'lang.label': 'Language',
|
||||
'reminders.title': 'Payment Reminders',
|
||||
'reminders.desc': 'Send payment reminders to customers with open tabs',
|
||||
'reminders.candidates': 'Eligible for reminder',
|
||||
'reminders.candidates_desc': 'These customers have open tabs and an email address on file. Reminders will be sent via Resend.',
|
||||
'reminders.desc': 'Send payment reminders to customers with an unpaid tab',
|
||||
'reminders.candidates': 'Eligible for a reminder',
|
||||
'reminders.candidates_desc': 'These customers have an unpaid tab and an email address on file. Reminders are sent via Resend.',
|
||||
'reminders.no_candidates': 'No eligible customers',
|
||||
'reminders.no_candidates_desc': 'Make sure planned customers have email addresses set.',
|
||||
'reminders.no_candidates_desc': 'Make sure planned customers have an email address.',
|
||||
'reminders.send': 'Send Reminders',
|
||||
'reminders.sending': 'Sending...',
|
||||
'reminders.result': 'Sent {sent} of {total} reminder(s)',
|
||||
'reminders.confirm': 'This emails a payment reminder to {count} customer(s) with an unpaid tab.',
|
||||
'reminders.sent_title': 'Reminders Sent',
|
||||
'reminders.partial': '{sent} of {total} sent',
|
||||
'reminders.error_detail': 'Error: {error}',
|
||||
'reminders.zero_sent': 'No reminders were sent. Check that Resend is configured.',
|
||||
'reminders.success': 'Successfully sent {sent} reminder email(s)',
|
||||
'reminders.history': 'Sent Reminders',
|
||||
@@ -159,47 +206,87 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'reminders.amount': 'Amount',
|
||||
'reminders.sent_at': 'Sent At',
|
||||
'reminders.status': 'Status',
|
||||
'tabs.email': 'Email (optional)',
|
||||
'tabs.planned_email_hint': 'Add email to enable payment reminders',
|
||||
'reminders.status_sent': 'Sent',
|
||||
},
|
||||
nl: {
|
||||
'nav.overview': 'Overzicht',
|
||||
'nav.tabs': 'Open Tabs',
|
||||
'nav.tabs': 'Open poefs',
|
||||
'nav.reminders': 'Herinneringen',
|
||||
'nav.users': 'Gebruikers',
|
||||
'nav.commands': 'Opdrachten',
|
||||
'nav.settings': 'Instellingen',
|
||||
'nav.logout': 'Uitloggen',
|
||||
'realtime.live': 'Live updates aan',
|
||||
'realtime.offline': 'Live updates niet beschikbaar — wordt periodiek vernieuwd',
|
||||
'tabs.title': 'Open Tabs',
|
||||
'tabs.count': '{count} open tab',
|
||||
'tabs.count_plural': '{count} open tabs',
|
||||
'tabs.empty': 'Geen open tabs',
|
||||
'tabs.empty_desc': 'Open tabs verschijnen hier wanneer de terminal synchroniseert.',
|
||||
'realtime.offline': 'Live updates niet beschikbaar — wordt regelmatig vernieuwd',
|
||||
'common.refresh': 'Vernieuwen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.error': 'Er is iets misgegaan',
|
||||
'common.unknown_error': 'Er is een fout opgetreden',
|
||||
'common.failed': 'Mislukt',
|
||||
'common.cancel': 'Annuleren',
|
||||
'common.close': 'Sluiten',
|
||||
'common.copied': 'Gekopieerd',
|
||||
'common.logged_out': 'Uitgelogd',
|
||||
'time.just_now': 'zonet',
|
||||
'time.minutes_ago': '{count} min geleden',
|
||||
'time.hours_ago': '{count} u geleden',
|
||||
'time.days_ago': '{count} d geleden',
|
||||
'status.pending': 'In behandeling',
|
||||
'status.completed': 'Voltooid',
|
||||
'status.failed': 'Mislukt',
|
||||
'status.conflict': 'Conflict',
|
||||
'status.expired': 'Verlopen',
|
||||
'device_status.online': 'Online',
|
||||
'device_status.recently_seen': 'Recent gezien',
|
||||
'device_status.stale': 'Inactief',
|
||||
'device_status.offline': 'Offline',
|
||||
'device_status.revoked': 'Ingetrokken',
|
||||
'payment.cash': 'Contant',
|
||||
'payment.payconiq': 'Payconiq',
|
||||
'payment.payment_pending': 'Onbetaald laten',
|
||||
'tabs.title': 'Open poefs',
|
||||
'tabs.count': '{count} open poef',
|
||||
'tabs.count_plural': '{count} open poefs',
|
||||
'tabs.empty': 'Geen open poefs',
|
||||
'tabs.empty_desc': 'Open poefs verschijnen hier zodra de terminal synchroniseert.',
|
||||
'tabs.load_failed': 'Poefs konden niet geladen worden',
|
||||
'tabs.customer': 'Klant',
|
||||
'tabs.email': 'E-mail',
|
||||
'tabs.amount': 'Bedrag',
|
||||
'tabs.items': 'Artikelen',
|
||||
'tabs.items': 'Items',
|
||||
'tabs.version': 'Versie',
|
||||
'tabs.reported': 'Laatst gemeld',
|
||||
'tabs.reported': 'Laatst bijgewerkt',
|
||||
'tabs.actions': 'Acties',
|
||||
'tabs.close': 'Sluiten',
|
||||
'tabs.close_title': 'Sluit {name}\'s tab?',
|
||||
'tabs.close_desc': 'Stuur een sluitverzoek naar de terminal.',
|
||||
'tabs.show_items': 'Items van {name} tonen',
|
||||
'tabs.hide_items': 'Items van {name} verbergen',
|
||||
'tabs.lines_unknown': 'Deze terminal stuurt de items nog niet door. Werk de app op de tablet bij om ze te zien.',
|
||||
'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.payment': 'Betaalmethode',
|
||||
'tabs.cash': 'Contant',
|
||||
'tabs.payconiq': 'Payconiq',
|
||||
'tabs.pending_payment': 'Betaling in behandeling',
|
||||
'tabs.select_payment': 'Selecteer een betaalmethode',
|
||||
'tabs.select_payment': 'Kies een betaalmethode',
|
||||
'tabs.send': 'Sluitverzoek versturen',
|
||||
'tabs.sending': 'Bezig met verzenden...',
|
||||
'tabs.cancel': 'Annuleren',
|
||||
'tabs.planned_title': 'Geplande Klanten',
|
||||
'tabs.planned_desc': 'Deze namen worden gesynchroniseerd met de terminal. Deze maakt automatisch tabs aan voor deze klanten.',
|
||||
'tabs.planned_add': 'Klantnaam toevoegen...',
|
||||
'tabs.planned_empty': 'Geen geplande klanten. Voeg namen toe en ze worden gesynchroniseerd met de terminal.',
|
||||
'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.send_test': 'Testmail versturen',
|
||||
'tabs.test_sent': 'Testmail verstuurd',
|
||||
'tabs.test_sent_desc': 'Verstuurd naar {email}',
|
||||
'tabs.test_failed': 'Testmail mislukt',
|
||||
'planned.title': 'Geplande klanten',
|
||||
'planned.name_placeholder': 'Naam van de klant...',
|
||||
'planned.email_placeholder': 'E-mail (optioneel)',
|
||||
'planned.add': 'Klant toevoegen',
|
||||
'planned.remove': '{name} verwijderen',
|
||||
'planned.waiting_empty': 'Geen klanten die op de terminal wachten.',
|
||||
'planned.added': 'Toegevoegd',
|
||||
'planned.added_desc': '"{name}" krijgt een poef op de terminal',
|
||||
'import.button': 'Importeren uit Excel',
|
||||
'import.title': 'Klanten importeren',
|
||||
'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een rekening op de terminal.',
|
||||
'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een poef op de terminal.',
|
||||
'import.choose_file': 'Kies een bestand',
|
||||
'import.empty_file': 'Het bestand bevat geen rijen.',
|
||||
'import.unreadable': 'Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.',
|
||||
@@ -214,7 +301,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'import.more': 'en nog {count}…',
|
||||
'import.mode': 'Bestaande klanten',
|
||||
'import.mode_merge': 'Klanten die niet in het bestand staan behouden',
|
||||
'import.mode_replace': 'Klanten die niet in het bestand staan verwijderen (hun open rekeningen op de terminal blijven)',
|
||||
'import.mode_replace': 'Klanten die niet in het bestand staan verwijderen (hun open poefs op de terminal blijven bestaan)',
|
||||
'import.submit': '{count} klanten importeren',
|
||||
'import.importing': 'Bezig met importeren...',
|
||||
'import.done': 'Import voltooid',
|
||||
@@ -223,23 +310,35 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'users.title': 'Gebruikers',
|
||||
'users.count': '{count} gebruiker',
|
||||
'users.count_plural': '{count} gebruikers',
|
||||
'users.load_failed': 'Gebruikers konden niet geladen worden',
|
||||
'users.empty': 'Geen gebruikers',
|
||||
'users.empty_desc': 'Voeg gebruikers toe die tabs kunnen beheren vanaf dit dashboard.',
|
||||
'users.empty_desc': 'Voeg gebruikers toe die poefs kunnen beheren via dit dashboard.',
|
||||
'users.add': 'Gebruiker toevoegen',
|
||||
'users.name': 'Naam',
|
||||
'users.email': 'E-mail',
|
||||
'users.role': 'Rol',
|
||||
'users.created': 'Aangemaakt',
|
||||
'users.add_title': 'Gebruiker toevoegen',
|
||||
'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang heeft tot het dashboard.',
|
||||
'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang krijgt tot het dashboard.',
|
||||
'users.name_placeholder': 'Naam van de medewerker',
|
||||
'users.password': 'Wachtwoord',
|
||||
'users.creating': 'Bezig...',
|
||||
'users.password_placeholder': 'Minstens 6 tekens',
|
||||
'users.role_viewer': 'Medewerker (kan poefs sluiten)',
|
||||
'users.role_admin': 'Beheerder (volledige toegang)',
|
||||
'users.role.viewer': 'Medewerker',
|
||||
'users.role.admin': 'Beheerder',
|
||||
'users.creating': 'Bezig met aanmaken...',
|
||||
'users.create': 'Gebruiker aanmaken',
|
||||
'users.created_toast': 'Gebruiker aangemaakt',
|
||||
'users.created_toast_desc': '{email} toegevoegd als {role}',
|
||||
'users.create_failed': 'Gebruiker aanmaken mislukt',
|
||||
'commands.title': 'Opdrachtgeschiedenis',
|
||||
'commands.count': '{count} opdracht',
|
||||
'commands.count_plural': '{count} opdrachten',
|
||||
'commands.load_failed': 'Opdrachten konden niet geladen worden',
|
||||
'commands.empty': 'Geen opdrachten gevonden',
|
||||
'commands.tab': 'Tab',
|
||||
'commands.empty_desc': 'Opdrachten verschijnen hier zodra je een sluitverzoek verstuurt.',
|
||||
'commands.tab': 'Poef',
|
||||
'commands.requested': 'Aangevraagd door',
|
||||
'commands.payment': 'Betaling',
|
||||
'commands.created': 'Aangemaakt',
|
||||
@@ -251,46 +350,37 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'commands.next': 'Volgende',
|
||||
'commands.page': 'Pagina {page} van {total}',
|
||||
'settings.title': 'Instellingen',
|
||||
'settings.desc': 'Locatie- en apparaatbeheer',
|
||||
'settings.venue': 'Locatie',
|
||||
'settings.device': 'Apparaat',
|
||||
'settings.desc': 'Zaak en terminal',
|
||||
'settings.venue': 'Zaak',
|
||||
'settings.device': 'Terminal',
|
||||
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
|
||||
'settings.not_paired': 'Nog geen apparaat gekoppeld',
|
||||
'settings.not_paired': 'Nog geen terminal gekoppeld',
|
||||
'settings.connected': 'Verbonden',
|
||||
'settings.since': 'Sinds {date}',
|
||||
'settings.pair_desc': 'Koppel een barterminal om tabs te ontvangen.',
|
||||
'settings.pair': 'Apparaat koppelen',
|
||||
'settings.repair': 'Opnieuw koppelen',
|
||||
'settings.pair_title': 'Apparaat koppelen',
|
||||
'settings.pair_instructions': 'Voer deze code in op de barterminal om deze te verbinden met uw locatie.',
|
||||
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
|
||||
'settings.pair': 'Terminal koppelen',
|
||||
'settings.repair': 'Terminal opnieuw koppelen',
|
||||
'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.',
|
||||
'settings.pair_code': 'Registratiecode',
|
||||
'settings.pair_expires': 'Verloopt om {time}',
|
||||
'settings.pair_step1': 'Open de barterminal app',
|
||||
'settings.pair_step2': 'Selecteer uw locatie uit de lijst',
|
||||
'settings.pair_steps': 'Stappen:',
|
||||
'settings.pair_step1': 'Open de app op de barterminal',
|
||||
'settings.pair_step2': 'Kies je zaak uit de lijst',
|
||||
'settings.pair_step3': 'Voer deze registratiecode in',
|
||||
'settings.pair_step4': 'Geef de terminal een naam',
|
||||
'settings.pair_step5': 'Voltooi de registratie',
|
||||
'settings.pair_step5': 'Rond de registratie af',
|
||||
'settings.copy': 'Code kopiëren',
|
||||
'settings.close': 'Sluiten',
|
||||
'settings.api': 'API',
|
||||
'settings.base_url': 'Basis-URL',
|
||||
'settings.swagger': 'Swagger',
|
||||
'settings.copied_desc': 'Registratiecode gekopieerd naar het klembord',
|
||||
'overview.title': 'Overzicht',
|
||||
'overview.desc': 'Real-time overzicht van uw locatie',
|
||||
'overview.open_tabs': 'Open Tabs',
|
||||
'overview.device': 'Apparaat',
|
||||
'overview.open_tabs': 'Open poefs',
|
||||
'overview.device': 'Terminal',
|
||||
'overview.pending': 'Openstaande opdrachten',
|
||||
'overview.recent': 'Recente opdrachten',
|
||||
'overview.no_commands': 'Nog geen opdrachten',
|
||||
'overview.not_connected': 'Niet verbonden',
|
||||
'overview.last_seen': 'Laatst gezien {time}',
|
||||
'common.refresh': 'Verversen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.error': 'Er is iets misgegaan',
|
||||
'common.confirm': 'Bevestigen',
|
||||
'common.cancel': 'Annuleren',
|
||||
'common.processing': 'Bezig...',
|
||||
'common.copied': 'Gekopieerd',
|
||||
'auth.login': 'Inloggen',
|
||||
'auth.logging_in': 'Bezig met inloggen...',
|
||||
'auth.email': 'E-mail',
|
||||
@@ -300,67 +390,110 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'lang.nl': 'Nederlands',
|
||||
'lang.label': 'Taal',
|
||||
'reminders.title': 'Betalingsherinneringen',
|
||||
'reminders.desc': 'Stuur betalingsherinneringen naar klanten met openstaande tabs',
|
||||
'reminders.candidates': 'In aanmerking voor herinnering',
|
||||
'reminders.candidates_desc': 'Deze klanten hebben openstaande tabs en een e-mailadres. Herinneringen worden verzonden via Resend.',
|
||||
'reminders.no_candidates': 'Geen geschikte klanten',
|
||||
'reminders.desc': 'Stuur betalingsherinneringen naar klanten met een openstaande poef',
|
||||
'reminders.candidates': 'Komen in aanmerking',
|
||||
'reminders.candidates_desc': 'Deze klanten hebben een openstaande poef en een gekend e-mailadres. Herinneringen worden verstuurd via Resend.',
|
||||
'reminders.no_candidates': 'Geen klanten die in aanmerking komen',
|
||||
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
|
||||
'reminders.send': 'Verstuur herinneringen',
|
||||
'reminders.send': 'Herinneringen versturen',
|
||||
'reminders.sending': 'Bezig met versturen...',
|
||||
'reminders.result': '{sent} van {total} herinnering(en) verzonden',
|
||||
'reminders.sent_title': 'Herinneringen verzonden',
|
||||
'reminders.zero_sent': 'Geen herinneringen verzonden. Controleer of Resend is geconfigureerd.',
|
||||
'reminders.success': '{sent} herinnering(en) succesvol verzonden',
|
||||
'reminders.history': 'Verzonden herinneringen',
|
||||
'reminders.history_empty': 'Nog geen herinneringen verzonden',
|
||||
'reminders.confirm': 'Er wordt een betalingsherinnering gemaild naar {count} klant(en) met een openstaande poef.',
|
||||
'reminders.sent_title': 'Herinneringen verstuurd',
|
||||
'reminders.partial': '{sent} van {total} verstuurd',
|
||||
'reminders.error_detail': 'Fout: {error}',
|
||||
'reminders.zero_sent': 'Er zijn geen herinneringen verstuurd. Controleer of Resend is ingesteld.',
|
||||
'reminders.success': '{sent} herinnering(en) verstuurd',
|
||||
'reminders.history': 'Verstuurde herinneringen',
|
||||
'reminders.history_empty': 'Nog geen herinneringen verstuurd',
|
||||
'reminders.customer': 'Klant',
|
||||
'reminders.email': 'E-mail',
|
||||
'reminders.amount': 'Bedrag',
|
||||
'reminders.sent_at': 'Verzonden op',
|
||||
'reminders.sent_at': 'Verstuurd op',
|
||||
'reminders.status': 'Status',
|
||||
'tabs.email': 'E-mail (optioneel)',
|
||||
'tabs.planned_email_hint': 'Voeg e-mail toe om betalingsherinneringen in te schakelen',
|
||||
'reminders.status_sent': 'Verstuurd',
|
||||
},
|
||||
};
|
||||
|
||||
const LOCALES: Record<Lang, string> = { en: 'en-GB', nl: 'nl-BE' };
|
||||
|
||||
type Params = Record<string, string | number>;
|
||||
|
||||
interface LangContextType {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
t: (key: string, params?: Params) => string;
|
||||
/** Uses `<key>_plural` unless `count` is 1; `{count}` is filled in. */
|
||||
tn: (key: string, count: number, params?: Params) => string;
|
||||
/** "just now", "5m ago"…, falling back to a date after a week. */
|
||||
relativeTime: (date: string | Date) => string;
|
||||
dateTime: (date: string | Date) => string;
|
||||
time: (date: string | Date) => string;
|
||||
}
|
||||
|
||||
const LangContext = createContext<LangContextType>({
|
||||
lang: 'en',
|
||||
setLang: () => {},
|
||||
t: (key) => key,
|
||||
});
|
||||
function translate(lang: Lang, key: string, params?: Params): string {
|
||||
let s = translations[lang][key] ?? translations.en[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
s = s.split(`{${k}}`).join(String(v));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function helpers(lang: Lang): Omit<LangContextType, 'setLang'> {
|
||||
const t = (key: string, params?: Params) => translate(lang, key, params);
|
||||
const locale = LOCALES[lang];
|
||||
return {
|
||||
lang,
|
||||
t,
|
||||
tn: (key, count, params) => t(count === 1 ? key : `${key}_plural`, { count, ...params }),
|
||||
relativeTime: (value) => {
|
||||
const date = new Date(value);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (seconds < 60) return t('time.just_now');
|
||||
if (minutes < 60) return t('time.minutes_ago', { count: minutes });
|
||||
if (hours < 24) return t('time.hours_ago', { count: hours });
|
||||
if (days < 7) return t('time.days_ago', { count: days });
|
||||
return date.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
},
|
||||
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' }),
|
||||
};
|
||||
}
|
||||
|
||||
const LangContext = createContext<LangContextType>({ ...helpers('en'), setLang: () => {} });
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>('en');
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('kooltap-lang') as Lang | null;
|
||||
if (stored === 'en' || stored === 'nl') setLangState(stored);
|
||||
try {
|
||||
const stored = localStorage.getItem('kooltap-lang') as Lang | null;
|
||||
if (stored === 'en' || stored === 'nl') setLangState(stored);
|
||||
} catch {
|
||||
// Storage unavailable (e.g. private mode): keep the default language
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
}, [lang]);
|
||||
|
||||
function setLang(l: Lang) {
|
||||
setLangState(l);
|
||||
localStorage.setItem('kooltap-lang', l);
|
||||
}
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
let s = translations[lang][key];
|
||||
if (!s) s = translations['en'][key] || key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
s = s.replace(`{${k}}`, String(v));
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('kooltap-lang', l);
|
||||
} catch {
|
||||
// Not persisted, but the choice still applies for this visit
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
return (
|
||||
<LangContext.Provider value={{ lang, setLang, t }}>
|
||||
<LangContext.Provider value={{ ...helpers(lang), setLang }}>
|
||||
{children}
|
||||
</LangContext.Provider>
|
||||
);
|
||||
@@ -368,4 +501,4 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
export function useLang() {
|
||||
return useContext(LangContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,30 +6,6 @@ export function formatCents(cents: number, currency: string = 'EUR'): string {
|
||||
}).format(euros);
|
||||
}
|
||||
|
||||
export function formatRelativeTime(dateString: string | Date): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString: string | Date): string {
|
||||
return new Date(dateString).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
// Relative times and dates are language-dependent: use relativeTime/dateTime from useLang()
|
||||
|
||||
export { cn } from './cn';
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user