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