@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { useState } from 'react';
|
||||
import { commandsApi } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -16,6 +15,8 @@ import { ErrorState } from '@/components/error-state';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { formatCents } from '@/lib/utils';
|
||||
|
||||
const statusFilters = [
|
||||
{ value: '', label: 'commands.all' },
|
||||
@@ -29,40 +30,24 @@ const statusFilters = [
|
||||
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 { t, tn, te, relativeTime } = useLang();
|
||||
const { venueId } = useSession();
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='));
|
||||
if (!cookie) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setVenueId(payload.venueId);
|
||||
}, [router]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['commands', venueId, statusFilter, page],
|
||||
queryFn: () =>
|
||||
api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => commandsApi.list(venueId, { limit, offset: page * limit, status: statusFilter || undefined }),
|
||||
refetchInterval: live ? false : 10000,
|
||||
});
|
||||
|
||||
const commands = data?.commands || [];
|
||||
const total = data?.total || 0;
|
||||
const commands = data?.commands ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={(error as any).message || t('commands.load_failed')} />;
|
||||
if (error) return <ErrorState message={te(error, 'commands.load_failed')} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -93,7 +78,8 @@ export default function CommandsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('commands.tab')}</TableHead>
|
||||
<TableHead>{t('commands.customer')}</TableHead>
|
||||
<TableHead>{t('commands.amount')}</TableHead>
|
||||
<TableHead>{t('commands.requested')}</TableHead>
|
||||
<TableHead>{t('commands.payment')}</TableHead>
|
||||
<TableHead>{t('commands.created')}</TableHead>
|
||||
@@ -103,9 +89,10 @@ export default function CommandsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{commands.map((cmd: any) => (
|
||||
{commands.map((cmd) => (
|
||||
<TableRow key={cmd.id}>
|
||||
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
|
||||
<TableCell className="font-medium">{cmd.customerName ?? <span className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</span>}</TableCell>
|
||||
<TableCell className="tabular-nums">{cmd.amountInCents != null ? formatCents(cmd.amountInCents) : '-'}</TableCell>
|
||||
<TableCell>{cmd.requestedBy}</TableCell>
|
||||
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
|
||||
<TableCell>{relativeTime(cmd.createdAt)}</TableCell>
|
||||
|
||||
@@ -18,9 +18,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { LanguageSelector } from '@/components/language-selector';
|
||||
import { authApi, api } from '@/lib/api/client';
|
||||
import { authApi, venuesApi } from '@/lib/api/client';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
|
||||
import { readSession, Session, SessionProvider } from '@/lib/session';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
|
||||
@@ -39,39 +41,22 @@ export default function DashboardLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null);
|
||||
const [user, setUser] = useState<Session | null>(null);
|
||||
const [venue, setVenue] = useState<{ name: string } | null>(null);
|
||||
const { t } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
// Check auth
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='));
|
||||
if (!cookie) {
|
||||
router.push('/login');
|
||||
const session = readSession();
|
||||
if (!session) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode JWT to get basic user info
|
||||
try {
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setUser({
|
||||
name: payload.email,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
venueId: payload.venueId,
|
||||
});
|
||||
|
||||
// Fetch venue name
|
||||
api(`/venues/${payload.venueId}`).then((v: any) => {
|
||||
setVenue(v);
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
router.push('/login');
|
||||
}
|
||||
setUser(session);
|
||||
venuesApi.get(session.venueId).then(setVenue).catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
if (!user) return <LoadingState />;
|
||||
|
||||
function handleLogout() {
|
||||
authApi.logout();
|
||||
toast({ title: t('common.logged_out'), variant: 'default' });
|
||||
@@ -79,6 +64,7 @@ export default function DashboardLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider session={user}>
|
||||
<RealtimeProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Mobile sidebar toggle */}
|
||||
@@ -118,7 +104,7 @@ export default function DashboardLayout({
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href;
|
||||
|
||||
if (item.adminOnly && user?.role !== 'admin') {
|
||||
if (item.adminOnly && user.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -142,13 +128,12 @@ export default function DashboardLayout({
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<LiveIndicator />
|
||||
<LanguageSelector />
|
||||
{user && (
|
||||
<div className="mb-3 text-sm">
|
||||
<p className="font-medium truncate">{user.name}</p>
|
||||
<p className="text-muted-foreground capitalize">{user.role}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 text-sm">
|
||||
<p className="font-medium truncate">{user.email}</p>
|
||||
<p className="text-muted-foreground">{t(`users.role.${user.role}`)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3"
|
||||
@@ -167,6 +152,7 @@ export default function DashboardLayout({
|
||||
</main>
|
||||
</div>
|
||||
</RealtimeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,80 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { commandsApi, devicesApi, tabsApi } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
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 { ClipboardList, Terminal, Monitor } from 'lucide-react';
|
||||
import { DeviceStatusBadge } from '@/components/device-status-badge';
|
||||
import { ClipboardList, Terminal, Monitor, Euro } from 'lucide-react';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { formatCents } from '@/lib/utils';
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='));
|
||||
if (!cookie) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setVenueId(payload.venueId);
|
||||
}, [router]);
|
||||
const { venueId } = useSession();
|
||||
|
||||
const tabsQuery = useQuery({
|
||||
queryKey: ['tabs', venueId],
|
||||
queryFn: () => api(`/venues/${venueId}/tabs`),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => tabsApi.list(venueId),
|
||||
refetchInterval: live ? false : 10000,
|
||||
});
|
||||
|
||||
const deviceQuery = useQuery({
|
||||
queryKey: ['device', venueId],
|
||||
queryFn: () => api(`/venues/${venueId}/devices`),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => devicesApi.get(venueId),
|
||||
// A terminal going offline sends no event, so its status is always polled
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const commandsQuery = useQuery({
|
||||
const recentQuery = useQuery({
|
||||
queryKey: ['commands', venueId, 'recent'],
|
||||
queryFn: () => api(`/venues/${venueId}/commands?limit=5`),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => commandsApi.list(venueId, { limit: 5 }),
|
||||
refetchInterval: live ? false : 15000,
|
||||
});
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
const pendingQuery = useQuery({
|
||||
queryKey: ['commands', venueId, 'pending-count'],
|
||||
queryFn: () => commandsApi.list(venueId, { status: 'pending', limit: 1 }),
|
||||
refetchInterval: live ? false : 15000,
|
||||
});
|
||||
|
||||
const tabs = tabsQuery.data?.tabs || [];
|
||||
const tabs = tabsQuery.data?.tabs ?? [];
|
||||
const device = deviceQuery.data?.device;
|
||||
const commands = commandsQuery.data?.commands || [];
|
||||
|
||||
const pendingCommands = commands.filter((c: any) => c.status === 'pending');
|
||||
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading;
|
||||
const commands = recentQuery.data?.commands ?? [];
|
||||
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
|
||||
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title={t('overview.title')} description="" />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}><CardHeader className="flex flex-row items-center justify-between pb-2"><Skeleton className="h-4 w-24" /><Skeleton className="h-4 w-4" /></CardHeader><CardContent><Skeleton className="h-8 w-16" /></CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('overview.open_tabs')}</CardTitle>
|
||||
@@ -82,14 +69,30 @@ export default function DashboardPage() {
|
||||
</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">{t('overview.outstanding')}</CardTitle>
|
||||
<Euro className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold tabular-nums">{formatCents(outstanding)}</div></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<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 || t('overview.not_connected')}</div>
|
||||
{device && <p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>}
|
||||
{device ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold truncate">{device.name}</span>
|
||||
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{t('overview.not_connected')}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -97,7 +100,7 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
<CardContent><div className="text-2xl font-bold">{pendingQuery.data?.total ?? 0}</div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -109,13 +112,14 @@ export default function DashboardPage() {
|
||||
<p className="text-sm text-muted-foreground">{t('overview.no_commands')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{commands.map((cmd: any) => (
|
||||
<div key={cmd.id} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{cmd.tabId?.substring(0, 8)}</span>
|
||||
{commands.map((cmd) => (
|
||||
<div key={cmd.id} className="flex items-center justify-between gap-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{cmd.customerName ?? cmd.tabId.substring(0, 8)}</span>
|
||||
{cmd.amountInCents != null && <span className="ml-2 tabular-nums">{formatCents(cmd.amountInCents)}</span>}
|
||||
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<CommandStatusBadge status={cmd.status} />
|
||||
<span className="text-muted-foreground">{relativeTime(cmd.createdAt)}</span>
|
||||
</div>
|
||||
@@ -127,4 +131,4 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { remindersApi } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -11,43 +10,34 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
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 } from '@/lib/utils';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Send, History } from 'lucide-react';
|
||||
|
||||
export default function RemindersPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t, relativeTime } = useLang();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const { t, te, relativeTime } = useLang();
|
||||
const { venueId, role } = useSession();
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||
if (!cookie) { router.push('/login'); return; }
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setVenueId(payload.venueId);
|
||||
}, [router]);
|
||||
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ['reminder-candidates', venueId],
|
||||
queryFn: () => remindersApi.candidates(venueId!),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => remindersApi.candidates(venueId),
|
||||
});
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['reminder-history', venueId],
|
||||
queryFn: () => remindersApi.history(venueId!),
|
||||
enabled: !!venueId && showHistory,
|
||||
queryFn: () => remindersApi.history(venueId),
|
||||
enabled: showHistory,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: () => remindersApi.send(venueId!),
|
||||
onSuccess: (result: any) => {
|
||||
mutationFn: () => remindersApi.send(venueId),
|
||||
onSuccess: (result) => {
|
||||
setSendDialogOpen(false);
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
const firstError = result.errors[0];
|
||||
@@ -60,15 +50,14 @@ export default function RemindersPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
|
||||
onError: (err) => {
|
||||
setSendDialogOpen(false);
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
|
||||
const candidates = candidatesQuery.data?.candidates || [];
|
||||
const reminders = historyQuery.data?.reminders || [];
|
||||
const candidates = candidatesQuery.data?.candidates ?? [];
|
||||
const reminders = historyQuery.data?.reminders ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -106,13 +95,13 @@ export default function RemindersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reminders.map((r: any) => (
|
||||
{reminders.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.customerName}</TableCell>
|
||||
<TableCell>{r.email}</TableCell>
|
||||
<TableCell>{formatCents(r.amountInCents)}</TableCell>
|
||||
<TableCell>{relativeTime(r.sentAt)}</TableCell>
|
||||
<TableCell><Badge variant="success">{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
|
||||
<TableCell><Badge variant={r.status === 'sent' ? 'success' : 'secondary'}>{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -149,7 +138,7 @@ export default function RemindersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{candidates.map((c: any) => (
|
||||
{candidates.map((c) => (
|
||||
<TableRow key={c.tabId}>
|
||||
<TableCell className="font-medium">{c.customerName}</TableCell>
|
||||
<TableCell>{c.email}</TableCell>
|
||||
@@ -161,10 +150,14 @@ export default function RemindersPage() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSendDialogOpen(true)}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{t('reminders.send')} ({candidates.length})
|
||||
</Button>
|
||||
{role === 'admin' ? (
|
||||
<Button onClick={() => setSendDialogOpen(true)}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{t('reminders.send')} ({candidates.length})
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('reminders.admin_only')}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -184,7 +177,7 @@ export default function RemindersPage() {
|
||||
{t('reminders.confirm', { count: candidates.length })}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{candidates.map((c: any) => (
|
||||
{candidates.map((c) => (
|
||||
<li key={c.tabId} className="text-sm flex items-center gap-2">
|
||||
<span className="font-medium">{c.customerName}</span>
|
||||
<span className="text-muted-foreground">({c.email})</span>
|
||||
|
||||
@@ -1,44 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, devicesApi } from '@/lib/api/client';
|
||||
import { devicesApi, venuesApi } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DeviceStatusBadge } from '@/components/device-status-badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Monitor, Copy, Plus } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const { venueId, role } = useSession();
|
||||
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='));
|
||||
if (!cookie) { router.push('/login'); return; }
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setVenueId(payload.venueId);
|
||||
setUserRole(payload.role);
|
||||
}, [router]);
|
||||
const { t, te, relativeTime, dateTime, time } = useLang();
|
||||
|
||||
const { data: venue } = useQuery({
|
||||
queryKey: ['venue', venueId],
|
||||
queryFn: () => api(`/venues/${venueId}`),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => venuesApi.get(venueId),
|
||||
});
|
||||
|
||||
const { data: deviceData } = useQuery({
|
||||
queryKey: ['device', venueId],
|
||||
queryFn: () => devicesApi.get(venueId!),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => devicesApi.get(venueId),
|
||||
// A terminal going offline sends no event, so its status is always polled
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
@@ -50,8 +38,8 @@ export default function SettingsPage() {
|
||||
try {
|
||||
const result = await devicesApi.generateCode();
|
||||
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
|
||||
} catch (err: any) {
|
||||
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -91,8 +79,8 @@ export default function SettingsPage() {
|
||||
<CardContent>
|
||||
{device ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Monitor className="h-4 w-4 text-green-600" />
|
||||
<Badge variant="success">{t('settings.connected')}</Badge>
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
|
||||
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -100,7 +88,7 @@ export default function SettingsPage() {
|
||||
{t('settings.pair_desc')}
|
||||
</p>
|
||||
)}
|
||||
{userRole === 'admin' && (
|
||||
{role === 'admin' && (
|
||||
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')}
|
||||
|
||||
@@ -2,155 +2,177 @@
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, commandsApi, plannedTabsApi, remindersApi } from '@/lib/api/client';
|
||||
import { commandsApi, plannedTabsApi, remindersApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 } from '@/lib/utils';
|
||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown, Search } from 'lucide-react';
|
||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
type SortKey = 'name' | 'amount';
|
||||
|
||||
const comparators: Record<SortKey, (a: Tab, b: Tab) => number> = {
|
||||
name: (a, b) => a.customerName.localeCompare(b.customerName, undefined, { sensitivity: 'base' }),
|
||||
amount: (a, b) => b.totalInCents - a.totalInCents,
|
||||
};
|
||||
|
||||
export default function TabsPage() {
|
||||
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||
const { connected: live } = useRealtime();
|
||||
const router = useRouter();
|
||||
const { venueId, role } = useSession();
|
||||
const isAdmin = role === 'admin';
|
||||
const queryClient = useQueryClient();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const [closeDialog, setCloseDialog] = useState<{ open: boolean; tab: any }>({ open: false, tab: null });
|
||||
const [closeTab, setCloseTab] = useState<Tab | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState('');
|
||||
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
||||
const [watchedCommandIds, setWatchedCommandIds] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortKey>('name');
|
||||
const [plannedName, setPlannedName] = useState('');
|
||||
const [plannedEmail, setPlannedEmail] = useState('');
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const { t, tn, relativeTime } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||
if (!cookie) { router.push('/login'); return; }
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setVenueId(payload.venueId);
|
||||
setUserRole(payload.role);
|
||||
}, [router]);
|
||||
const { t, tn, te, relativeTime } = useLang();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tabs', venueId],
|
||||
queryFn: () => api(`/venues/${venueId}/tabs`),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => tabsApi.list(venueId),
|
||||
refetchInterval: live ? false : 10000,
|
||||
});
|
||||
|
||||
const commandsQuery = useQuery({
|
||||
const pendingQuery = useQuery({
|
||||
queryKey: ['commands', venueId, 'pending'],
|
||||
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
|
||||
enabled: !!venueId && pendingCommandIds.size > 0,
|
||||
queryFn: () => commandsApi.list(venueId, { status: 'pending', limit: 200 }),
|
||||
refetchInterval: live ? false : 5000,
|
||||
});
|
||||
const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId));
|
||||
|
||||
// Only needed to report the outcome of close requests sent from this page
|
||||
const resultsQuery = useQuery({
|
||||
queryKey: ['commands', venueId, 'results'],
|
||||
queryFn: () => commandsApi.list(venueId, { limit: 50 }),
|
||||
enabled: watchedCommandIds.size > 0,
|
||||
refetchInterval: live ? false : 3000,
|
||||
});
|
||||
|
||||
const plannedQuery = useQuery({
|
||||
queryKey: ['planned-tabs', venueId],
|
||||
queryFn: () => plannedTabsApi.list(venueId!),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => plannedTabsApi.list(venueId),
|
||||
refetchInterval: live ? false : 10000,
|
||||
});
|
||||
|
||||
// Tiles only show customers the terminal has not opened a tab for yet
|
||||
const pendingPlanned = (plannedQuery.data?.plannedTabs || []).filter((pt: any) => !pt.deliveredAt);
|
||||
const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt);
|
||||
|
||||
useEffect(() => {
|
||||
if (commandsQuery.data?.commands) {
|
||||
for (const cmd of commandsQuery.data.commands) {
|
||||
if (cmd.status !== 'pending' && pendingCommandIds.has(cmd.id)) {
|
||||
const newSet = new Set(pendingCommandIds);
|
||||
newSet.delete(cmd.id);
|
||||
setPendingCommandIds(newSet);
|
||||
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'] });
|
||||
}
|
||||
}
|
||||
const finished = (resultsQuery.data?.commands ?? []).filter((c) => watchedCommandIds.has(c.id) && c.status !== 'pending');
|
||||
if (finished.length === 0) return;
|
||||
for (const cmd of finished) {
|
||||
const status = t(`status.${cmd.status}`);
|
||||
toast({
|
||||
title: status,
|
||||
description: t('tabs.request_result', { name: cmd.customerName ?? cmd.tabId.substring(0, 8), status }),
|
||||
variant: cmd.status === 'completed' ? 'success' : 'destructive',
|
||||
});
|
||||
}
|
||||
}, [commandsQuery.data, pendingCommandIds, queryClient, data, t]);
|
||||
setWatchedCommandIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const cmd of finished) next.delete(cmd.id);
|
||||
return next;
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['tabs'] });
|
||||
}, [resultsQuery.data, watchedCommandIds, queryClient, t]);
|
||||
|
||||
const createCommandMutation = useMutation({
|
||||
mutationFn: (data: { tabId: string; expectedVersion: number; paymentMethod: string }) =>
|
||||
commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
|
||||
mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) =>
|
||||
commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }),
|
||||
onSuccess: (result) => {
|
||||
setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
|
||||
setWatchedCommandIds((current) => new Set(current).add(result.id));
|
||||
toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' });
|
||||
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
|
||||
onError: (err) => {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
queryClient.invalidateQueries({ queryKey: ['tabs'] });
|
||||
},
|
||||
onSettled: () => {
|
||||
setCloseTab(null);
|
||||
setPaymentMethod('');
|
||||
queryClient.invalidateQueries({ queryKey: ['commands'] });
|
||||
},
|
||||
});
|
||||
|
||||
function openCloseDialog(tab: Tab) {
|
||||
setPaymentMethod('');
|
||||
setCloseTab(tab);
|
||||
}
|
||||
|
||||
function confirmClose() {
|
||||
if (!paymentMethod) return;
|
||||
setCreating(true);
|
||||
createCommandMutation.mutate({ tabId: closeDialog.tab.tabId, expectedVersion: closeDialog.tab.version, paymentMethod }, {
|
||||
onSettled: () => { setCreating(false); setCloseDialog({ open: false, tab: null }); setPaymentMethod(''); },
|
||||
});
|
||||
if (!closeTab || !paymentMethod) return;
|
||||
createCommandMutation.mutate({ tab: closeTab, paymentMethod });
|
||||
}
|
||||
|
||||
async function addPlannedName() {
|
||||
if (!plannedName.trim()) return;
|
||||
const name = plannedName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await plannedTabsApi.create(venueId!, plannedName.trim(), plannedEmail.trim() || undefined);
|
||||
await plannedTabsApi.create(venueId, name, plannedEmail.trim() || undefined);
|
||||
setPlannedName('');
|
||||
setPlannedEmail('');
|
||||
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
|
||||
toast({ title: t('planned.added'), description: t('planned.added_desc', { name: plannedName.trim() }) });
|
||||
} catch (err: any) {
|
||||
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
|
||||
toast({ title: t('planned.added'), description: t('planned.added_desc', { name }) });
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlannedName(id: string) {
|
||||
try {
|
||||
await plannedTabsApi.remove(venueId!, id);
|
||||
await plannedTabsApi.remove(venueId, id);
|
||||
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
|
||||
} catch (err: any) {
|
||||
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId),
|
||||
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId, plannedTabId),
|
||||
onSuccess: (result) => {
|
||||
toast({ title: t('tabs.test_sent'), description: t('tabs.test_sent_desc', { email: result.email }), variant: 'success' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: t('tabs.test_failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
|
||||
onError: (err) => {
|
||||
toast({ title: t('tabs.test_failed'), description: te(err), variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={(error as any).message || t('tabs.load_failed')} />;
|
||||
if (error) return <ErrorState message={te(error, 'tabs.load_failed')} />;
|
||||
|
||||
const tabs = data?.tabs || [];
|
||||
const tabs = data?.tabs ?? [];
|
||||
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
|
||||
const query = search.trim().toLowerCase();
|
||||
const visibleTabs = tabs
|
||||
.filter((tab) => !query || tab.customerName.toLowerCase().includes(query))
|
||||
.sort(comparators[sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={t('tabs.title')}
|
||||
description={tn('tabs.count', tabs.length)}
|
||||
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>}
|
||||
/>
|
||||
|
||||
@@ -159,7 +181,7 @@ export default function TabsPage() {
|
||||
<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>
|
||||
{userRole === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
|
||||
</Button>
|
||||
@@ -167,7 +189,7 @@ export default function TabsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{userRole === 'admin' && (
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input
|
||||
placeholder={t('planned.name_placeholder')}
|
||||
@@ -193,11 +215,11 @@ export default function TabsPage() {
|
||||
{plannedQuery.data && pendingPlanned.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('planned.waiting_empty')}</p>
|
||||
)}
|
||||
{pendingPlanned.map((pt: any) => (
|
||||
{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" />}
|
||||
{userRole === 'admin' && pt.email && (
|
||||
{isAdmin && pt.email && (
|
||||
<button
|
||||
onClick={() => sendTestMutation.mutate(pt.id)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
@@ -208,7 +230,7 @@ export default function TabsPage() {
|
||||
<Send className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{userRole === 'admin' && (
|
||||
{isAdmin && (
|
||||
<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>
|
||||
@@ -222,105 +244,134 @@ export default function TabsPage() {
|
||||
{tabs.length === 0 ? (
|
||||
<EmptyState title={t('tabs.empty')} description={t('tabs.empty_desc')} />
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<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>
|
||||
{tabs.map((tab: any) => {
|
||||
const expanded = expandedTabs.has(tab.tabId);
|
||||
const toggle = () => setExpandedTabs((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId);
|
||||
return next;
|
||||
});
|
||||
return (
|
||||
<Fragment key={tab.tabId}>
|
||||
<>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[12rem] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t('tabs.search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
aria-label={t('tabs.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<Select value={sort} onValueChange={(value) => setSort(value as SortKey)}>
|
||||
<SelectTrigger className="w-44" aria-label={t('tabs.sort')}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">{t('tabs.sort_name')}</SelectItem>
|
||||
<SelectItem value="amount">{t('tabs.sort_amount')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tab.email ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate max-w-[200px]">{tab.email}</span>
|
||||
{userRole === 'admin' && tab.plannedTabId && (
|
||||
<button
|
||||
onClick={() => sendTestMutation.mutate(tab.plannedTabId)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="hover:text-primary"
|
||||
title={t('tabs.send_test')}
|
||||
aria-label={t('tabs.send_test')}
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
|
||||
<TableCell>{tab.itemCount}</TableCell>
|
||||
<TableCell>{tab.version}</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(''); }}>{t('tabs.close')}</Button>
|
||||
</TableCell>
|
||||
<TableHead>{t('tabs.customer')}</TableHead>
|
||||
<TableHead>{t('tabs.email')}</TableHead>
|
||||
<TableHead>{t('tabs.amount')}</TableHead>
|
||||
<TableHead>{t('tabs.items')}</TableHead>
|
||||
<TableHead>{t('tabs.reported')}</TableHead>
|
||||
<TableHead className="text-right">{t('tabs.actions')}</TableHead>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||
<TableCell colSpan={7} className="py-3 pl-4 sm:pl-10">
|
||||
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
|
||||
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
|
||||
<TabLines items={tab.items} />
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleTabs.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>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{visibleTabs.map((tab) => {
|
||||
const expanded = expandedTabs.has(tab.tabId);
|
||||
const toggle = () => setExpandedTabs((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId);
|
||||
return next;
|
||||
});
|
||||
return (
|
||||
<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={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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tab.email ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate max-w-[200px]">{tab.email}</span>
|
||||
{isAdmin && tab.plannedTabId && (
|
||||
<button
|
||||
onClick={() => sendTestMutation.mutate(tab.plannedTabId!)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="hover:text-primary"
|
||||
title={t('tabs.send_test')}
|
||||
aria-label={t('tabs.send_test')}
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell>
|
||||
<TableCell>{tab.itemCount}</TableCell>
|
||||
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{closingTabIds.has(tab.tabId) ? (
|
||||
<Badge variant="warning">{t('tabs.closing')}</Badge>
|
||||
) : (
|
||||
// Closing settles the tab on the terminal; a tab without a balance has nothing to settle
|
||||
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)}>{t('tabs.close')}</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
|
||||
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
|
||||
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
|
||||
<TabLines items={tab.items} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{venueId && (
|
||||
<ImportCustomersDialog
|
||||
venueId={venueId}
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
|
||||
/>
|
||||
)}
|
||||
<ImportCustomersDialog
|
||||
venueId={venueId}
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
|
||||
/>
|
||||
|
||||
{closeDialog.tab && (
|
||||
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
|
||||
{closeTab && (
|
||||
<Dialog open onOpenChange={(open) => !open && !createCommandMutation.isPending && setCloseTab(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('tabs.close_title', { name: closeDialog.tab.customerName })}</DialogTitle>
|
||||
<DialogTitle>{t('tabs.close_title', { name: closeTab.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">{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">{t('tabs.version')}</span><span>{closeDialog.tab.version}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.amount')}</span><span className="font-semibold">{formatCents(closeTab.totalInCents)}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.items')}</span><span>{closeTab.itemCount}</span></div>
|
||||
<div className="rounded-md border p-3"><TabLines items={closeTab.items} /></div>
|
||||
<div className="space-y-3 pt-2">
|
||||
<Label className="text-base font-semibold">{t('tabs.payment')}</Label>
|
||||
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
@@ -332,8 +383,8 @@ export default function TabsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<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>
|
||||
<Button variant="outline" onClick={() => setCloseTab(null)} disabled={createCommandMutation.isPending}>{t('common.cancel')}</Button>
|
||||
<Button onClick={confirmClose} disabled={createCommandMutation.isPending || !paymentMethod}>{createCommandMutation.isPending ? t('tabs.sending') : t('tabs.send')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -342,8 +393,6 @@ export default function TabsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { usersApi } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
@@ -17,55 +16,42 @@ import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { RefreshCw, Plus } from 'lucide-react';
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const { venueId, role } = useSession();
|
||||
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
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='));
|
||||
if (!cookie) { router.push('/login'); return; }
|
||||
const payload = JSON.parse(atob(cookie.split('.')[1]));
|
||||
setVenueId(payload.venueId);
|
||||
setUserRole(payload.role);
|
||||
}, [router]);
|
||||
const { t, tn, te, relativeTime } = useLang();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['users', venueId],
|
||||
queryFn: () => usersApi.list(venueId!),
|
||||
enabled: !!venueId,
|
||||
queryFn: () => usersApi.list(venueId),
|
||||
});
|
||||
|
||||
const users = data?.users || [];
|
||||
const users = data?.users ?? [];
|
||||
|
||||
async function handleCreateUser() {
|
||||
if (!form.email || !form.password || !form.name) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await usersApi.create(venueId!, form);
|
||||
await usersApi.create(venueId, form);
|
||||
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: t('users.create_failed'), description: err.message, variant: 'destructive' });
|
||||
} catch (err) {
|
||||
toast({ title: t('users.create_failed'), description: te(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!venueId) return <LoadingState />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={(error as any).message || t('users.load_failed')} />;
|
||||
if (error) return <ErrorState message={te(error, 'users.load_failed')} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -77,7 +63,7 @@ export default function UsersPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
{userRole === 'admin' && (
|
||||
{role === 'admin' && (
|
||||
<Button size="sm" onClick={() => setAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t('users.add')}
|
||||
</Button>
|
||||
@@ -100,7 +86,7 @@ export default function UsersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u: any) => (
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLang();
|
||||
const { t, te } = useLang();
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -27,8 +27,8 @@ export default function LoginPage() {
|
||||
const result = await authApi.login(email, password);
|
||||
setTokens(result.accessToken, result.refreshToken);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('auth.invalid'));
|
||||
} catch (err) {
|
||||
setError(te(err, 'auth.invalid'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { readSession } from '@/lib/session';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='));
|
||||
if (cookie) {
|
||||
redirect('/dashboard');
|
||||
} else {
|
||||
redirect('/login');
|
||||
}
|
||||
}, []);
|
||||
router.replace(readSession() ? '/dashboard' : '/login');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { LanguageProvider } from '@/lib/i18n';
|
||||
import { Toaster } from '@/components/ui/toast';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
@@ -19,7 +20,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>{children}</LanguageProvider>
|
||||
<LanguageProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) {
|
||||
const { t } = useLang();
|
||||
const { t, te } = useLang();
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [sheet, setSheet] = useState<SheetData | null>(null);
|
||||
const [mapping, setMapping] = useState<ColumnMapping | null>(null);
|
||||
@@ -70,8 +70,8 @@ export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported
|
||||
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
|
||||
onImported();
|
||||
handleOpenChange(false);
|
||||
} catch (err: any) {
|
||||
toast({ title: t('import.failed'), description: err.message, variant: 'destructive' });
|
||||
} catch (err) {
|
||||
toast({ title: t('import.failed'), description: te(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -126,30 +126,37 @@ export {
|
||||
ToastAction,
|
||||
};
|
||||
|
||||
// Simple toast hook
|
||||
let toastCount = 0;
|
||||
const listeners: Set<(toast: any) => void> = new Set();
|
||||
type ToastMessage = {
|
||||
id: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: "default" | "destructive" | "success";
|
||||
};
|
||||
type ToastEvent = (ToastMessage & { open: true }) | { id: number; open: false };
|
||||
|
||||
export function toast(props: { title?: string; description?: string; variant?: "default" | "destructive" | "success" }) {
|
||||
let toastCount = 0;
|
||||
const listeners: Set<(event: ToastEvent) => void> = new Set();
|
||||
|
||||
export function toast(props: Omit<ToastMessage, "id">) {
|
||||
const id = ++toastCount;
|
||||
const newToast = { id, ...props, open: true };
|
||||
listeners.forEach((listener) => listener(newToast));
|
||||
setTimeout(() => {
|
||||
listeners.forEach((listener) => listener({ id, open: false }));
|
||||
}, 5000);
|
||||
listeners.forEach((listener) => listener({ id, ...props, open: true }));
|
||||
return id;
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
listeners.forEach((listener) => listener({ id, open: false }));
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = React.useState<any[]>([]);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = (toast: any) => {
|
||||
const listener = (event: ToastEvent) => {
|
||||
setToasts((prev) => {
|
||||
if (toast.open === false) {
|
||||
return prev.filter((t) => t.id !== toast.id);
|
||||
}
|
||||
return [...prev.filter((t) => t.id !== toast.id), toast];
|
||||
const others = prev.filter((t) => t.id !== event.id);
|
||||
if (!event.open) return others;
|
||||
const { open: _open, ...message } = event;
|
||||
return [...others, message];
|
||||
});
|
||||
};
|
||||
listeners.add(listener);
|
||||
@@ -157,4 +164,23 @@ export function useToast() {
|
||||
}, []);
|
||||
|
||||
return { toasts, toast };
|
||||
}
|
||||
|
||||
/** Shows the toasts; Radix closes each one after 5 seconds (paused while hovered). */
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
return (
|
||||
<ToastProvider duration={5000}>
|
||||
{toasts.map(({ id, title, description, variant }) => (
|
||||
<Toast key={id} variant={variant} open onOpenChange={(open) => !open && dismiss(id)}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,105 @@
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
|
||||
interface ApiError {
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function getTokens() {
|
||||
if (typeof window === 'undefined') return { accessToken: null };
|
||||
const accessToken = document.cookie
|
||||
export type Role = 'admin' | 'viewer';
|
||||
|
||||
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
export interface Tab {
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
version: number;
|
||||
totalInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
deviceLastSeenAt: string;
|
||||
deviceRevokedAt: string | null;
|
||||
lastReportedAt: string;
|
||||
plannedTabId: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired';
|
||||
|
||||
export interface Command {
|
||||
id: string;
|
||||
tabId: string;
|
||||
customerName: string | null;
|
||||
amountInCents: number | null;
|
||||
type: string;
|
||||
status: CommandStatus;
|
||||
paymentMethod: string;
|
||||
requestedBy: string;
|
||||
expectedVersion: number;
|
||||
createdAt: string;
|
||||
processedAt: string | null;
|
||||
result: string | null;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
lastSeenAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PlannedTab {
|
||||
id: string;
|
||||
customerName: string;
|
||||
email: string | null;
|
||||
position: number;
|
||||
createdAt: string;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
export interface ReminderCandidate {
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
email: string;
|
||||
amountInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
}
|
||||
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
email: string;
|
||||
amountInCents: number;
|
||||
sentAt: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function getCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='))
|
||||
?.split('=')[1];
|
||||
return { accessToken };
|
||||
.find((row) => row.startsWith(`${name}=`))
|
||||
?.split('=')[1] ?? null;
|
||||
}
|
||||
|
||||
function setTokens(accessToken: string, refreshToken: string) {
|
||||
// The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an
|
||||
// expired JWT triggers a 401 which is transparently refreshed below.
|
||||
document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
const attributes = `path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`;
|
||||
document.cookie = `access_token=${accessToken}; ${attributes}`;
|
||||
document.cookie = `refresh_token=${refreshToken}; ${attributes}`;
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
@@ -39,11 +120,7 @@ function refreshAccessToken(): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function doRefreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('refresh_token='))
|
||||
?.split('=')[1];
|
||||
|
||||
const refreshToken = getCookie('refresh_token');
|
||||
if (!refreshToken) return null;
|
||||
|
||||
try {
|
||||
@@ -63,11 +140,19 @@ async function doRefreshAccessToken(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = any>(
|
||||
async function request(endpoint: string, options: RequestInit, headers: Record<string, string>): Promise<Response> {
|
||||
try {
|
||||
return await fetch(`${API_URL}${endpoint}`, { ...options, headers });
|
||||
} catch (error) {
|
||||
throw { code: 'network_error', message: String(error) } satisfies ApiError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const { accessToken } = getTokens();
|
||||
const accessToken = getCookie('access_token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
@@ -77,25 +162,19 @@ export async function api<T = any>(
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
let res = await request(endpoint, options, headers);
|
||||
|
||||
if (res.status === 401 && accessToken) {
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
res = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
res = await request(endpoint, options, headers);
|
||||
} else {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Session expired');
|
||||
throw { code: 'session_expired', message: 'Session expired' } satisfies ApiError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,16 +193,13 @@ export async function api<T = any>(
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api<{ accessToken: string; refreshToken: string; user: any }>('/auth/login', {
|
||||
api<{ accessToken: string; refreshToken: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
logout: () => {
|
||||
const refreshToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('refresh_token='))
|
||||
?.split('=')[1];
|
||||
const refreshToken = getCookie('refresh_token');
|
||||
clearTokens();
|
||||
if (refreshToken) {
|
||||
api('/auth/logout', {
|
||||
@@ -137,20 +213,20 @@ export const authApi = {
|
||||
// Tabs API
|
||||
export const tabsApi = {
|
||||
list: (venueId: string) =>
|
||||
api<{ tabs: any[] }>(`/venues/${venueId}/tabs`),
|
||||
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
|
||||
};
|
||||
|
||||
// Devices API
|
||||
export const devicesApi = {
|
||||
get: (venueId: string) =>
|
||||
api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`),
|
||||
api<{ device: Device | null }>(`/venues/${venueId}/devices`),
|
||||
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
|
||||
};
|
||||
|
||||
// Users API
|
||||
export const usersApi = {
|
||||
list: (venueId: string) =>
|
||||
api<{ users: any[] }>(`/venues/${venueId}/users`),
|
||||
api<{ users: User[] }>(`/venues/${venueId}/users`),
|
||||
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
|
||||
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
};
|
||||
@@ -163,7 +239,7 @@ export const commandsApi = {
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params?.offset) searchParams.set('offset', String(params.offset));
|
||||
const qs = searchParams.toString();
|
||||
return api<{ commands: any[]; total: number }>(
|
||||
return api<{ commands: Command[]; total: number }>(
|
||||
`/venues/${venueId}/commands${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
@@ -176,13 +252,12 @@ export const commandsApi = {
|
||||
|
||||
// Venues API
|
||||
export const venuesApi = {
|
||||
list: () => api<{ id: string; name: string }[]>('/venues'),
|
||||
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
|
||||
};
|
||||
|
||||
// Planned Tabs API
|
||||
export const plannedTabsApi = {
|
||||
list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`),
|
||||
list: (venueId: string) => api<{ plannedTabs: PlannedTab[] }>(`/venues/${venueId}/planned-tabs`),
|
||||
create: (venueId: string, customerName: string, email?: string) =>
|
||||
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
|
||||
update: (venueId: string, id: string, customerName: string, email?: string) =>
|
||||
@@ -199,11 +274,11 @@ export const plannedTabsApi = {
|
||||
// Reminders API
|
||||
export const remindersApi = {
|
||||
candidates: (venueId: string) =>
|
||||
api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`),
|
||||
api<{ candidates: ReminderCandidate[] }>(`/venues/${venueId}/reminders/candidates`),
|
||||
send: (venueId: string) =>
|
||||
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }),
|
||||
history: (venueId: string) =>
|
||||
api<{ reminders: any[] }>(`/venues/${venueId}/reminders`),
|
||||
api<{ reminders: Reminder[] }>(`/venues/${venueId}/reminders`),
|
||||
sendTest: (venueId: string, plannedTabId: string) =>
|
||||
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
|
||||
};
|
||||
@@ -211,5 +286,5 @@ export const remindersApi = {
|
||||
export { setTokens, clearTokens, refreshAccessToken };
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return getTokens().accessToken ?? null;
|
||||
}
|
||||
return getCookie('access_token');
|
||||
}
|
||||
|
||||
@@ -23,6 +23,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'common.close': 'Close',
|
||||
'common.copied': 'Copied',
|
||||
'common.logged_out': 'Logged out',
|
||||
'errors.invalid_credentials': 'Invalid email or password',
|
||||
'errors.too_many_requests': 'Too many attempts. Wait a minute and try again.',
|
||||
'errors.validation_error': 'Some fields are not filled in correctly',
|
||||
'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.',
|
||||
'errors.command_already_pending': 'A close request for this tab is already waiting for the terminal',
|
||||
'errors.tab_not_owned': 'This tab is no longer open on the terminal',
|
||||
'errors.device_revoked': 'The terminal for this tab has been unpaired',
|
||||
'errors.name_exists': 'This customer already exists',
|
||||
'errors.email_exists': 'A user with this email already exists',
|
||||
'errors.resend_not_configured': 'Sending email is not set up on the server',
|
||||
'errors.reminders_in_progress': 'Reminders are already being sent',
|
||||
'errors.no_email': 'This customer has no email address',
|
||||
'errors.forbidden': "You don't have permission to do this",
|
||||
'errors.venue_forbidden': "You don't have permission to do this",
|
||||
'errors.internal_error': 'Something went wrong on the server',
|
||||
'errors.network_error': "Can't reach the server. Check the connection.",
|
||||
'errors.session_expired': 'Your session has expired. Log in again.',
|
||||
'errors.unknown': 'An error occurred',
|
||||
'status_page.home': 'Back to KoolTap',
|
||||
'not_found.title': 'Page not found',
|
||||
'not_found.desc': "This page doesn't exist or has been moved.",
|
||||
@@ -56,7 +74,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.email': 'Email',
|
||||
'tabs.amount': 'Amount',
|
||||
'tabs.items': 'Items',
|
||||
'tabs.version': 'Version',
|
||||
'tabs.search_placeholder': 'Search customers…',
|
||||
'tabs.sort': 'Sort by',
|
||||
'tabs.sort_name': 'Name',
|
||||
'tabs.sort_amount': 'Highest amount',
|
||||
'tabs.outstanding': '{amount} outstanding',
|
||||
'tabs.closing': 'Closing…',
|
||||
'tabs.no_matches': 'No tabs match "{query}"',
|
||||
'tabs.reported': 'Last updated',
|
||||
'tabs.actions': 'Actions',
|
||||
'tabs.close': 'Close',
|
||||
@@ -140,7 +164,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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.customer': 'Customer',
|
||||
'commands.amount': 'Amount',
|
||||
'commands.requested': 'Requested By',
|
||||
'commands.payment': 'Payment',
|
||||
'commands.created': 'Created',
|
||||
@@ -157,7 +182,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'settings.device': 'Terminal',
|
||||
'settings.paired': 'Paired as "{name}" — last seen {time}',
|
||||
'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 Terminal',
|
||||
@@ -178,7 +202,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'overview.title': 'Overview',
|
||||
'overview.open_tabs': 'Open Tabs',
|
||||
'overview.device': 'Terminal',
|
||||
'overview.pending': 'Pending Commands',
|
||||
'overview.pending': 'Waiting for terminal',
|
||||
'overview.outstanding': 'Outstanding',
|
||||
'overview.recent': 'Recent Commands',
|
||||
'overview.no_commands': 'No commands yet',
|
||||
'overview.not_connected': 'Not connected',
|
||||
@@ -199,6 +224,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'reminders.no_candidates_desc': 'Make sure planned customers have an email address.',
|
||||
'reminders.send': 'Send Reminders',
|
||||
'reminders.sending': 'Sending...',
|
||||
'reminders.admin_only': 'Only admins can send reminders.',
|
||||
'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',
|
||||
@@ -233,6 +259,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'common.close': 'Sluiten',
|
||||
'common.copied': 'Gekopieerd',
|
||||
'common.logged_out': 'Uitgelogd',
|
||||
'errors.invalid_credentials': 'Ongeldig e-mailadres of wachtwoord',
|
||||
'errors.too_many_requests': 'Te veel pogingen. Wacht een minuutje en probeer opnieuw.',
|
||||
'errors.validation_error': 'Sommige velden zijn niet correct ingevuld',
|
||||
'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.',
|
||||
'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa',
|
||||
'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa',
|
||||
'errors.device_revoked': 'De kassa van deze poef is ontkoppeld',
|
||||
'errors.name_exists': 'Deze klant bestaat al',
|
||||
'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres',
|
||||
'errors.resend_not_configured': 'E-mail versturen is niet ingesteld op de server',
|
||||
'errors.reminders_in_progress': 'Herinneringen worden al verstuurd',
|
||||
'errors.no_email': 'Deze klant heeft geen e-mailadres',
|
||||
'errors.forbidden': 'Je hebt geen toestemming om dit te doen',
|
||||
'errors.venue_forbidden': 'Je hebt geen toestemming om dit te doen',
|
||||
'errors.internal_error': 'Er ging iets mis op de server',
|
||||
'errors.network_error': 'Kan de server niet bereiken. Controleer de verbinding.',
|
||||
'errors.session_expired': 'Je sessie is verlopen. Log opnieuw in.',
|
||||
'errors.unknown': 'Er is een fout opgetreden',
|
||||
'status_page.home': 'Terug naar KoolTap',
|
||||
'not_found.title': 'Pagina niet gevonden',
|
||||
'not_found.desc': 'Deze pagina bestaat niet of is verplaatst.',
|
||||
@@ -266,7 +310,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.email': 'E-mail',
|
||||
'tabs.amount': 'Bedrag',
|
||||
'tabs.items': 'Items',
|
||||
'tabs.version': 'Versie',
|
||||
'tabs.search_placeholder': 'Zoek klant…',
|
||||
'tabs.sort': 'Sorteer op',
|
||||
'tabs.sort_name': 'Naam',
|
||||
'tabs.sort_amount': 'Hoogste bedrag',
|
||||
'tabs.outstanding': '{amount} openstaand',
|
||||
'tabs.closing': 'Wordt gesloten…',
|
||||
'tabs.no_matches': 'Geen poefs gevonden voor "{query}"',
|
||||
'tabs.reported': 'Laatst bijgewerkt',
|
||||
'tabs.actions': 'Acties',
|
||||
'tabs.close': 'Sluiten',
|
||||
@@ -350,7 +400,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'commands.load_failed': 'Opdrachten konden niet geladen worden',
|
||||
'commands.empty': 'Geen opdrachten gevonden',
|
||||
'commands.empty_desc': 'Opdrachten verschijnen hier zodra je een sluitverzoek verstuurt.',
|
||||
'commands.tab': 'Poef',
|
||||
'commands.customer': 'Klant',
|
||||
'commands.amount': 'Bedrag',
|
||||
'commands.requested': 'Aangevraagd door',
|
||||
'commands.payment': 'Betaling',
|
||||
'commands.created': 'Aangemaakt',
|
||||
@@ -367,7 +418,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'settings.device': 'Terminal',
|
||||
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
|
||||
'settings.not_paired': 'Nog geen terminal gekoppeld',
|
||||
'settings.connected': 'Verbonden',
|
||||
'settings.since': 'Sinds {date}',
|
||||
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
|
||||
'settings.pair': 'Terminal koppelen',
|
||||
@@ -388,7 +438,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'overview.title': 'Overzicht',
|
||||
'overview.open_tabs': 'Open poefs',
|
||||
'overview.device': 'Terminal',
|
||||
'overview.pending': 'Openstaande opdrachten',
|
||||
'overview.pending': 'Wachtend op kassa',
|
||||
'overview.outstanding': 'Openstaand',
|
||||
'overview.recent': 'Recente opdrachten',
|
||||
'overview.no_commands': 'Nog geen opdrachten',
|
||||
'overview.not_connected': 'Niet verbonden',
|
||||
@@ -409,6 +460,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
|
||||
'reminders.send': 'Herinneringen versturen',
|
||||
'reminders.sending': 'Bezig met versturen...',
|
||||
'reminders.admin_only': 'Alleen beheerders kunnen herinneringen versturen.',
|
||||
'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',
|
||||
@@ -436,6 +488,8 @@ interface LangContextType {
|
||||
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;
|
||||
/** An API error in the current language: by its code when translated, else its message. */
|
||||
te: (error: unknown, fallbackKey?: string) => string;
|
||||
/** "just now", "5m ago"…, falling back to a date after a week. */
|
||||
relativeTime: (date: string | Date) => string;
|
||||
dateTime: (date: string | Date) => string;
|
||||
@@ -459,6 +513,12 @@ function helpers(lang: Lang): Omit<LangContextType, 'setLang'> {
|
||||
lang,
|
||||
t,
|
||||
tn: (key, count, params) => t(count === 1 ? key : `${key}_plural`, { count, ...params }),
|
||||
te: (error, fallbackKey = 'common.unknown_error') => {
|
||||
const { code, message } = (error ?? {}) as { code?: string; message?: string };
|
||||
const key = `errors.${code}`;
|
||||
if (code && (translations[lang][key] ?? translations.en[key])) return t(key);
|
||||
return message || t(fallbackKey);
|
||||
},
|
||||
relativeTime: (value) => {
|
||||
const date = new Date(value);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { getCookie, type Role } from '@/lib/api/client';
|
||||
|
||||
export interface Session {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
venueId: string;
|
||||
}
|
||||
|
||||
/** The signed-in user from the access token cookie, or null when there is none or it can't be read. */
|
||||
export function readSession(): Session | null {
|
||||
const token = getCookie('access_token');
|
||||
if (!token) return null;
|
||||
try {
|
||||
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(base64));
|
||||
if (!payload.venueId || !payload.role) return null;
|
||||
return { userId: payload.sub, email: payload.email, role: payload.role, venueId: payload.venueId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SessionContext = createContext<Session | null>(null);
|
||||
|
||||
export function SessionProvider({ session, children }: { session: Session; children: ReactNode }) {
|
||||
return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): Session {
|
||||
const session = useContext(SessionContext);
|
||||
if (!session) throw new Error('useSession must be used inside the dashboard layout');
|
||||
return session;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export function formatCents(cents: number, currency: string = 'EUR'): string {
|
||||
const euros = cents / 100;
|
||||
return new Intl.NumberFormat('nl-NL', {
|
||||
return new Intl.NumberFormat('nl-BE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(euros);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user