feat: add plannedTabs

This commit is contained in:
2026-09-20 12:20:55 +02:00
parent 4eb9bf7643
commit db3d8f30f0
28 changed files with 1172 additions and 782 deletions
@@ -87,26 +87,22 @@ export default function CommandsPage() {
<>
<div className="rounded-md border mb-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tab</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Device</TableHead>
<TableHead>Requested By</TableHead>
<TableHead>Payment</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{commands.map((cmd: any) => (
<TableRow key={cmd.id}>
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-xs">{cmd.deviceId?.substring(0, 8)}</TableCell>
<TableCell>{cmd.requestedBy}</TableCell>
<TableHeader>
<TableRow>
<TableHead>Tab</TableHead>
<TableHead>Requested By</TableHead>
<TableHead>Payment</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{commands.map((cmd: any) => (
<TableRow key={cmd.id}>
<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><CommandStatusBadge status={cmd.status} /></TableCell>
@@ -1,229 +0,0 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api, devicesApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { ConfirmDialog } from '@/components/confirm-dialog';
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 { formatRelativeTime } from '@/lib/utils';
import { RefreshCw, Plus, Copy } from 'lucide-react';
export default function DevicesPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [venueId, setVenueId] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(null);
const [revokeDialog, setRevokeDialog] = useState<{ open: boolean; device: any }>({ open: false, device: null });
const [revoking, setRevoking] = useState(false);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = 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);
setUserRole(payload.role);
}, [router]);
const { data, isLoading, error } = useQuery({
queryKey: ['devices', venueId],
queryFn: () => api(`/venues/${venueId}/devices`),
enabled: !!venueId,
refetchInterval: 15000,
});
const devices = data?.devices || [];
const revokeMutation = useMutation({
mutationFn: (deviceId: string) => devicesApi.revoke(venueId!, deviceId),
onSuccess: () => {
toast({ title: 'Device revoked', description: 'Device has been revoked successfully', variant: 'destructive' });
queryClient.invalidateQueries({ queryKey: ['devices'] });
},
onError: (err: any) => {
toast({ title: 'Failed to revoke device', description: err.message, variant: 'destructive' });
},
});
function handleRevoke(device: any) {
setRevokeDialog({ open: true, device });
}
function confirmRevoke() {
setRevoking(true);
revokeMutation.mutate(revokeDialog.device.id, {
onSettled: () => {
setRevoking(false);
setRevokeDialog({ open: false, device: null });
},
});
}
async function handleGenerateCode() {
setGenerating(true);
try {
const result = await devicesApi.generateCode();
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err: any) {
toast({ title: 'Failed to generate code', description: err.message, variant: 'destructive' });
} finally {
setGenerating(false);
}
}
function copyCode() {
if (regDialog) {
navigator.clipboard.writeText(regDialog.code);
toast({ title: 'Code copied', description: 'Registration code copied to clipboard' });
}
}
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load devices'} />;
return (
<div>
<PageHeader
title="Devices"
description={`${devices.length} device${devices.length !== 1 ? 's' : ''} registered`}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['devices'] })}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
{userRole === 'admin' && (
<Button size="sm" onClick={handleGenerateCode} disabled={generating}>
<Plus className="h-4 w-4 mr-2" />
{generating ? 'Generating...' : 'Add Device'}
</Button>
)}
</div>
}
/>
{devices.length === 0 ? (
<EmptyState title="No devices registered" description="Register a device using the Add Device button to get started." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device: any) => (
<TableRow key={device.id}>
<TableCell className="font-medium">{device.name}</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{device.id.substring(0, 8)}...
</TableCell>
<TableCell>{formatRelativeTime(device.lastSeenAt)}</TableCell>
<TableCell>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} revokedAt={device.revokedAt} />
</TableCell>
<TableCell>{formatRelativeTime(device.createdAt)}</TableCell>
<TableCell className="text-right">
{device.revokedAt ? (
<span className="text-sm text-muted-foreground">Revoked</span>
) : userRole === 'admin' ? (
<Button
size="sm"
variant="destructive"
onClick={() => handleRevoke(device)}
>
Revoke
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{revokeDialog.device && (
<ConfirmDialog
open={revokeDialog.open}
onOpenChange={(open) => !open && setRevokeDialog({ open: false, device: null })}
title="Revoke device?"
description={`Are you sure you want to revoke "${revokeDialog.device.name}"? This action cannot be undone.`}
confirmLabel="Revoke"
variant="destructive"
onConfirm={confirmRevoke}
loading={revoking}
/>
)}
{regDialog && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Device Registration</DialogTitle>
<DialogDescription>
Share this code with the bar terminal to register it.
</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>
<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()}
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">Instructions:</p>
<ol className="list-decimal list-inside text-muted-foreground space-y-1">
<li>Open Admin on the bar terminal</li>
<li>Enter the Admin PIN</li>
<li>Enter this registration code</li>
<li>Give the terminal a name</li>
<li>Complete registration</li>
</ol>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setRegDialog(null)}>
Close
</Button>
<Button onClick={copyCode}>
<Copy className="h-4 w-4 mr-2" />
Copy code
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ import { toast } from '@/components/ui/toast';
const navItems = [
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList },
{ href: '/dashboard/devices', label: 'Devices', icon: Monitor },
{ href: '/dashboard/users', label: 'Users', icon: Monitor },
{ href: '/dashboard/commands', label: 'Command History', icon: Terminal },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
];
+38 -91
View File
@@ -9,10 +9,8 @@ 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 { DeviceStatusBadge } from '@/components/device-status-badge';
import { formatRelativeTime, formatCents } from '@/lib/utils';
import { getDeviceStatus } from '@/lib/device-status';
import { ClipboardList, Monitor, Terminal, AlertTriangle } from 'lucide-react';
import { formatRelativeTime } from '@/lib/utils';
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
export default function DashboardPage() {
const router = useRouter();
@@ -37,8 +35,8 @@ export default function DashboardPage() {
refetchInterval: 10000,
});
const devicesQuery = useQuery({
queryKey: ['devices', venueId],
const deviceQuery = useQuery({
queryKey: ['device', venueId],
queryFn: () => api(`/venues/${venueId}/devices`),
enabled: !!venueId,
refetchInterval: 30000,
@@ -54,64 +52,39 @@ export default function DashboardPage() {
if (!venueId) return <LoadingState />;
const tabs = tabsQuery.data?.tabs || [];
const devices = devicesQuery.data?.devices || [];
const device = deviceQuery.data?.device;
const commands = commandsQuery.data?.commands || [];
const onlineDevices = devices.filter((d: any) => !d.revokedAt && getDeviceStatus(d.lastSeenAt) === 'online');
const staleDevices = devices.filter((d: any) => !d.revokedAt && (getDeviceStatus(d.lastSeenAt) === 'stale' || getDeviceStatus(d.lastSeenAt) === 'offline'));
const pendingCommands = commands.filter((c: any) => c.status === 'pending');
const commandsToday = commands.length;
const isLoading = tabsQuery.isLoading || devicesQuery.isLoading || commandsQuery.isLoading;
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading;
return (
<div>
<PageHeader
title="Overview"
description="Real-time overview of your venue"
/>
<PageHeader title="Overview" description="Real-time overview of your venue" />
{isLoading ? (
<div className="grid gap-4 md: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 className="grid gap-4 md:grid-cols-3 mb-8">
{Array.from({ length: 3 }).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-2 lg:grid-cols-4 mb-8">
<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>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{tabs.length}</div>
</CardContent>
<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">Online Devices</CardTitle>
<Monitor className="h-4 w-4 text-green-600" />
<CardTitle className="text-sm font-medium">Device</CardTitle>
<Monitor className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{onlineDevices.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Stale/Offline Devices</CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">{staleDevices.length}</div>
<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>}
</CardContent>
</Card>
<Card>
@@ -119,60 +92,34 @@ export default function DashboardPage() {
<CardTitle className="text-sm font-medium">Pending Commands</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">{pendingCommands.length}</div></CardContent>
</Card>
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Commands</CardTitle>
</CardHeader>
<CardContent>
{commands.length === 0 ? (
<p className="text-sm text-muted-foreground">No commands yet</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>
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
</div>
<div className="flex items-center gap-2">
<CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">Recent Commands</CardTitle></CardHeader>
<CardContent>
{commands.length === 0 ? (
<p className="text-sm text-muted-foreground">No commands yet</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>
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Device Health</CardTitle>
</CardHeader>
<CardContent>
{devices.length === 0 ? (
<p className="text-sm text-muted-foreground">No devices registered</p>
) : (
<div className="space-y-3">
{devices.map((device: any) => (
<div key={device.id} className="flex items-center justify-between text-sm">
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} revokedAt={device.revokedAt} />
<div className="flex items-center gap-2">
<CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,66 +1,168 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { api, devicesApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
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 { 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 [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = 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);
setUserRole(payload.role);
}, [router]);
const { data: venue } = useQuery({
queryKey: ['venue', venueId],
queryFn: () => api(`/venues/${venueId}`),
enabled: !!venueId,
});
const { data: deviceData } = useQuery({
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId!),
enabled: !!venueId,
refetchInterval: 15000,
});
const device = deviceData?.device;
async function handleGenerateCode() {
setGenerating(true);
try {
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' });
} finally {
setGenerating(false);
}
}
function copyCode() {
if (regDialog) {
navigator.clipboard.writeText(regDialog.code);
toast({ title: 'Copied', description: 'Registration code copied to clipboard' });
}
}
return (
<div>
<PageHeader title="Settings" description="Venue and account settings" />
<PageHeader title="Settings" description="Venue and device management" />
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Venue Information</CardTitle>
<CardTitle>Venue</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Venue name</span>
<span>KoolTab Demo Bar</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<Badge variant="secondary">Demo</Badge>
</div>
<CardContent className="text-sm">
<p className="font-medium">{venue?.name || 'Loading...'}</p>
<p className="text-muted-foreground font-mono text-xs mt-1">ID: {venueId}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Demo Credentials</CardTitle>
<CardTitle>Device</CardTitle>
<CardDescription>
{device
? `Paired as "${device.name}" — last seen ${formatRelativeTime(device.lastSeenAt)}`
: 'No device paired yet'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="p-3 bg-muted rounded-md">
<p className="font-medium mb-1">Admin account</p>
<p className="text-muted-foreground">admin@example.com / Admin123!</p>
</div>
<div className="p-3 bg-muted rounded-md">
<p className="font-medium mb-1">Viewer account</p>
<p className="text-muted-foreground">viewer@example.com / Viewer123!</p>
</div>
<p className="text-xs text-muted-foreground">
These credentials are for demo purposes only. Do not use in production.
</p>
<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>
</div>
) : (
<p className="text-sm text-muted-foreground mb-3">
Pair a bar terminal to start receiving tabs.
</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'}
</Button>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Information</CardTitle>
<CardTitle>API</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">API Base URL</span>
<span className="text-muted-foreground">Base URL</span>
<span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Swagger Docs</span>
<span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'}</span>
<span className="text-muted-foreground">Swagger</span>
<span className="font-mono text-xs">
{process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'}
</span>
</div>
</CardContent>
</Card>
</div>
{regDialog && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Pair a Device</DialogTitle>
<DialogDescription>
Enter this code on the bar terminal to connect it to your venue.
</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>
<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()}
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">Instructions:</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>
</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>
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}
+111 -149
View File
@@ -3,23 +3,21 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api, commandsApi } from '@/lib/api/client';
import { api, commandsApi, plannedTabsApi } 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { ConfirmDialog } from '@/components/confirm-dialog';
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 { 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 { getDeviceStatus } from '@/lib/device-status';
import { RefreshCw, XCircle, AlertTriangle } from 'lucide-react';
import { RefreshCw, Plus, X, Users } from 'lucide-react';
export default function TabsPage() {
const router = useRouter();
@@ -29,17 +27,15 @@ export default function TabsPage() {
const [paymentMethod, setPaymentMethod] = useState<string>('');
const [creating, setCreating] = useState(false);
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
const [plannedName, setPlannedName] = useState('');
const [userRole, setUserRole] = useState<string | null>(null);
useEffect(() => {
const cookie = document.cookie
.split('; ')
.find((row) => row.startsWith('access_token='));
if (!cookie) {
router.push('/login');
return;
}
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 { data, isLoading, error } = useQuery({
@@ -49,7 +45,6 @@ export default function TabsPage() {
refetchInterval: 10000,
});
// Poll pending commands to check their status
const commandsQuery = useQuery({
queryKey: ['commands', venueId, 'pending'],
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
@@ -57,6 +52,12 @@ export default function TabsPage() {
refetchInterval: 3000,
});
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId!),
enabled: !!venueId,
});
useEffect(() => {
if (commandsQuery.data?.commands) {
for (const cmd of commandsQuery.data.commands) {
@@ -64,11 +65,7 @@ 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',
});
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' });
queryClient.invalidateQueries({ queryKey: ['tabs'] });
queryClient.invalidateQueries({ queryKey: ['commands'] });
}
@@ -81,44 +78,41 @@ export default function TabsPage() {
commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
onSuccess: (result) => {
setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
toast({
title: 'Close request sent',
description: result.warning || 'Command has been created and sent to the device',
variant: result.warning ? 'default' : 'success',
});
toast({ title: 'Close request sent', description: result.warning || 'Command sent to the terminal', variant: result.warning ? 'default' : 'success' });
queryClient.invalidateQueries({ queryKey: ['commands'] });
},
onError: (err: any) => {
toast({
title: 'Failed to create command',
description: err.message || 'An error occurred',
variant: 'destructive',
});
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
},
});
function handleCloseTab(tab: any) {
setCloseDialog({ open: true, tab });
setPaymentMethod('');
}
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('');
},
},
);
createCommandMutation.mutate({ tabId: closeDialog.tab.tabId, expectedVersion: closeDialog.tab.version, paymentMethod }, {
onSettled: () => { setCreating(false); setCloseDialog({ open: false, tab: null }); setPaymentMethod(''); },
});
}
async function addPlannedName() {
if (!plannedName.trim()) return;
try {
await plannedTabsApi.create(venueId!, plannedName.trim());
setPlannedName('');
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
} catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
}
}
async function removePlannedName(id: string) {
try {
await plannedTabsApi.remove(venueId!, id);
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
} catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' });
}
}
if (!venueId) return <LoadingState />;
@@ -131,21 +125,53 @@ export default function TabsPage() {
<div>
<PageHeader
title="Open Tabs"
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''} across all devices`}
actions={
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
}
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>}
/>
{/* Planned Customers */}
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
These names are synced to the terminal. It will create tabs for these customers automatically.
</p>
{userRole === 'admin' && (
<div className="flex gap-2 mb-3">
<Input
placeholder="Add customer name..."
value={plannedName}
onChange={(e) => setPlannedName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()}>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex flex-wrap gap-2">
{plannedQuery.data?.plannedTabs?.length === 0 && (
<p className="text-sm text-muted-foreground">No planned customers. Add names above and they'll sync to the terminal.</p>
)}
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{pt.customerName}
{userRole === 'admin' && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-1">
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</CardContent>
</Card>
{tabs.length === 0 ? (
<EmptyState title="No open tabs" description="All tabs are closed. Open tabs will appear here when devices sync." />
<EmptyState title="No open tabs" description="Open tabs will appear here when the terminal syncs." />
) : (
<div className="rounded-md border">
<Table>
@@ -154,117 +180,53 @@ export default function TabsPage() {
<TableHead>Customer</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Items</TableHead>
<TableHead>Device</TableHead>
<TableHead>Device Status</TableHead>
<TableHead>Version</TableHead>
<TableHead>Last Reported</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tabs.map((tab: any) => {
const deviceStale = getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'stale'
|| getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'offline';
return (
<TableRow key={tab.tabId}>
<TableCell className="font-medium">{tab.customerName}</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.deviceName}</TableCell>
<TableCell>
<DeviceStatusBadge lastSeenAt={tab.deviceLastSeenAt} revokedAt={tab.deviceRevokedAt} />
</TableCell>
<TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => handleCloseTab(tab)}
disabled={!!tab.deviceRevokedAt}
>
{tab.deviceRevokedAt ? 'Device revoked' : 'Close'}
</Button>
</TableCell>
</TableRow>
);
})}
{tabs.map((tab: any) => (
<TableRow key={tab.tabId}>
<TableCell className="font-medium">{tab.customerName}</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Close Tab Dialog */}
{closeDialog.tab && (
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle>
<DialogDescription>
This will send a close request to the bar terminal.
</DialogDescription>
<DialogDescription>Send a close request to the terminal.</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">Device</span>
<span>{closeDialog.tab.deviceName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Version</span>
<span>{closeDialog.tab.version}</span>
</div>
{closeDialog.tab.deviceLastSeenAt && getDeviceStatus(closeDialog.tab.deviceLastSeenAt) !== 'online' && (
<div className="flex items-center gap-2 text-sm text-yellow-600 bg-yellow-50 p-3 rounded-md">
<AlertTriangle className="h-4 w-4" />
Device has not been seen recently
</div>
)}
<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">Version</span><span>{closeDialog.tab.version}</span></div>
<div className="space-y-3 pt-2">
<Label className="text-base font-semibold">Payment method</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">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>
</RadioGroup>
{!paymentMethod && (
<p className="text-xs text-destructive">Please select a payment method</p>
)}
{!paymentMethod && <p className="text-xs text-destructive">Please select a payment method</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}>Cancel</Button>
<Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? 'Sending...' : 'Send close request'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,155 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { usersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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 { formatRelativeTime } from '@/lib/utils';
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 [addDialog, setAddDialog] = useState(false);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
const [submitting, setSubmitting] = 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);
setUserRole(payload.role);
}, [router]);
const { data, isLoading, error } = useQuery({
queryKey: ['users', venueId],
queryFn: () => usersApi.list(venueId!),
enabled: !!venueId,
});
const users = data?.users || [];
async function handleCreateUser() {
if (!form.email || !form.password || !form.name) return;
setSubmitting(true);
try {
await usersApi.create(venueId!, form);
toast({ title: 'User created', description: `${form.email} added as ${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' });
} finally {
setSubmitting(false);
}
}
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load users'} />;
return (
<div>
<PageHeader
title="Users"
description={`${users.length} user${users.length !== 1 ? 's' : ''} registered`}
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
</Button>
{userRole === 'admin' && (
<Button size="sm" onClick={() => setAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> Add User
</Button>
)}
</div>
}
/>
{users.length === 0 ? (
<EmptyState title="No users" description="Add users who can manage tabs from this dashboard." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u: any) => (
<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>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Dialog open={addDialog} onOpenChange={setAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>Create a new user who can access the dashboard.</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" />
</div>
<div className="space-y-2">
<Label htmlFor="email">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" />
</div>
<div className="space-y-2">
<Label htmlFor="role">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>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={submitting || !form.email || !form.password || !form.name}>
{submitting ? 'Creating...' : 'Create User'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+21 -4
View File
@@ -128,13 +128,19 @@ export const tabsApi = {
// Devices API
export const devicesApi = {
list: (venueId: string) =>
api<{ devices: any[] }>(`/venues/${venueId}/devices`),
revoke: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/devices/${deviceId}/revoke`, { method: 'POST' }),
get: (venueId: string) =>
api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | 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`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
};
// Commands API
export const commandsApi = {
list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => {
@@ -160,4 +166,15 @@ export const venuesApi = {
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`),
create: (venueId: string, customerName: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName }) }),
update: (venueId: string, id: string, customerName: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName }) }),
remove: (venueId: string, id: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
};
export { setTokens, clearTokens };