@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { adminApi, type AdminDevice } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { DeviceStatusBadge } from '@/components/device-status-badge';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { Checkbox } from './shared';
|
||||
|
||||
export function DevicesSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, te, relativeTime, dateTime } = useLang();
|
||||
const [showUnpaired, setShowUnpaired] = useState(false);
|
||||
const [unpairing, setUnpairing] = useState<AdminDevice | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'devices'],
|
||||
queryFn: adminApi.devices,
|
||||
// A terminal going offline sends no event, so its status is polled
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const codes = useQuery({ queryKey: ['admin', 'registration-codes'], queryFn: adminApi.registrationCodes });
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
|
||||
const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
|
||||
const unpair = useMutation({
|
||||
mutationFn: (device: AdminDevice) => adminApi.revokeDevice(device.id),
|
||||
onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }),
|
||||
onError,
|
||||
onSettled: () => {
|
||||
setUnpairing(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
const revokeCode = useMutation({ mutationFn: adminApi.revokeRegistrationCode, onError, onSettled: refresh });
|
||||
const cleanupCodes = useMutation({
|
||||
mutationFn: adminApi.cleanupRegistrationCodes,
|
||||
onSuccess: ({ deleted }) => toast({ title: t('admin.codes.cleaned', { count: deleted }), variant: 'success' }),
|
||||
onError,
|
||||
onSettled: refresh,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={te(error)} />;
|
||||
|
||||
const all = data?.devices ?? [];
|
||||
const devices = showUnpaired ? all : all.filter((d) => !d.revokedAt);
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Checkbox checked={showUnpaired} onChange={setShowUnpaired} label={t('admin.devices.show_unpaired', { count: all.length - all.filter((d) => !d.revokedAt).length })} />
|
||||
<Button variant="outline" size="sm" onClick={refresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.devices.name')}</TableHead>
|
||||
<TableHead>{t('admin.devices.venue')}</TableHead>
|
||||
<TableHead>{t('admin.devices.status')}</TableHead>
|
||||
<TableHead>{t('admin.devices.last_seen')}</TableHead>
|
||||
<TableHead>{t('admin.devices.token_expires')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.devices.pending')}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((device) => (
|
||||
<TableRow key={device.id}>
|
||||
<TableCell className="font-medium">
|
||||
{device.name}
|
||||
<span className="block text-xs text-muted-foreground font-mono">{device.tokenPrefix}…</span>
|
||||
</TableCell>
|
||||
<TableCell>{device.venueName}</TableCell>
|
||||
<TableCell>
|
||||
{device.revokedAt ? <Badge variant="secondary">{t('admin.devices.unpaired')}</Badge> : <DeviceStatusBadge lastSeenAt={device.lastSeenAt} />}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{relativeTime(device.lastSeenAt)}</TableCell>
|
||||
<TableCell className={!device.revokedAt && new Date(device.tokenExpiresAt).getTime() < now ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{dateTime(device.tokenExpiresAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{device.pendingCommands || ''}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!device.revokedAt && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setUnpairing(device)}>{t('settings.unpair')}</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.codes.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.codes.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(codes.data?.codes ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.codes.none')}</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border text-sm">
|
||||
{codes.data!.codes.map((code) => (
|
||||
<li key={code.id} className="flex flex-wrap items-center gap-3 px-3 py-2">
|
||||
<span className="font-mono font-bold tracking-widest">{code.code}</span>
|
||||
<span>{code.venueName}</span>
|
||||
<span className="text-muted-foreground">{t('admin.venues.pair_expires', { time: dateTime(code.expiresAt) })}</span>
|
||||
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => revokeCode.mutate(code.id)} disabled={revokeCode.isPending}>
|
||||
{t('admin.codes.revoke')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => cleanupCodes.mutate()} disabled={!codes.data?.stale || cleanupCodes.isPending}>
|
||||
{t('admin.codes.cleanup', { count: codes.data?.stale ?? 0 })}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={unpairing !== null}
|
||||
onOpenChange={(open) => !open && !unpair.isPending && setUnpairing(null)}
|
||||
title={t('settings.unpair_title', { name: unpairing?.name ?? '' })}
|
||||
description={t('settings.unpair_desc')}
|
||||
confirmLabel={t('settings.unpair')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
variant="destructive"
|
||||
loading={unpair.isPending}
|
||||
onConfirm={() => unpairing && unpair.mutate(unpairing)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { adminApi } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { formatBytes } from './shared';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const ALL_VENUES = 'all';
|
||||
|
||||
export function AuditSection() {
|
||||
const { t, te, dateTime } = useLang();
|
||||
const [venueId, setVenueId] = useState(ALL_VENUES);
|
||||
const [action, setAction] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin', 'audit', venueId, action.trim(), page],
|
||||
queryFn: () => adminApi.audit({
|
||||
venueId: venueId === ALL_VENUES ? undefined : venueId,
|
||||
action: action.trim() || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
}),
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-48">
|
||||
<Select value={venueId} onValueChange={(v) => { setVenueId(v); setPage(0); }}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VENUES}>{t('admin.all_venues')}</SelectItem>
|
||||
{(venues.data?.venues ?? []).map((v) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input placeholder={t('admin.audit.action_filter')} value={action} onChange={(e) => { setAction(e.target.value); setPage(0); }} className="max-w-xs" />
|
||||
<Button variant="outline" size="sm" className="ml-auto" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState /> : error ? <ErrorState message={te(error)} /> : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.audit.when')}</TableHead>
|
||||
<TableHead>{t('admin.devices.venue')}</TableHead>
|
||||
<TableHead>{t('admin.audit.who')}</TableHead>
|
||||
<TableHead>{t('admin.audit.action')}</TableHead>
|
||||
<TableHead>{t('admin.audit.details')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.entries ?? []).map((entry) => {
|
||||
const details = entry.details === null ? '' : typeof entry.details === 'string' ? entry.details : JSON.stringify(entry.details);
|
||||
return (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">{dateTime(entry.createdAt)}</TableCell>
|
||||
<TableCell>{entry.venueName}</TableCell>
|
||||
<TableCell>
|
||||
{entry.actorName ?? <span className="font-mono text-xs">{entry.actorId.slice(0, 8)}</span>}
|
||||
<span className="block text-xs text-muted-foreground">{entry.actorType}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={entry.action.startsWith('admin_') ? 'warning' : 'secondary'}>{entry.action}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground" title={details}>{details}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t('admin.audit.page', { page: page + 1, pages, total })}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((p) => p - 1)} disabled={page === 0}>{t('admin.audit.previous')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page + 1 >= pages}>{t('admin.audit.next')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
export function HealthSection() {
|
||||
const { t, te, relativeTime } = useLang();
|
||||
const { data, isLoading, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'health'],
|
||||
queryFn: adminApi.health,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error || !data) return <ErrorState message={te(error)} />;
|
||||
|
||||
const stats: { label: string; value: string; bad?: boolean }[] = [
|
||||
{ label: t('admin.health.database'), value: formatBytes(data.databaseBytes) },
|
||||
{ label: t('admin.health.uptime'), value: formatUptime(data.uptimeSeconds) },
|
||||
{ label: t('admin.health.connections'), value: String(data.realtimeConnections) },
|
||||
{
|
||||
label: t('admin.health.migrations'),
|
||||
value: data.migrations.failed.length > 0 ? t('admin.health.migrations_failed', { count: data.migrations.failed.length }) : String(data.migrations.applied),
|
||||
bad: data.migrations.failed.length > 0,
|
||||
},
|
||||
{
|
||||
label: t('admin.health.expiry_job'),
|
||||
value: data.commandExpiryLastRunAt ? relativeTime(data.commandExpiryLastRunAt) : t('admin.health.not_yet'),
|
||||
},
|
||||
{ label: t('admin.health.stuck'), value: String(data.stuckCommands), bad: data.stuckCommands > 0 },
|
||||
{ label: t('admin.health.email'), value: data.emailConfigured ? t('admin.health.configured') : t('admin.health.not_configured'), bad: !data.emailConfigured },
|
||||
{ label: t('admin.health.node'), value: data.nodeVersion },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.label}>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||
<p className={`text-xl font-semibold mt-1 tabular-nums ${stat.bad ? 'text-destructive' : ''}`}>{stat.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.migrations.failed.length > 0 && (
|
||||
<p className="text-sm text-destructive">{t('admin.health.failed_list', { names: data.migrations.failed.join(', ') })}</p>
|
||||
)}
|
||||
{data.migrations.latest && (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.health.latest_migration')} <span className="font-mono">{data.migrations.latest}</span></p>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.health.tables')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.tables.map(({ table, rows }) => (
|
||||
<li key={table} className="flex justify-between border-b py-1">
|
||||
<span className="font-mono text-xs">{table}</span>
|
||||
<span className="tabular-nums">{rows.toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi, PURGE_TARGETS, type AdminVenue, type PurgeTarget } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { Checkbox, CountList, DangerDialog, formatBytes } from './shared';
|
||||
|
||||
const ALL_VENUES = 'all';
|
||||
|
||||
export function MaintenanceSection() {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<StuckCommandsCard />
|
||||
<ImagesCard />
|
||||
<PurgeCard />
|
||||
<TestEmailCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useFeedback() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, te } = useLang();
|
||||
return {
|
||||
onError: (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
refresh: () => queryClient.invalidateQueries({ queryKey: ['admin'] }),
|
||||
};
|
||||
}
|
||||
|
||||
function StuckCommandsCard() {
|
||||
const { t } = useLang();
|
||||
const { onError, refresh } = useFeedback();
|
||||
const preview = useQuery({ queryKey: ['admin', 'stuck-commands'], queryFn: () => adminApi.expireStuckCommands(true) });
|
||||
const expire = useMutation({
|
||||
mutationFn: () => adminApi.expireStuckCommands(false),
|
||||
onSuccess: ({ count }) => toast({ title: t('admin.stuck.done', { count }), variant: 'success' }),
|
||||
onError,
|
||||
onSettled: refresh,
|
||||
});
|
||||
const count = preview.data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.stuck.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.stuck.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm">{preview.isLoading ? t('common.loading') : t('admin.stuck.count', { count })}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => expire.mutate()} disabled={count === 0 || expire.isPending}>
|
||||
{expire.isPending ? t('admin.working') : t('admin.stuck.confirm')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagesCard() {
|
||||
const { t } = useLang();
|
||||
const { onError, refresh } = useFeedback();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const preview = useQuery({ queryKey: ['admin', 'orphan-images'], queryFn: () => adminApi.cleanupImages(true) });
|
||||
const cleanup = useMutation({
|
||||
mutationFn: () => adminApi.cleanupImages(false),
|
||||
onSuccess: ({ count }) => {
|
||||
toast({ title: t('admin.images.done', { count }), variant: 'success' });
|
||||
setConfirming(false);
|
||||
},
|
||||
onError,
|
||||
onSettled: refresh,
|
||||
});
|
||||
const count = preview.data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.images.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.images.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm">
|
||||
{preview.isLoading ? t('common.loading') : t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirming(true)} disabled={count === 0}>
|
||||
{t('admin.images.confirm')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
<DangerDialog
|
||||
open={confirming}
|
||||
onOpenChange={setConfirming}
|
||||
title={t('admin.images.title')}
|
||||
description={t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })}
|
||||
confirmLabel={t('admin.images.confirm')}
|
||||
loading={cleanup.isPending}
|
||||
onConfirm={() => cleanup.mutate()}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PurgeCard() {
|
||||
const { t, te } = useLang();
|
||||
const { onError, refresh } = useFeedback();
|
||||
const [targets, setTargets] = useState<PurgeTarget[]>(['tabEvents', 'commands']);
|
||||
const [days, setDays] = useState('90');
|
||||
const [venueId, setVenueId] = useState(ALL_VENUES);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
|
||||
const olderThanDays = parseInt(days, 10);
|
||||
const valid = targets.length > 0 && Number.isInteger(olderThanDays) && olderThanDays >= 1;
|
||||
const request = { targets, olderThanDays, venueId: venueId === ALL_VENUES ? undefined : venueId };
|
||||
|
||||
const preview = useQuery({
|
||||
queryKey: ['admin', 'purge-preview', [...targets].sort().join(','), olderThanDays, venueId],
|
||||
queryFn: () => adminApi.purge({ ...request, dryRun: true }),
|
||||
enabled: confirming && valid,
|
||||
});
|
||||
const purge = useMutation({
|
||||
mutationFn: () => adminApi.purge({ ...request, dryRun: false }),
|
||||
onSuccess: ({ counts }) => {
|
||||
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
|
||||
toast({ title: t('admin.purge.done', { count: total }), variant: 'success' });
|
||||
setConfirming(false);
|
||||
},
|
||||
onError,
|
||||
onSettled: refresh,
|
||||
});
|
||||
|
||||
function toggle(target: PurgeTarget, on: boolean) {
|
||||
setTargets((current) => (on ? [...current, target] : current.filter((x) => x !== target)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.purge.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.purge.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
{PURGE_TARGETS.map((target) => (
|
||||
<Checkbox key={target} checked={targets.includes(target)} onChange={(on) => toggle(target, on)}
|
||||
label={t(`admin.purge.target.${target}`)} hint={t(`admin.purge.target.${target}_hint`)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purge-days">{t('admin.purge.days')}</Label>
|
||||
<Input id="purge-days" type="number" min={1} value={days} onChange={(e) => setDays(e.target.value)} className="w-28" />
|
||||
</div>
|
||||
<div className="space-y-1 min-w-48">
|
||||
<Label>{t('admin.devices.venue')}</Label>
|
||||
<Select value={venueId} onValueChange={setVenueId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VENUES}>{t('admin.all_venues')}</SelectItem>
|
||||
{(venues.data?.venues ?? []).map((v: AdminVenue) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirming(true)} disabled={!valid}>
|
||||
{t('admin.purge.preview')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
<DangerDialog
|
||||
open={confirming}
|
||||
onOpenChange={setConfirming}
|
||||
title={t('admin.purge.title')}
|
||||
description={t('admin.purge.confirm_desc', { days: olderThanDays })}
|
||||
confirmLabel={t('admin.purge.confirm')}
|
||||
loading={purge.isPending}
|
||||
disabled={!preview.data}
|
||||
onConfirm={() => purge.mutate()}
|
||||
>
|
||||
{preview.data ? (
|
||||
<CountList counts={preview.data.counts} label={(target) => t(`admin.purge.target.${target}`)} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{preview.error ? te(preview.error) : t('common.loading')}</p>
|
||||
)}
|
||||
</DangerDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TestEmailCard() {
|
||||
const { t } = useLang();
|
||||
const { onError } = useFeedback();
|
||||
const [to, setTo] = useState('');
|
||||
const send = useMutation({
|
||||
mutationFn: () => adminApi.testEmail(to.trim()),
|
||||
onSuccess: ({ email }) => toast({ title: t('admin.email.sent', { email }), variant: 'success' }),
|
||||
onError,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.email.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.email.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); send.mutate(); }}>
|
||||
<Input type="email" placeholder="you@example.com" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
<Button type="submit" size="sm" className="h-9" disabled={!to.includes('@') || send.isPending}>
|
||||
{send.isPending ? t('admin.working') : t('admin.email.send')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Activity, Building2, Monitor, ScrollText, Users, Wrench } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { VenuesSection } from './venues-section';
|
||||
import { DevicesSection } from './devices-section';
|
||||
import { UsersSection } from './users-section';
|
||||
import { MaintenanceSection } from './maintenance-section';
|
||||
import { AuditSection, HealthSection } from './insight-sections';
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'venues', icon: Building2, Component: VenuesSection },
|
||||
{ key: 'devices', icon: Monitor, Component: DevicesSection },
|
||||
{ key: 'users', icon: Users, Component: UsersSection },
|
||||
{ key: 'maintenance', icon: Wrench, Component: MaintenanceSection },
|
||||
{ key: 'audit', icon: ScrollText, Component: AuditSection },
|
||||
{ key: 'health', icon: Activity, Component: HealthSection },
|
||||
] as const;
|
||||
|
||||
type SectionKey = (typeof SECTIONS)[number]['key'];
|
||||
|
||||
const SECTION_KEY = 'kooltap-admin-section';
|
||||
|
||||
function storedSection(): SectionKey {
|
||||
try {
|
||||
const stored = localStorage.getItem(SECTION_KEY);
|
||||
return SECTIONS.some((s) => s.key === stored) ? (stored as SectionKey) : 'venues';
|
||||
} catch {
|
||||
return 'venues';
|
||||
}
|
||||
}
|
||||
|
||||
/** KoolTap's own admin page: every venue, terminal and user. Only for superadmins. */
|
||||
export default function AdminPage() {
|
||||
const { role } = useSession();
|
||||
const { t } = useLang();
|
||||
const [section, setSection] = useState<SectionKey>(storedSection);
|
||||
|
||||
if (role !== 'superadmin') return <ErrorState message={t('errors.forbidden')} />;
|
||||
|
||||
function select(key: SectionKey) {
|
||||
setSection(key);
|
||||
try {
|
||||
localStorage.setItem(SECTION_KEY, key);
|
||||
} catch {
|
||||
// Not remembered, but the section still opens
|
||||
}
|
||||
}
|
||||
|
||||
const { Component } = SECTIONS.find((s) => s.key === section)!;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title={t('admin.title')} description={t('admin.desc')} />
|
||||
|
||||
<div className="flex gap-1 mb-6 overflow-x-auto overflow-y-hidden border-b" role="tablist">
|
||||
{SECTIONS.map(({ key, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={section === key}
|
||||
onClick={() => select(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors',
|
||||
section === key ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(`admin.section.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Component />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { adminApi } from '@/lib/api/client';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* A confirmation for something that can't be undone. With [confirmText] the button only works once that text is
|
||||
* typed, so a misclick or the wrong row can't wipe a venue.
|
||||
*/
|
||||
export function DangerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
confirmText,
|
||||
confirmLabel,
|
||||
loading,
|
||||
disabled,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
confirmText?: string;
|
||||
confirmLabel: string;
|
||||
loading: boolean;
|
||||
disabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useLang();
|
||||
const [typed, setTyped] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTyped('');
|
||||
}, [open]);
|
||||
|
||||
const matches = !confirmText || typed.trim() === confirmText;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{children}
|
||||
{confirmText && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="danger-confirm">{t('admin.type_to_confirm', { text: confirmText })}</Label>
|
||||
<Input id="danger-confirm" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" />
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={loading || disabled || !matches}>
|
||||
{loading ? t('admin.working') : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Checkbox({ checked, onChange, label, hint }: { checked: boolean; onChange: (checked: boolean) => void; label: string; hint?: string }) {
|
||||
return (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-primary" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span>
|
||||
{label}
|
||||
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Downloads everything of the venue as a JSON file: the backup taken before a reset or delete. */
|
||||
export async function downloadVenueExport(venue: { id: string; name: string }) {
|
||||
const data = await adminApi.exportVenue(venue.id);
|
||||
const slug = venue.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'venue';
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `kooltap-${slug}-${stamp}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/** Counts from a dry run, one line each. */
|
||||
export function CountList({ counts, label }: { counts: Record<string, number | undefined>; label: (key: string) => string }) {
|
||||
const entries = Object.entries(counts).filter((entry): entry is [string, number] => entry[1] !== undefined);
|
||||
return (
|
||||
<ul className="text-sm rounded-md border divide-y">
|
||||
{entries.map(([key, count]) => (
|
||||
<li key={key} className="flex justify-between px-3 py-1.5">
|
||||
<span>{label(key)}</span>
|
||||
<span className="font-mono tabular-nums">{count.toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { KeyRound, LogOut, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type AdminUser, type Role } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { DangerDialog } from './shared';
|
||||
|
||||
const ROLES: Role[] = ['manager', 'admin', 'superadmin'];
|
||||
|
||||
export function UsersSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const { userId } = useSession();
|
||||
const { t, te, relativeTime } = useLang();
|
||||
const [search, setSearch] = useState('');
|
||||
const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [logoutAll, setLogoutAll] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users });
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
|
||||
const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
const done = (title: string) => {
|
||||
toast({ title, variant: 'success' });
|
||||
setAction(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const role = useMutation({
|
||||
mutationFn: ({ user, role }: { user: AdminUser; role: Role }) => adminApi.updateRole(user.id, role),
|
||||
onSuccess: (_, { user, role }) => done(t('admin.users.role_changed', { email: user.email, role: t(`users.role.${role}`) })),
|
||||
onError,
|
||||
});
|
||||
const resetPassword = useMutation({
|
||||
mutationFn: (user: AdminUser) => adminApi.resetPassword(user.id, password),
|
||||
onSuccess: (_, user) => done(t('admin.users.password_set', { email: user.email })),
|
||||
onError,
|
||||
});
|
||||
const logout = useMutation({
|
||||
mutationFn: (user: AdminUser) => adminApi.logoutUser(user.id),
|
||||
onSuccess: (_, user) => done(t('admin.users.logged_out', { email: user.email })),
|
||||
onError,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (user: AdminUser) => adminApi.deleteUser(user.id),
|
||||
onSuccess: (_, user) => done(t('users.deleted', { email: user.email })),
|
||||
onError,
|
||||
});
|
||||
const everyone = useMutation({
|
||||
mutationFn: adminApi.logoutEveryone,
|
||||
onSuccess: ({ users }) => {
|
||||
toast({ title: t('admin.users.everyone_done', { count: users }), variant: 'success' });
|
||||
setLogoutAll(false);
|
||||
refresh();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={te(error)} />;
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const users = (data?.users ?? []).filter((u) =>
|
||||
!query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query)));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Input placeholder={t('admin.users.search')} value={search} onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={refresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setLogoutAll(true)}>
|
||||
<LogOut className="h-4 w-4 mr-2" /> {t('admin.users.everyone')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('users.name')}</TableHead>
|
||||
<TableHead>{t('admin.devices.venue')}</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.users.sessions')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => {
|
||||
const self = user.id === userId;
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{user.email}</span>
|
||||
</TableCell>
|
||||
<TableCell>{user.venueName}</TableCell>
|
||||
<TableCell>
|
||||
{/* You can't change your own role, so there is always a superadmin left */}
|
||||
<Select value={user.role} onValueChange={(next) => role.mutate({ user, role: next as Role })} disabled={self || role.isPending}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((r) => <SelectItem key={r} value={r}>{t(`users.role.${r}`)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{user.activeSessions}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{relativeTime(user.createdAt)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
{/* Your own password is changed on My Account, which keeps you signed in */}
|
||||
{!self && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" title={t('admin.users.password')} aria-label={t('admin.users.password')}
|
||||
onClick={() => { setPassword(''); setAction({ kind: 'password', user }); }}>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title={t('admin.users.logout')} aria-label={t('admin.users.logout')}
|
||||
onClick={() => setAction({ kind: 'logout', user })}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive"
|
||||
title={t('users.delete_label', { name: user.name })} aria-label={t('users.delete_label', { name: user.name })}
|
||||
onClick={() => setAction({ kind: 'delete', user })}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={action?.kind === 'password'} onOpenChange={(open) => !open && !resetPassword.isPending && setAction(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.users.password_title', { email: action?.user.email ?? '' })}</DialogTitle>
|
||||
<DialogDescription>{t('admin.users.password_desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t('users.password')}</Label>
|
||||
<Input id="new-password" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t('users.password_placeholder')} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAction(null)} disabled={resetPassword.isPending}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => action && resetPassword.mutate(action.user)} disabled={password.length < 6 || resetPassword.isPending}>
|
||||
{resetPassword.isPending ? t('admin.working') : t('admin.users.password_confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={action?.kind === 'logout'}
|
||||
onOpenChange={(open) => !open && !logout.isPending && setAction(null)}
|
||||
title={t('admin.users.logout_title', { email: action?.user.email ?? '' })}
|
||||
description={t('admin.users.logout_desc')}
|
||||
confirmLabel={t('admin.users.logout')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
loading={logout.isPending}
|
||||
onConfirm={() => action && logout.mutate(action.user)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={action?.kind === 'delete'}
|
||||
onOpenChange={(open) => !open && !remove.isPending && setAction(null)}
|
||||
title={t('users.delete_title', { name: action?.user.name ?? '' })}
|
||||
description={t('users.delete_desc', { email: action?.user.email ?? '' })}
|
||||
confirmLabel={t('users.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
variant="destructive"
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => action && remove.mutate(action.user)}
|
||||
/>
|
||||
|
||||
<DangerDialog
|
||||
open={logoutAll}
|
||||
onOpenChange={setLogoutAll}
|
||||
title={t('admin.users.everyone_title')}
|
||||
description={t('admin.users.everyone_desc')}
|
||||
confirmText="LOGOUT"
|
||||
confirmLabel={t('admin.users.everyone')}
|
||||
loading={everyone.isPending}
|
||||
onConfirm={() => everyone.mutate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, CheckCircle2, Copy, Download, Eye, Info, KeyRound, Pencil, Plus, RefreshCw, RotateCcw, Stethoscope, Trash2 } from 'lucide-react';
|
||||
import { adminApi, RESET_SCOPES, type AdminVenue, type Finding, type ResetScope } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession, viewVenue } from '@/lib/session';
|
||||
import { Checkbox, CountList, DangerDialog, downloadVenueExport } from './shared';
|
||||
|
||||
// "Clear all tabs" and "full reset" are the common cases; any other mix can be ticked by hand
|
||||
const PRESETS: { key: string; scopes: ResetScope[] }[] = [
|
||||
{ key: 'admin.reset.preset_tabs', scopes: ['tabs'] },
|
||||
{ key: 'admin.reset.preset_event', scopes: ['tabs', 'plannedTabs', 'stock'] },
|
||||
{ key: 'admin.reset.preset_full', scopes: [...RESET_SCOPES] },
|
||||
];
|
||||
|
||||
export function VenuesSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, te, relativeTime } = useLang();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
|
||||
const venues = data?.venues ?? [];
|
||||
const selected = venues.find((v) => v.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={te(error)} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">{t('admin.venues.desc')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['admin'] })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t('admin.venues.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.venues.name')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.users')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.terminals')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.open_tabs')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.products')}</TableHead>
|
||||
<TableHead>{t('admin.venues.last_sync')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{venues.map((venue) => (
|
||||
<TableRow
|
||||
key={venue.id}
|
||||
className={venue.id === selectedId ? 'bg-muted' : 'cursor-pointer'}
|
||||
onClick={() => setSelectedId(venue.id === selectedId ? null : venue.id)}
|
||||
>
|
||||
<TableCell className="font-medium">{venue.name}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{venue.users}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{venue.devices}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{venue.openTabs}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{venue.products}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{venue.lastSyncAt ? relativeTime(venue.lastSyncAt) : t('admin.never')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<VenuePanel key={selected.id} venue={selected} venues={venues} onDeleted={() => setSelectedId(null)} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.venues.pick')}</p>
|
||||
)}
|
||||
|
||||
<NameDialog
|
||||
open={creating}
|
||||
onOpenChange={setCreating}
|
||||
title={t('admin.venues.create')}
|
||||
initial=""
|
||||
onSave={async (name) => {
|
||||
const venue = await adminApi.createVenue(name);
|
||||
toast({ title: t('admin.venues.created', { name: venue.name }), variant: 'success' });
|
||||
setSelectedId(venue.id);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin'] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: AdminVenue[]; onDeleted: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { homeVenueId } = useSession();
|
||||
const { t, te, dateTime } = useLang();
|
||||
const [dialog, setDialog] = useState<'rename' | 'reset' | 'copy' | 'delete' | null>(null);
|
||||
const [findings, setFindings] = useState<Finding[] | null>(null);
|
||||
const [pairCode, setPairCode] = useState<{ code: string; expiresAt: string } | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
|
||||
|
||||
async function run(key: string, action: () => Promise<void>) {
|
||||
setBusy(key);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{venue.name}</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">{venue.id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => run('view', async () => {
|
||||
await adminApi.viewVenue(venue.id);
|
||||
viewVenue(venue.id === homeVenueId ? null : { id: venue.id, name: venue.name });
|
||||
})} disabled={busy !== null}>
|
||||
<Eye className="h-4 w-4 mr-2" /> {t('admin.venues.view')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => run('check', async () => {
|
||||
setFindings((await adminApi.consistency(venue.id)).findings);
|
||||
})} disabled={busy !== null}>
|
||||
<Stethoscope className="h-4 w-4 mr-2" /> {busy === 'check' ? t('admin.working') : t('admin.venues.check')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => run('export', () => downloadVenueExport(venue))} disabled={busy !== null}>
|
||||
<Download className="h-4 w-4 mr-2" /> {busy === 'export' ? t('admin.working') : t('admin.venues.export')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => run('pair', async () => {
|
||||
setPairCode(await adminApi.createRegistrationCode(venue.id));
|
||||
})} disabled={busy !== null}>
|
||||
<KeyRound className="h-4 w-4 mr-2" /> {t('admin.venues.pair')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDialog('rename')}>
|
||||
<Pencil className="h-4 w-4 mr-2" /> {t('admin.venues.rename')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDialog('copy')} disabled={venues.length < 2}>
|
||||
<Copy className="h-4 w-4 mr-2" /> {t('admin.venues.copy_catalog')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="text-destructive" onClick={() => setDialog('reset')}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" /> {t('admin.venues.reset')}
|
||||
</Button>
|
||||
{venue.id !== homeVenueId && (
|
||||
<Button size="sm" variant="outline" className="text-destructive" onClick={() => setDialog('delete')}>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> {t('admin.venues.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pairCode && (
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
{t('admin.venues.pair_code')} <span className="font-mono text-lg font-bold tracking-widest ml-2">{pairCode.code}</span>
|
||||
<span className="text-muted-foreground ml-2">{t('admin.venues.pair_expires', { time: dateTime(pairCode.expiresAt) })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{findings && <Findings findings={findings} />}
|
||||
</CardContent>
|
||||
|
||||
<NameDialog
|
||||
open={dialog === 'rename'}
|
||||
onOpenChange={(open) => setDialog(open ? 'rename' : null)}
|
||||
title={t('admin.venues.rename_title', { name: venue.name })}
|
||||
initial={venue.name}
|
||||
onSave={async (name) => {
|
||||
await adminApi.renameVenue(venue.id, name);
|
||||
toast({ title: t('admin.venues.renamed'), variant: 'success' });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
<ResetDialog open={dialog === 'reset'} onOpenChange={(open) => setDialog(open ? 'reset' : null)} venue={venue} onDone={refresh} />
|
||||
<CopyCatalogDialog open={dialog === 'copy'} onOpenChange={(open) => setDialog(open ? 'copy' : null)} venue={venue} venues={venues} onDone={refresh} />
|
||||
<DeleteVenueDialog
|
||||
open={dialog === 'delete'}
|
||||
onOpenChange={(open) => setDialog(open ? 'delete' : null)}
|
||||
venue={venue}
|
||||
onDone={() => {
|
||||
onDeleted();
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const SEVERITY_ICON = { error: AlertTriangle, warning: AlertTriangle, info: Info };
|
||||
const SEVERITY_BADGE = { error: 'destructive', warning: 'warning', info: 'info' } as const;
|
||||
|
||||
function Findings({ findings }: { findings: Finding[] }) {
|
||||
const { t } = useLang();
|
||||
if (findings.length === 0) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400">
|
||||
<CheckCircle2 className="h-4 w-4" /> {t('admin.check.clean')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{findings.map((finding, index) => {
|
||||
const Icon = SEVERITY_ICON[finding.severity];
|
||||
return (
|
||||
<li key={index} className="flex items-start gap-2 text-sm">
|
||||
<Icon className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<Badge variant={SEVERITY_BADGE[finding.severity]}>{t(`admin.check.severity.${finding.severity}`)}</Badge>
|
||||
<span>{finding.message}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function NameDialog({ open, onOpenChange, title, initial, onSave }: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
initial: string;
|
||||
onSave: (name: string) => Promise<void>;
|
||||
}) {
|
||||
const { t, te } = useLang();
|
||||
const [name, setName] = useState(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setName(initial);
|
||||
}, [open, initial]);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(name.trim());
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="venue-name">{t('admin.venues.name')}</Label>
|
||||
<Input id="venue-name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && name.trim() && save()} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t('common.cancel')}</Button>
|
||||
<Button onClick={save} disabled={saving || !name.trim() || name.trim() === initial}>{t('admin.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) {
|
||||
const { t, te } = useLang();
|
||||
const [scopes, setScopes] = useState<ResetScope[]>(['tabs']);
|
||||
const [stockValue, setStockValue] = useState('0');
|
||||
const [exportFirst, setExportFirst] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setScopes(['tabs']);
|
||||
setStockValue('0');
|
||||
setExportFirst(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const stock = parseInt(stockValue, 10);
|
||||
const stockValid = !scopes.includes('stock') || (Number.isInteger(stock) && stock >= 0);
|
||||
|
||||
// A fresh dry run whenever the selection changes, so the numbers shown are what will go
|
||||
const preview = useQuery({
|
||||
queryKey: ['admin', 'reset-preview', venue.id, [...scopes].sort().join(',')],
|
||||
queryFn: () => adminApi.resetVenue(venue.id, { scopes, dryRun: true }),
|
||||
enabled: open && scopes.length > 0,
|
||||
});
|
||||
|
||||
function toggle(scope: ResetScope, on: boolean) {
|
||||
setScopes((current) => (on ? [...current, scope] : current.filter((s) => s !== scope)));
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (exportFirst) await downloadVenueExport(venue);
|
||||
const { counts } = await adminApi.resetVenue(venue.id, { scopes, stockValue: stock, dryRun: false });
|
||||
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
|
||||
toast({ title: t('admin.reset.done', { name: venue.name }), description: t('admin.reset.done_desc', { count: total }), variant: 'success' });
|
||||
onOpenChange(false);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DangerDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('admin.reset.title', { name: venue.name })}
|
||||
description={t('admin.reset.desc')}
|
||||
confirmText={venue.name}
|
||||
confirmLabel={t('admin.reset.confirm')}
|
||||
loading={loading}
|
||||
disabled={scopes.length === 0 || !stockValid}
|
||||
onConfirm={confirm}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<Button key={preset.key} size="sm" variant="secondary" onClick={() => setScopes(preset.scopes)}>
|
||||
{t(preset.key)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{RESET_SCOPES.map((scope) => (
|
||||
<Checkbox
|
||||
key={scope}
|
||||
checked={scopes.includes(scope)}
|
||||
onChange={(on) => toggle(scope, on)}
|
||||
label={t(`admin.reset.scope.${scope}`)}
|
||||
hint={t(`admin.reset.scope.${scope}_hint`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{scopes.includes('stock') && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="stock-value">{t('admin.reset.stock_value')}</Label>
|
||||
<Input id="stock-value" type="number" min={0} value={stockValue} onChange={(e) => setStockValue(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
)}
|
||||
{scopes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('admin.preview')}</p>
|
||||
{preview.data ? (
|
||||
<CountList counts={preview.data.counts} label={(scope) => t(`admin.reset.scope.${scope}`)} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{preview.error ? te(preview.error) : t('common.loading')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Checkbox checked={exportFirst} onChange={setExportFirst} label={t('admin.export_first')} />
|
||||
</div>
|
||||
</DangerDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyCatalogDialog({ open, onOpenChange, venue, venues, onDone }: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
venue: AdminVenue;
|
||||
venues: AdminVenue[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t, te } = useLang();
|
||||
const [fromId, setFromId] = useState('');
|
||||
const others = venues.filter((v) => v.id !== venue.id);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => adminApi.copyCatalog(venue.id, fromId),
|
||||
onSuccess: ({ created }) => {
|
||||
toast({ title: t('admin.copy.done', { count: created }), variant: 'success' });
|
||||
onOpenChange(false);
|
||||
onDone();
|
||||
},
|
||||
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !mutation.isPending && onOpenChange(next)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.copy.title', { name: venue.name })}</DialogTitle>
|
||||
<DialogDescription>{t('admin.copy.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select value={fromId} onValueChange={setFromId}>
|
||||
<SelectTrigger><SelectValue placeholder={t('admin.copy.from')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{others.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>{t('admin.copy.option', { name: v.name, count: v.products })}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={!fromId || mutation.isPending}>
|
||||
{mutation.isPending ? t('admin.working') : t('admin.copy.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteVenueDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) {
|
||||
const { t, te } = useLang();
|
||||
const [exportFirst, setExportFirst] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function confirm() {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (exportFirst) await downloadVenueExport(venue);
|
||||
await adminApi.deleteVenue(venue.id, venue.name);
|
||||
toast({ title: t('admin.delete.done', { name: venue.name }), variant: 'success' });
|
||||
onOpenChange(false);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DangerDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('admin.delete.title', { name: venue.name })}
|
||||
description={t('admin.delete.desc', { users: venue.users, terminals: venue.devices, settlements: venue.settlements })}
|
||||
confirmText={venue.name}
|
||||
confirmLabel={t('admin.delete.confirm')}
|
||||
loading={loading}
|
||||
onConfirm={confirm}
|
||||
>
|
||||
<Checkbox checked={exportFirst} onChange={setExportFirst} label={t('admin.export_first')} />
|
||||
</DangerDialog>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Bell,
|
||||
Package,
|
||||
UserCog,
|
||||
ShieldAlert,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -23,18 +25,22 @@ import { LanguageSelector } from '@/components/language-selector';
|
||||
import { authApi, venuesApi } from '@/lib/api/client';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
|
||||
import { readSession, Session, SessionProvider } from '@/lib/session';
|
||||
import { canAdministerVenue, readSession, Session, SessionProvider, viewVenue } from '@/lib/session';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
|
||||
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
|
||||
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
|
||||
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, adminOnly: false },
|
||||
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
|
||||
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
|
||||
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
|
||||
{ href: '/dashboard/account', key: 'nav.account', icon: UserCog, adminOnly: false },
|
||||
// Who sees each item: everyone, admins (and superadmins), or superadmins only
|
||||
type Access = 'all' | 'admin' | 'superadmin';
|
||||
|
||||
const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [
|
||||
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' },
|
||||
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, access: 'all' },
|
||||
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, access: 'all' },
|
||||
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' },
|
||||
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' },
|
||||
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, access: 'all' },
|
||||
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, access: 'all' },
|
||||
{ href: '/dashboard/account', key: 'nav.account', icon: UserCog, access: 'all' },
|
||||
{ href: '/dashboard/admin', key: 'nav.admin', icon: ShieldAlert, access: 'superadmin' },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -108,9 +114,8 @@ export default function DashboardLayout({
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href;
|
||||
|
||||
if (item.adminOnly && user.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
if (item.access === 'admin' && !canAdministerVenue(user.role)) return null;
|
||||
if (item.access === 'superadmin' && user.role !== 'superadmin') return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -151,6 +156,15 @@ export default function DashboardLayout({
|
||||
|
||||
{/* Main content */}
|
||||
<main className="lg:pl-64 pt-14 lg:pt-0">
|
||||
{user.viewing && (
|
||||
<div className="flex flex-wrap items-center gap-3 px-6 py-2 text-sm bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100 border-b">
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>{t('admin.viewing_banner', { name: user.viewing.name })}</span>
|
||||
<Button size="sm" variant="outline" className="ml-auto h-7" onClick={() => viewVenue(null, '/dashboard/admin')}>
|
||||
{t('admin.viewing_stop')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 max-w-7xl mx-auto">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { EmptyState } from '@/components/empty-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { formatCents } from '@/lib/utils';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { canManageVenue, useSession } from '@/lib/session';
|
||||
import { Send, History, Search } from 'lucide-react';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -29,7 +29,7 @@ export default function RemindersPage() {
|
||||
const { t, tn, te, relativeTime, dateTime } = useLang();
|
||||
const { venueId, role } = useSession();
|
||||
// Admins and managers both run the venue
|
||||
const canManage = role === 'admin' || role === 'manager';
|
||||
const canManage = canManageVenue(role);
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
@@ -11,11 +11,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { canAdministerVenue, useSession } from '@/lib/session';
|
||||
import { Monitor, Copy, Plus } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { venueId, role } = useSession();
|
||||
const { venueId, role, viewing } = useSession();
|
||||
const isAdmin = canAdministerVenue(role);
|
||||
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [unpairing, setUnpairing] = useState<Device | null>(null);
|
||||
@@ -49,7 +50,7 @@ export default function SettingsPage() {
|
||||
async function handleGenerateCode() {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await devicesApi.generateCode();
|
||||
const result = await devicesApi.generateCode(viewing?.id);
|
||||
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
|
||||
} catch (err) {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
@@ -100,7 +101,7 @@ export default function SettingsPage() {
|
||||
<span className="font-medium">{device.name}</span>
|
||||
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
|
||||
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
|
||||
{role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setUnpairing(device)} aria-label={t('settings.unpair_label', { name: device.name })}>
|
||||
{t('settings.unpair')}
|
||||
</Button>
|
||||
@@ -113,7 +114,7 @@ export default function SettingsPage() {
|
||||
{t('settings.pair_desc')}
|
||||
</p>
|
||||
)}
|
||||
{role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{generating ? t('settings.generating') : devices.length > 0 ? t('settings.repair') : t('settings.pair')}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { centsToInput, formatCents, parseEuros } from '@/lib/utils';
|
||||
import { RefreshCw, Search, Truck, History, Plus, Pencil, ImageIcon, Trash2, Upload } from 'lucide-react';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { canManageVenue, useSession } from '@/lib/session';
|
||||
|
||||
type Filter = 'all' | 'low' | 'untracked' | 'archived';
|
||||
type Mode = 'add' | 'remove' | 'count';
|
||||
@@ -59,7 +59,7 @@ export default function StockPage() {
|
||||
const { connected: live } = useRealtime();
|
||||
const { venueId, role } = useSession();
|
||||
// Admins and managers both run the venue
|
||||
const canManage = role === 'admin' || role === 'manager';
|
||||
const canManage = canManageVenue(role);
|
||||
const queryClient = useQueryClient();
|
||||
const { t, tn, te, relativeTime, dateTime } = useLang();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { canManageVenue, useSession } from '@/lib/session';
|
||||
|
||||
type SortKey = 'name' | 'amount';
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function TabsPage() {
|
||||
const { connected: live } = useRealtime();
|
||||
const { venueId, role } = useSession();
|
||||
// Admins and managers both run the venue
|
||||
const canManage = role === 'admin' || role === 'manager';
|
||||
const canManage = canManageVenue(role);
|
||||
const queryClient = useQueryClient();
|
||||
const [closeTab, setCloseTab] = useState<Tab | null>(null);
|
||||
const [deleteTab, setDeleteTab] = useState<Tab | null>(null);
|
||||
|
||||
@@ -16,13 +16,14 @@ import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { canAdministerVenue, useSession } from '@/lib/session';
|
||||
import { RefreshCw, Plus, Trash2 } from 'lucide-react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { venueId, role, userId } = useSession();
|
||||
const isAdmin = canAdministerVenue(role);
|
||||
const [addDialog, setAddDialog] = useState(false);
|
||||
const [deleting, setDeleting] = useState<User | null>(null);
|
||||
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
|
||||
@@ -75,7 +76,7 @@ export default function UsersPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
|
||||
</Button>
|
||||
{role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={() => setAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t('users.add')}
|
||||
</Button>
|
||||
@@ -95,7 +96,7 @@ export default function UsersPage() {
|
||||
<TableHead>{t('users.email')}</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
{role === 'admin' && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
|
||||
{isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -103,9 +104,9 @@ export default function UsersPage() {
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
|
||||
<TableCell><Badge variant={u.role === 'manager' ? 'secondary' : 'default'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
|
||||
<TableCell>{relativeTime(u.createdAt)}</TableCell>
|
||||
{role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<TableCell className="text-right">
|
||||
{/* You can't delete yourself, so the venue always keeps an admin */}
|
||||
{u.id !== userId && (
|
||||
|
||||
@@ -5,8 +5,11 @@ export interface ApiError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders) */
|
||||
export type Role = 'admin' | 'manager';
|
||||
/**
|
||||
* admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders); superadmin: runs
|
||||
* KoolTap itself (every venue, and the admin page)
|
||||
*/
|
||||
export type Role = 'admin' | 'manager' | 'superadmin';
|
||||
|
||||
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
@@ -288,7 +291,9 @@ export const devicesApi = {
|
||||
api<{ devices: Device[] }>(`/venues/${venueId}/devices`),
|
||||
unpair: (venueId: string, deviceId: string) =>
|
||||
api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }),
|
||||
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
|
||||
/** [venueId]: the venue a superadmin is viewing; others always pair for their own venue */
|
||||
generateCode: (venueId?: string) =>
|
||||
api<{ code: string; expiresAt: string }>(`/devices/generation-code${venueId ? `?venueId=${encodeURIComponent(venueId)}` : ''}`),
|
||||
};
|
||||
|
||||
// Users API
|
||||
@@ -397,6 +402,151 @@ export const stockApi = {
|
||||
}),
|
||||
};
|
||||
|
||||
// Superadmin API: every venue, terminal and user. Destructive calls take dryRun to preview what they would change
|
||||
|
||||
export const RESET_SCOPES = ['tabs', 'settlements', 'tabEvents', 'plannedTabs', 'stock', 'stockHistory', 'commands', 'auditLog'] as const;
|
||||
export type ResetScope = (typeof RESET_SCOPES)[number];
|
||||
|
||||
export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const;
|
||||
export type PurgeTarget = (typeof PURGE_TARGETS)[number];
|
||||
|
||||
export interface AdminVenue {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
users: number;
|
||||
devices: number;
|
||||
openTabs: number;
|
||||
products: number;
|
||||
plannedTabs: number;
|
||||
settlements: number;
|
||||
/** The latest sync of any of its terminals */
|
||||
lastSyncAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdminDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
venueId: string;
|
||||
venueName: string;
|
||||
tokenPrefix: string;
|
||||
tokenExpiresAt: string;
|
||||
lastSeenAt: string;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
pendingCommands: number;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
venueId: string;
|
||||
venueName: string;
|
||||
createdAt: string;
|
||||
activeSessions: number;
|
||||
}
|
||||
|
||||
export interface RegistrationCode {
|
||||
id: string;
|
||||
code: string;
|
||||
venueId: string;
|
||||
venueName: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
venueId: string;
|
||||
venueName: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
/** The user's email or the terminal's name; null when it no longer exists */
|
||||
actorName: string | null;
|
||||
action: string;
|
||||
details: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type FindingSeverity = 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Finding {
|
||||
severity: FindingSeverity;
|
||||
code: string;
|
||||
message: string;
|
||||
subjectId: string;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
checkedAt: string;
|
||||
uptimeSeconds: number;
|
||||
nodeVersion: string;
|
||||
databaseBytes: number;
|
||||
tables: { table: string; rows: number }[];
|
||||
migrations: { applied: number; latest: string | null; failed: string[] };
|
||||
realtimeConnections: number;
|
||||
/** Null until the hourly job ran since the server started */
|
||||
commandExpiryLastRunAt: string | null;
|
||||
stuckCommands: number;
|
||||
emailConfigured: boolean;
|
||||
}
|
||||
|
||||
const post = <T>(endpoint: string, body: unknown = {}) =>
|
||||
api<T>(endpoint, { method: 'POST', body: JSON.stringify(body) });
|
||||
|
||||
export const adminApi = {
|
||||
venues: () => api<{ venues: AdminVenue[] }>('/admin/venues'),
|
||||
createVenue: (name: string) => post<{ id: string; name: string }>('/admin/venues', { name }),
|
||||
renameVenue: (venueId: string, name: string) =>
|
||||
api(`/admin/venues/${venueId}`, { method: 'PATCH', body: JSON.stringify({ name }) }),
|
||||
/** [confirmName] must be the venue's name */
|
||||
deleteVenue: (venueId: string, confirmName: string) =>
|
||||
api(`/admin/venues/${venueId}?confirmName=${encodeURIComponent(confirmName)}`, { method: 'DELETE' }),
|
||||
viewVenue: (venueId: string) => post<{ id: string; name: string }>(`/admin/venues/${venueId}/view`),
|
||||
copyCatalog: (venueId: string, fromVenueId: string) =>
|
||||
post<{ created: number }>(`/admin/venues/${venueId}/copy-catalog`, { fromVenueId }),
|
||||
resetVenue: (venueId: string, data: { scopes: ResetScope[]; stockValue?: number; dryRun: boolean }) =>
|
||||
post<{ dryRun: boolean; counts: Partial<Record<ResetScope, number>> }>(`/admin/venues/${venueId}/reset`, data),
|
||||
consistency: (venueId: string) =>
|
||||
api<{ checkedAt: string; findings: Finding[] }>(`/admin/venues/${venueId}/consistency`),
|
||||
exportVenue: (venueId: string) => api<Record<string, unknown>>(`/admin/venues/${venueId}/export`),
|
||||
createRegistrationCode: (venueId: string) =>
|
||||
post<{ code: string; expiresAt: string }>(`/admin/venues/${venueId}/registration-codes`),
|
||||
|
||||
devices: () => api<{ devices: AdminDevice[] }>('/admin/devices'),
|
||||
revokeDevice: (deviceId: string) => post(`/admin/devices/${deviceId}/revoke`),
|
||||
registrationCodes: () => api<{ codes: RegistrationCode[]; stale: number }>('/admin/registration-codes'),
|
||||
revokeRegistrationCode: (id: string) => post(`/admin/registration-codes/${id}/revoke`),
|
||||
cleanupRegistrationCodes: () => post<{ deleted: number }>('/admin/registration-codes/cleanup'),
|
||||
expireStuckCommands: (dryRun: boolean) => post<{ dryRun: boolean; count: number }>('/admin/commands/expire-stuck', { dryRun }),
|
||||
|
||||
users: () => api<{ users: AdminUser[] }>('/admin/users'),
|
||||
updateRole: (userId: string, role: Role) =>
|
||||
api(`/admin/users/${userId}/role`, { method: 'PATCH', body: JSON.stringify({ role }) }),
|
||||
resetPassword: (userId: string, password: string) => post(`/admin/users/${userId}/password`, { password }),
|
||||
logoutUser: (userId: string) => post(`/admin/users/${userId}/logout`),
|
||||
deleteUser: (userId: string) => api(`/admin/users/${userId}`, { method: 'DELETE' }),
|
||||
logoutEveryone: () => post<{ users: number }>('/admin/sessions/revoke-all'),
|
||||
|
||||
purge: (data: { targets: PurgeTarget[]; olderThanDays: number; venueId?: string; dryRun: boolean }) =>
|
||||
post<{ dryRun: boolean; counts: Partial<Record<PurgeTarget, number>> }>('/admin/purge', data),
|
||||
cleanupImages: (dryRun: boolean) => post<{ dryRun: boolean; count: number; bytes?: number }>('/admin/images/cleanup', { dryRun }),
|
||||
testEmail: (to: string) => post<{ ok: boolean; email: string }>('/admin/email/test', { to }),
|
||||
|
||||
audit: (params: { venueId?: string; action?: string; limit?: number; offset?: number }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.venueId) searchParams.set('venueId', params.venueId);
|
||||
if (params.action) searchParams.set('action', params.action);
|
||||
if (params.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params.offset) searchParams.set('offset', String(params.offset));
|
||||
const qs = searchParams.toString();
|
||||
return api<{ entries: AuditEntry[]; total: number }>(`/admin/audit${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
health: () => api<SystemHealth>('/admin/health'),
|
||||
};
|
||||
|
||||
export { setTokens, clearTokens, refreshAccessToken };
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
|
||||
@@ -392,6 +392,175 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.status.cancelled': 'Cancelled',
|
||||
'stock.reason.unknown_product': 'product not on the terminal',
|
||||
'stock.reason.stock_not_tracked': 'stock not tracked on the terminal',
|
||||
'nav.admin': 'KoolTap Admin',
|
||||
'users.role.superadmin': 'Superadmin',
|
||||
'errors.cannot_delete_own_venue': 'You cannot delete the venue your account belongs to',
|
||||
'errors.confirm_name_mismatch': 'Type the venue name exactly to confirm',
|
||||
'errors.same_venue': 'Pick another venue to copy from',
|
||||
'errors.cannot_change_own_role': 'You cannot change your own role',
|
||||
'errors.cannot_logout_self': 'Use Logout to sign yourself out',
|
||||
'errors.use_account_page': 'Change your own password on My Account',
|
||||
'errors.venue_not_found': 'This venue no longer exists',
|
||||
'errors.device_not_found': 'This terminal no longer exists',
|
||||
'errors.email_failed': 'The email could not be sent',
|
||||
'admin.title': 'KoolTap Admin',
|
||||
'admin.desc': 'Every venue, terminal and user. Changes here cannot be undone.',
|
||||
'admin.section.venues': 'Venues',
|
||||
'admin.section.devices': 'Terminals',
|
||||
'admin.section.users': 'Users',
|
||||
'admin.section.maintenance': 'Maintenance',
|
||||
'admin.section.audit': 'Audit log',
|
||||
'admin.section.health': 'System',
|
||||
'admin.working': 'Working...',
|
||||
'admin.save': 'Save',
|
||||
'admin.never': 'never',
|
||||
'admin.preview': 'This will remove:',
|
||||
'admin.all_venues': 'All venues',
|
||||
'admin.type_to_confirm': 'Type {text} to confirm',
|
||||
'admin.export_first': 'Download a backup (JSON) first',
|
||||
'admin.viewing_banner': 'You are viewing {name} as a KoolTap admin. Everything you do here happens in this venue.',
|
||||
'admin.viewing_stop': 'Back to KoolTap Admin',
|
||||
'admin.venues.desc': 'Pick a venue to see what you can do with it.',
|
||||
'admin.venues.pick': 'Pick a venue above.',
|
||||
'admin.venues.create': 'New venue',
|
||||
'admin.venues.created': '{name} created',
|
||||
'admin.venues.name': 'Name',
|
||||
'admin.venues.users': 'Users',
|
||||
'admin.venues.terminals': 'Terminals',
|
||||
'admin.venues.open_tabs': 'Open tabs',
|
||||
'admin.venues.products': 'Products',
|
||||
'admin.venues.last_sync': 'Last sync',
|
||||
'admin.venues.view': 'View as venue',
|
||||
'admin.venues.check': 'Check data',
|
||||
'admin.venues.export': 'Export',
|
||||
'admin.venues.pair': 'Pairing code',
|
||||
'admin.venues.pair_code': 'Pairing code:',
|
||||
'admin.venues.pair_expires': 'valid until {time}',
|
||||
'admin.venues.rename': 'Rename',
|
||||
'admin.venues.rename_title': 'Rename {name}',
|
||||
'admin.venues.renamed': 'Venue renamed',
|
||||
'admin.venues.copy_catalog': 'Copy products',
|
||||
'admin.venues.reset': 'Reset…',
|
||||
'admin.venues.delete': 'Delete…',
|
||||
'admin.check.clean': 'Nothing found: the data adds up.',
|
||||
'admin.check.severity.error': 'Error',
|
||||
'admin.check.severity.warning': 'Warning',
|
||||
'admin.check.severity.info': 'Note',
|
||||
'admin.reset.title': 'Reset {name}',
|
||||
'admin.reset.desc': 'Users, terminals and products stay. Terminals pick up the change on their next sync.',
|
||||
'admin.reset.preset_tabs': 'Clear all tabs',
|
||||
'admin.reset.preset_event': 'New event',
|
||||
'admin.reset.preset_full': 'Full reset',
|
||||
'admin.reset.scope.tabs': 'Open tabs',
|
||||
'admin.reset.scope.tabs_hint': 'Unpaid drinks on them are lost',
|
||||
'admin.reset.scope.settlements': 'Paid tabs',
|
||||
'admin.reset.scope.settlements_hint': 'The sales history',
|
||||
'admin.reset.scope.tabEvents': 'Terminal events',
|
||||
'admin.reset.scope.tabEvents_hint': 'Only when every terminal has synced: an event sent again would count twice',
|
||||
'admin.reset.scope.plannedTabs': 'Planned customers',
|
||||
'admin.reset.scope.plannedTabs_hint': 'With their email addresses and reminders',
|
||||
'admin.reset.scope.stock': 'Stock',
|
||||
'admin.reset.scope.stock_hint': 'Every tracked product gets the same count',
|
||||
'admin.reset.scope.stockHistory': 'Stock history',
|
||||
'admin.reset.scope.stockHistory_hint': 'Deliveries, breakage and counts',
|
||||
'admin.reset.scope.commands': 'Command history',
|
||||
'admin.reset.scope.commands_hint': 'Tabs closed from the dashboard',
|
||||
'admin.reset.scope.auditLog': 'Audit log',
|
||||
'admin.reset.scope.auditLog_hint': 'An entry for this reset is kept',
|
||||
'admin.reset.stock_value': 'Stock count',
|
||||
'admin.reset.confirm': 'Reset',
|
||||
'admin.reset.done': '{name} is reset',
|
||||
'admin.reset.done_desc': '{count} rows changed',
|
||||
'admin.copy.title': 'Copy products into {name}',
|
||||
'admin.copy.desc': 'Products this venue doesn\'t have yet (by name) are added, with their pictures. Stock is not copied.',
|
||||
'admin.copy.from': 'Copy from…',
|
||||
'admin.copy.option': '{name} ({count} products)',
|
||||
'admin.copy.confirm': 'Copy',
|
||||
'admin.copy.done': '{count} products added',
|
||||
'admin.delete.title': 'Delete {name}?',
|
||||
'admin.delete.desc': 'The venue is deleted with everything in it: {users} users, {terminals} terminals and {settlements} paid tabs. Its terminals stop working.',
|
||||
'admin.delete.confirm': 'Delete venue',
|
||||
'admin.delete.done': '{name} is deleted',
|
||||
'admin.devices.show_unpaired': 'Show unpaired terminals ({count})',
|
||||
'admin.devices.name': 'Terminal',
|
||||
'admin.devices.venue': 'Venue',
|
||||
'admin.devices.status': 'Status',
|
||||
'admin.devices.last_seen': 'Last seen',
|
||||
'admin.devices.token_expires': 'Token valid until',
|
||||
'admin.devices.pending': 'Pending commands',
|
||||
'admin.devices.unpaired': 'Unpaired',
|
||||
'admin.codes.title': 'Pairing codes',
|
||||
'admin.codes.desc': 'Codes that can still be used to pair a terminal.',
|
||||
'admin.codes.none': 'No codes waiting to be used.',
|
||||
'admin.codes.revoke': 'Revoke',
|
||||
'admin.codes.cleanup': 'Delete used and expired codes ({count})',
|
||||
'admin.codes.cleaned': '{count} codes deleted',
|
||||
'admin.users.search': 'Search name, email or venue',
|
||||
'admin.users.sessions': 'Sessions',
|
||||
'admin.users.role_changed': '{email} is now {role}',
|
||||
'admin.users.password': 'Set password',
|
||||
'admin.users.password_title': 'New password for {email}',
|
||||
'admin.users.password_desc': 'They are signed out everywhere and log in with this password.',
|
||||
'admin.users.password_confirm': 'Set password',
|
||||
'admin.users.password_set': 'Password of {email} changed',
|
||||
'admin.users.logout': 'Sign out everywhere',
|
||||
'admin.users.logout_title': 'Sign out {email}?',
|
||||
'admin.users.logout_desc': 'Every session of this user ends right away. They can log in again.',
|
||||
'admin.users.logged_out': '{email} is signed out',
|
||||
'admin.users.everyone': 'Sign out everyone',
|
||||
'admin.users.everyone_title': 'Sign out every user?',
|
||||
'admin.users.everyone_desc': 'Everyone but you is signed out right away, in every venue. Terminals keep working.',
|
||||
'admin.users.everyone_done': '{count} users signed out',
|
||||
'admin.stuck.title': 'Stuck commands',
|
||||
'admin.stuck.desc': 'Close requests no terminal picked up. The server settles tabs itself now, so these will never run.',
|
||||
'admin.stuck.count': 'Pending: {count}',
|
||||
'admin.stuck.confirm': 'Expire all',
|
||||
'admin.stuck.done': '{count} commands expired',
|
||||
'admin.images.title': 'Unused pictures',
|
||||
'admin.images.desc': 'Product pictures no product uses anymore, older than a week.',
|
||||
'admin.images.count': '{count} pictures, {size}',
|
||||
'admin.images.confirm': 'Delete them',
|
||||
'admin.images.done': '{count} pictures deleted',
|
||||
'admin.purge.title': 'Delete old history',
|
||||
'admin.purge.desc': 'Keeps the database small. Open tabs, sales and stock are never touched.',
|
||||
'admin.purge.target.tabEvents': 'Terminal events',
|
||||
'admin.purge.target.tabEvents_hint': 'What the terminals reported, drink by drink',
|
||||
'admin.purge.target.auditLog': 'Audit log',
|
||||
'admin.purge.target.auditLog_hint': 'Who did what on the dashboard',
|
||||
'admin.purge.target.commands': 'Finished commands',
|
||||
'admin.purge.target.commands_hint': 'Pending ones are kept',
|
||||
'admin.purge.days': 'Older than (days)',
|
||||
'admin.purge.preview': 'Preview…',
|
||||
'admin.purge.confirm_desc': 'Everything older than {days} days:',
|
||||
'admin.purge.confirm': 'Delete',
|
||||
'admin.purge.done': '{count} rows deleted',
|
||||
'admin.email.title': 'Test email',
|
||||
'admin.email.desc': 'Sends the test email, to check that sending reminders works.',
|
||||
'admin.email.send': 'Send',
|
||||
'admin.email.sent': 'Test email sent to {email}',
|
||||
'admin.audit.action_filter': 'Filter on action',
|
||||
'admin.audit.when': 'When',
|
||||
'admin.audit.who': 'Who',
|
||||
'admin.audit.action': 'Action',
|
||||
'admin.audit.details': 'Details',
|
||||
'admin.audit.page': 'Page {page} of {pages} ({total} entries)',
|
||||
'admin.audit.previous': 'Previous',
|
||||
'admin.audit.next': 'Next',
|
||||
'admin.health.database': 'Database size',
|
||||
'admin.health.uptime': 'API uptime',
|
||||
'admin.health.connections': 'Live dashboards',
|
||||
'admin.health.migrations': 'Migrations applied',
|
||||
'admin.health.migrations_failed': '{count} failed',
|
||||
'admin.health.failed_list': 'Failed migrations: {names}',
|
||||
'admin.health.latest_migration': 'Latest migration:',
|
||||
'admin.health.expiry_job': 'Command expiry job',
|
||||
'admin.health.not_yet': 'not yet (hourly)',
|
||||
'admin.health.stuck': 'Stuck commands',
|
||||
'admin.health.email': 'Email',
|
||||
'admin.health.configured': 'Set up',
|
||||
'admin.health.not_configured': 'Not set up',
|
||||
'admin.health.node': 'Node.js',
|
||||
'admin.health.tables': 'Rows per table',
|
||||
},
|
||||
nl: {
|
||||
'nav.overview': 'Overzicht',
|
||||
@@ -781,6 +950,175 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.status.cancelled': 'Geannuleerd',
|
||||
'stock.reason.unknown_product': 'product staat niet op de kassa',
|
||||
'stock.reason.stock_not_tracked': 'voorraad niet bijgehouden op de kassa',
|
||||
'nav.admin': 'KoolTap-beheer',
|
||||
'users.role.superadmin': 'Superadmin',
|
||||
'errors.cannot_delete_own_venue': 'Je kan de zaak van je eigen account niet verwijderen',
|
||||
'errors.confirm_name_mismatch': 'Typ de naam van de zaak exact om te bevestigen',
|
||||
'errors.same_venue': 'Kies een andere zaak om van te kopiëren',
|
||||
'errors.cannot_change_own_role': 'Je kan je eigen rol niet wijzigen',
|
||||
'errors.cannot_logout_self': 'Gebruik Uitloggen om jezelf af te melden',
|
||||
'errors.use_account_page': 'Wijzig je eigen wachtwoord via Mijn account',
|
||||
'errors.venue_not_found': 'Deze zaak bestaat niet meer',
|
||||
'errors.device_not_found': 'Deze kassa bestaat niet meer',
|
||||
'errors.email_failed': 'De e-mail kon niet verstuurd worden',
|
||||
'admin.title': 'KoolTap-beheer',
|
||||
'admin.desc': 'Alle zaken, kassa\'s en gebruikers. Wat je hier doet, kan je niet ongedaan maken.',
|
||||
'admin.section.venues': 'Zaken',
|
||||
'admin.section.devices': 'Kassa\'s',
|
||||
'admin.section.users': 'Gebruikers',
|
||||
'admin.section.maintenance': 'Onderhoud',
|
||||
'admin.section.audit': 'Logboek',
|
||||
'admin.section.health': 'Systeem',
|
||||
'admin.working': 'Bezig...',
|
||||
'admin.save': 'Opslaan',
|
||||
'admin.never': 'nooit',
|
||||
'admin.preview': 'Dit wordt verwijderd:',
|
||||
'admin.all_venues': 'Alle zaken',
|
||||
'admin.type_to_confirm': 'Typ {text} om te bevestigen',
|
||||
'admin.export_first': 'Eerst een back-up (JSON) downloaden',
|
||||
'admin.viewing_banner': 'Je bekijkt {name} als KoolTap-beheerder. Alles wat je hier doet, gebeurt in deze zaak.',
|
||||
'admin.viewing_stop': 'Terug naar KoolTap-beheer',
|
||||
'admin.venues.desc': 'Kies een zaak om te zien wat je ermee kan doen.',
|
||||
'admin.venues.pick': 'Kies hierboven een zaak.',
|
||||
'admin.venues.create': 'Nieuwe zaak',
|
||||
'admin.venues.created': '{name} aangemaakt',
|
||||
'admin.venues.name': 'Naam',
|
||||
'admin.venues.users': 'Gebruikers',
|
||||
'admin.venues.terminals': 'Kassa\'s',
|
||||
'admin.venues.open_tabs': 'Open poefs',
|
||||
'admin.venues.products': 'Producten',
|
||||
'admin.venues.last_sync': 'Laatste sync',
|
||||
'admin.venues.view': 'Bekijk als zaak',
|
||||
'admin.venues.check': 'Gegevens controleren',
|
||||
'admin.venues.export': 'Exporteren',
|
||||
'admin.venues.pair': 'Koppelcode',
|
||||
'admin.venues.pair_code': 'Koppelcode:',
|
||||
'admin.venues.pair_expires': 'geldig tot {time}',
|
||||
'admin.venues.rename': 'Hernoemen',
|
||||
'admin.venues.rename_title': '{name} hernoemen',
|
||||
'admin.venues.renamed': 'Zaak hernoemd',
|
||||
'admin.venues.copy_catalog': 'Producten kopiëren',
|
||||
'admin.venues.reset': 'Resetten…',
|
||||
'admin.venues.delete': 'Verwijderen…',
|
||||
'admin.check.clean': 'Niets gevonden: de gegevens kloppen.',
|
||||
'admin.check.severity.error': 'Fout',
|
||||
'admin.check.severity.warning': 'Waarschuwing',
|
||||
'admin.check.severity.info': 'Opmerking',
|
||||
'admin.reset.title': '{name} resetten',
|
||||
'admin.reset.desc': 'Gebruikers, kassa\'s en producten blijven. De kassa\'s nemen de wijziging over bij hun volgende sync.',
|
||||
'admin.reset.preset_tabs': 'Alle poefs wissen',
|
||||
'admin.reset.preset_event': 'Nieuw evenement',
|
||||
'admin.reset.preset_full': 'Volledige reset',
|
||||
'admin.reset.scope.tabs': 'Open poefs',
|
||||
'admin.reset.scope.tabs_hint': 'Onbetaalde drankjes erop gaan verloren',
|
||||
'admin.reset.scope.settlements': 'Betaalde poefs',
|
||||
'admin.reset.scope.settlements_hint': 'De verkoopgeschiedenis',
|
||||
'admin.reset.scope.tabEvents': 'Kassagebeurtenissen',
|
||||
'admin.reset.scope.tabEvents_hint': 'Alleen als elke kassa gesynct is: een opnieuw verstuurde gebeurtenis telt anders dubbel',
|
||||
'admin.reset.scope.plannedTabs': 'Geplande klanten',
|
||||
'admin.reset.scope.plannedTabs_hint': 'Met hun e-mailadressen en herinneringen',
|
||||
'admin.reset.scope.stock': 'Voorraad',
|
||||
'admin.reset.scope.stock_hint': 'Elk opgevolgd product krijgt dezelfde telling',
|
||||
'admin.reset.scope.stockHistory': 'Voorraadgeschiedenis',
|
||||
'admin.reset.scope.stockHistory_hint': 'Leveringen, breuk en tellingen',
|
||||
'admin.reset.scope.commands': 'Opdrachtengeschiedenis',
|
||||
'admin.reset.scope.commands_hint': 'Poefs afgesloten via het dashboard',
|
||||
'admin.reset.scope.auditLog': 'Logboek',
|
||||
'admin.reset.scope.auditLog_hint': 'Een regel voor deze reset blijft bewaard',
|
||||
'admin.reset.stock_value': 'Voorraadtelling',
|
||||
'admin.reset.confirm': 'Resetten',
|
||||
'admin.reset.done': '{name} is gereset',
|
||||
'admin.reset.done_desc': '{count} rijen gewijzigd',
|
||||
'admin.copy.title': 'Producten kopiëren naar {name}',
|
||||
'admin.copy.desc': 'Producten die deze zaak nog niet heeft (op naam) worden toegevoegd, met hun foto. De voorraad wordt niet gekopieerd.',
|
||||
'admin.copy.from': 'Kopiëren van…',
|
||||
'admin.copy.option': '{name} ({count} producten)',
|
||||
'admin.copy.confirm': 'Kopiëren',
|
||||
'admin.copy.done': '{count} producten toegevoegd',
|
||||
'admin.delete.title': '{name} verwijderen?',
|
||||
'admin.delete.desc': 'De zaak wordt verwijderd met alles erin: {users} gebruikers, {terminals} kassa\'s en {settlements} betaalde poefs. De kassa\'s werken niet meer.',
|
||||
'admin.delete.confirm': 'Zaak verwijderen',
|
||||
'admin.delete.done': '{name} is verwijderd',
|
||||
'admin.devices.show_unpaired': 'Ontkoppelde kassa\'s tonen ({count})',
|
||||
'admin.devices.name': 'Kassa',
|
||||
'admin.devices.venue': 'Zaak',
|
||||
'admin.devices.status': 'Status',
|
||||
'admin.devices.last_seen': 'Laatst gezien',
|
||||
'admin.devices.token_expires': 'Token geldig tot',
|
||||
'admin.devices.pending': 'Wachtende opdrachten',
|
||||
'admin.devices.unpaired': 'Ontkoppeld',
|
||||
'admin.codes.title': 'Koppelcodes',
|
||||
'admin.codes.desc': 'Codes die nog gebruikt kunnen worden om een kassa te koppelen.',
|
||||
'admin.codes.none': 'Geen codes die nog wachten.',
|
||||
'admin.codes.revoke': 'Intrekken',
|
||||
'admin.codes.cleanup': 'Gebruikte en verlopen codes verwijderen ({count})',
|
||||
'admin.codes.cleaned': '{count} codes verwijderd',
|
||||
'admin.users.search': 'Zoek op naam, e-mail of zaak',
|
||||
'admin.users.sessions': 'Sessies',
|
||||
'admin.users.role_changed': '{email} is nu {role}',
|
||||
'admin.users.password': 'Wachtwoord instellen',
|
||||
'admin.users.password_title': 'Nieuw wachtwoord voor {email}',
|
||||
'admin.users.password_desc': 'De gebruiker wordt overal uitgelogd en logt in met dit wachtwoord.',
|
||||
'admin.users.password_confirm': 'Wachtwoord instellen',
|
||||
'admin.users.password_set': 'Wachtwoord van {email} gewijzigd',
|
||||
'admin.users.logout': 'Overal uitloggen',
|
||||
'admin.users.logout_title': '{email} uitloggen?',
|
||||
'admin.users.logout_desc': 'Elke sessie van deze gebruiker stopt meteen. Opnieuw inloggen kan.',
|
||||
'admin.users.logged_out': '{email} is uitgelogd',
|
||||
'admin.users.everyone': 'Iedereen uitloggen',
|
||||
'admin.users.everyone_title': 'Alle gebruikers uitloggen?',
|
||||
'admin.users.everyone_desc': 'Iedereen behalve jij wordt meteen uitgelogd, in elke zaak. De kassa\'s blijven werken.',
|
||||
'admin.users.everyone_done': '{count} gebruikers uitgelogd',
|
||||
'admin.stuck.title': 'Vastgelopen opdrachten',
|
||||
'admin.stuck.desc': 'Afsluitverzoeken die geen kassa oppikte. De server sluit poefs nu zelf af, dus deze worden nooit meer uitgevoerd.',
|
||||
'admin.stuck.count': 'Wachtend: {count}',
|
||||
'admin.stuck.confirm': 'Alle laten verlopen',
|
||||
'admin.stuck.done': '{count} opdrachten verlopen',
|
||||
'admin.images.title': 'Ongebruikte foto\'s',
|
||||
'admin.images.desc': 'Productfoto\'s die geen product meer gebruikt, ouder dan een week.',
|
||||
'admin.images.count': '{count} foto\'s, {size}',
|
||||
'admin.images.confirm': 'Verwijderen',
|
||||
'admin.images.done': '{count} foto\'s verwijderd',
|
||||
'admin.purge.title': 'Oude geschiedenis verwijderen',
|
||||
'admin.purge.desc': 'Houdt de database klein. Open poefs, verkopen en voorraad blijven altijd.',
|
||||
'admin.purge.target.tabEvents': 'Kassagebeurtenissen',
|
||||
'admin.purge.target.tabEvents_hint': 'Wat de kassa\'s doorgaven, drankje per drankje',
|
||||
'admin.purge.target.auditLog': 'Logboek',
|
||||
'admin.purge.target.auditLog_hint': 'Wie wat deed op het dashboard',
|
||||
'admin.purge.target.commands': 'Afgehandelde opdrachten',
|
||||
'admin.purge.target.commands_hint': 'Wachtende blijven bewaard',
|
||||
'admin.purge.days': 'Ouder dan (dagen)',
|
||||
'admin.purge.preview': 'Voorbeeld…',
|
||||
'admin.purge.confirm_desc': 'Alles ouder dan {days} dagen:',
|
||||
'admin.purge.confirm': 'Verwijderen',
|
||||
'admin.purge.done': '{count} rijen verwijderd',
|
||||
'admin.email.title': 'Test-e-mail',
|
||||
'admin.email.desc': 'Verstuurt de test-e-mail, om te controleren dat herinneringen versturen werkt.',
|
||||
'admin.email.send': 'Versturen',
|
||||
'admin.email.sent': 'Test-e-mail verstuurd naar {email}',
|
||||
'admin.audit.action_filter': 'Filter op actie',
|
||||
'admin.audit.when': 'Wanneer',
|
||||
'admin.audit.who': 'Wie',
|
||||
'admin.audit.action': 'Actie',
|
||||
'admin.audit.details': 'Details',
|
||||
'admin.audit.page': 'Pagina {page} van {pages} ({total} regels)',
|
||||
'admin.audit.previous': 'Vorige',
|
||||
'admin.audit.next': 'Volgende',
|
||||
'admin.health.database': 'Databasegrootte',
|
||||
'admin.health.uptime': 'API draait sinds',
|
||||
'admin.health.connections': 'Live dashboards',
|
||||
'admin.health.migrations': 'Migraties uitgevoerd',
|
||||
'admin.health.migrations_failed': '{count} mislukt',
|
||||
'admin.health.failed_list': 'Mislukte migraties: {names}',
|
||||
'admin.health.latest_migration': 'Laatste migratie:',
|
||||
'admin.health.expiry_job': 'Opdrachten-verloopjob',
|
||||
'admin.health.not_yet': 'nog niet (elk uur)',
|
||||
'admin.health.stuck': 'Vastgelopen opdrachten',
|
||||
'admin.health.email': 'E-mail',
|
||||
'admin.health.configured': 'Ingesteld',
|
||||
'admin.health.not_configured': 'Niet ingesteld',
|
||||
'admin.health.node': 'Node.js',
|
||||
'admin.health.tables': 'Rijen per tabel',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { io } from 'socket.io-client';
|
||||
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
// Server topics mapped to the dashboard queries that show that data
|
||||
const TOPIC_QUERIES: Record<string, string[][]> = {
|
||||
@@ -20,12 +21,14 @@ const RealtimeContext = createContext({ connected: false });
|
||||
|
||||
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { venueId } = useSession();
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${new URL(API_URL).origin}/realtime`, {
|
||||
// Read on every (re)connect so a refreshed token is used
|
||||
auth: (cb) => cb({ token: getAccessToken() }),
|
||||
// Read on every (re)connect so a refreshed token is used. The venue is only used for a superadmin viewing
|
||||
// another venue; everyone else follows their own
|
||||
auth: (cb) => cb({ token: getAccessToken(), venueId }),
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
@@ -64,7 +67,7 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [queryClient]);
|
||||
}, [queryClient, venueId]);
|
||||
|
||||
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,18 @@ export interface Session {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
/** The venue the dashboard shows: the user's own, or the one a superadmin is viewing */
|
||||
venueId: string;
|
||||
/** The venue the user belongs to */
|
||||
homeVenueId: string;
|
||||
/** Set while a superadmin views another venue */
|
||||
viewing: ViewedVenue | null;
|
||||
}
|
||||
|
||||
export type ViewedVenue = { id: string; name: string };
|
||||
|
||||
const VIEWING_KEY = 'kooltap-viewing-venue';
|
||||
|
||||
/** The signed-in user from the access token cookie, or null when there is none or it can't be read. */
|
||||
export function readSession(): Session | null {
|
||||
const token = getCookie('access_token');
|
||||
@@ -20,12 +29,50 @@ export function readSession(): Session | null {
|
||||
if (!payload.venueId || !payload.role) return null;
|
||||
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
|
||||
const role = payload.role === 'viewer' ? 'manager' : payload.role;
|
||||
return { userId: payload.sub, email: payload.email, role, venueId: payload.venueId };
|
||||
const viewing = role === 'superadmin' ? readViewing() : null;
|
||||
const showing = viewing && viewing.id !== payload.venueId ? viewing : null;
|
||||
return {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role,
|
||||
venueId: showing?.id ?? payload.venueId,
|
||||
homeVenueId: payload.venueId,
|
||||
viewing: showing,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readViewing(): ViewedVenue | null {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VIEWING_KEY) ?? 'null');
|
||||
return stored && typeof stored.id === 'string' && typeof stored.name === 'string' ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the dashboard as [venue], or the superadmin's own venue with null. Reloads, so every page and query starts
|
||||
* over with the other venue.
|
||||
*/
|
||||
export function viewVenue(venue: ViewedVenue | null, path = '/dashboard') {
|
||||
try {
|
||||
if (venue) localStorage.setItem(VIEWING_KEY, JSON.stringify(venue));
|
||||
else localStorage.removeItem(VIEWING_KEY);
|
||||
} catch {
|
||||
// Without storage a superadmin can only see their own venue
|
||||
}
|
||||
window.location.href = path;
|
||||
}
|
||||
|
||||
/** Users and terminals: the owner, and KoolTap's superadmins. */
|
||||
export const canAdministerVenue = (role: Role) => role === 'admin' || role === 'superadmin';
|
||||
|
||||
/** Products, stock, tabs and reminders. */
|
||||
export const canManageVenue = (role: Role) => role === 'admin' || role === 'manager' || role === 'superadmin';
|
||||
|
||||
const SessionContext = createContext<Session | null>(null);
|
||||
|
||||
export function SessionProvider({ session, children }: { session: Session; children: ReactNode }) {
|
||||
|
||||
Reference in New Issue
Block a user