feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -10,6 +10,11 @@ const securityHeaders = [
/** @type {import('next').NextConfig} */
const nextConfig = {
poweredByHeader: false,
// A small server with only what it needs, for the Docker image
output: 'standalone',
outputFileTracingRoot: require('path').join(__dirname, '../..'),
// The shared package is TypeScript source
transpilePackages: ['@kooltap/shared'],
async headers() {
return [
{ source: '/:path*', headers: securityHeaders },
+29 -34
View File
@@ -1,48 +1,43 @@
{
"name": "@kooltap/dashboard",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"clean": "echo 'clean'"
"test": "vitest run",
"clean": "rimraf .next"
},
"dependencies": {
"@kooltap/shared": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.309.0",
"next": "^14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.0",
"socket.io-client": "^4.8.4",
"tailwind-merge": "^2.2.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.22.4"
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.548.0",
"next": "^16.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.0"
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"rimraf": "^6.0.1",
"tailwindcss": "^3.4.18",
"typescript": "~5.9.3",
"vitest": "^4.0.6"
}
}
}
@@ -11,7 +11,8 @@ import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
const MIN_PASSWORD_LENGTH = 6;
// As the API requires
const MIN_PASSWORD_LENGTH = 8;
export default function AccountPage() {
const { email, role } = useSession();
@@ -27,7 +27,7 @@ export function DevicesSection() {
const { t, te, relativeTime, dateTime } = useLang();
const [showUnpaired, setShowUnpaired] = useState(false);
const [unpairing, setUnpairing] = useState<AdminDevice | null>(null);
const sort = useSort<'name' | 'venue' | 'status' | 'lastSeen' | 'tokenExpires' | 'pending'>();
const sort = useSort<'name' | 'venue' | 'status' | 'lastSeen' | 'tokenExpires' >();
const { data, isLoading, error } = useQuery({
queryKey: ['admin', 'devices'],
@@ -60,14 +60,13 @@ export function DevicesSection() {
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error)} />;
const all = data?.devices ?? [];
const all = data ?? [];
const devices = sort.apply(showUnpaired ? all : all.filter((d) => !d.revokedAt), {
name: (d) => d.name,
venue: (d) => d.venueName,
status: (d) => STATUS_ORDER.indexOf(getDeviceStatus(d.lastSeenAt, d.revokedAt)),
lastSeen: (d) => new Date(d.lastSeenAt),
tokenExpires: (d) => new Date(d.tokenExpiresAt),
pending: (d) => d.pendingCommands,
});
const now = Date.now();
@@ -89,7 +88,6 @@ export function DevicesSection() {
<SortableHead sort={sort} column="status">{t('admin.devices.status')}</SortableHead>
<SortableHead sort={sort} column="lastSeen">{t('admin.devices.last_seen')}</SortableHead>
<SortableHead sort={sort} column="tokenExpires">{t('admin.devices.token_expires')}</SortableHead>
<SortableHead sort={sort} column="pending" className="text-right">{t('admin.devices.pending')}</SortableHead>
<TableHead />
</TableRow>
</TableHeader>
@@ -108,7 +106,6 @@ export function DevicesSection() {
<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>
@@ -47,7 +47,7 @@ export function AuditSection() {
<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>)}
{(venues.data ?? []).map((v) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -75,9 +75,9 @@ export function AuditSection() {
return (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{dateTime(entry.createdAt)}</TableCell>
<TableCell>{entry.venueName}</TableCell>
<TableCell>{entry.venueName ?? '—'}</TableCell>
<TableCell>
{entry.actorName ?? <span className="font-mono text-xs">{entry.actorId.slice(0, 8)}</span>}
{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>
@@ -130,11 +130,7 @@ export function HealthSection() {
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.images'), value: formatBytes(data.imageBytes) },
{ 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 },
];
@@ -2,7 +2,8 @@
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 { PURGE_TARGETS } from '@kooltap/shared';
import { adminApi, 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';
@@ -17,7 +18,6 @@ const ALL_VENUES = 'all';
export function MaintenanceSection() {
return (
<div className="grid gap-6 lg:grid-cols-2">
<StuckCommandsCard />
<ImagesCard />
<PurgeCard />
<TestEmailCard />
@@ -34,34 +34,6 @@ function useFeedback() {
};
}
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();
@@ -108,7 +80,7 @@ function ImagesCard() {
function PurgeCard() {
const { t, te } = useLang();
const { onError, refresh } = useFeedback();
const [targets, setTargets] = useState<PurgeTarget[]>(['tabEvents', 'commands']);
const [targets, setTargets] = useState<PurgeTarget[]>(['tabEvents']);
const [days, setDays] = useState('90');
const [venueId, setVenueId] = useState(ALL_VENUES);
const [confirming, setConfirming] = useState(false);
@@ -162,7 +134,7 @@ function PurgeCard() {
<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>)}
{(venues.data ?? []).map((v: AdminVenue) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -30,9 +30,12 @@ export function UsersSection() {
const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null);
const [password, setPassword] = useState('');
const [logoutAll, setLogoutAll] = useState(false);
// A superadmin becoming an admin or manager joins a venue: picked here first
const [joining, setJoining] = useState<{ user: AdminUser; role: Role; venueId: string } | null>(null);
const sort = useSort<'name' | 'venue' | 'role' | 'sessions' | 'created'>();
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users });
const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues, enabled: joining !== null });
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
@@ -43,10 +46,18 @@ export function UsersSection() {
};
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}`) })),
mutationFn: ({ user, role, venueId }: { user: AdminUser; role: Role; venueId?: string }) => adminApi.updateRole(user.id, role, venueId),
onSuccess: (_, { user, role }) => {
setJoining(null);
done(t('admin.users.role_changed', { email: user.email, role: t(`users.role.${role}`) }));
},
onError,
});
function changeRole(user: AdminUser, next: Role) {
if (user.venueId === null && next !== 'superadmin') setJoining({ user, role: next, venueId: '' });
else role.mutate({ user, role: next });
}
const resetPassword = useMutation({
mutationFn: (user: AdminUser) => adminApi.resetPassword(user.id, password),
onSuccess: (_, user) => done(t('admin.users.password_set', { email: user.email })),
@@ -76,8 +87,8 @@ export function UsersSection() {
if (error) return <ErrorState message={te(error)} />;
const query = search.trim().toLowerCase();
const users = sort.apply((data?.users ?? []).filter((u) =>
!query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query))), {
const users = sort.apply((data ?? []).filter((u) =>
!query || [u.name, u.email, u.venueName ?? ''].some((field) => field.toLowerCase().includes(query))), {
name: (u) => u.name,
venue: (u) => u.venueName,
role: (u) => t(`users.role.${u.role}`),
@@ -120,10 +131,10 @@ export function UsersSection() {
<span className="font-medium">{user.name}</span>
<span className="block text-xs text-muted-foreground">{user.email}</span>
</TableCell>
<TableCell>{user.venueName}</TableCell>
<TableCell className={user.venueName ? undefined : 'text-muted-foreground'}>{user.venueName ?? t('admin.users.no_venue')}</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}>
<Select value={user.role} onValueChange={(next) => changeRole(user, 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>)}
@@ -171,13 +182,34 @@ export function UsersSection() {
</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}>
<Button onClick={() => action && resetPassword.mutate(action.user)} disabled={password.length < 8 || resetPassword.isPending}>
{resetPassword.isPending ? t('admin.working') : t('admin.users.password_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={joining !== null} onOpenChange={(open) => !open && !role.isPending && setJoining(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.users.pick_venue', { name: joining?.user.name ?? '' })}</DialogTitle>
<DialogDescription>{t('admin.users.pick_venue_desc')}</DialogDescription>
</DialogHeader>
<Select value={joining?.venueId ?? ''} onValueChange={(venueId) => joining && setJoining({ ...joining, venueId })}>
<SelectTrigger><SelectValue placeholder={t('admin.users.pick_venue_placeholder')} /></SelectTrigger>
<SelectContent>
{(venues.data ?? []).map((v) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setJoining(null)} disabled={role.isPending}>{t('common.cancel')}</Button>
<Button onClick={() => joining && role.mutate(joining)} disabled={!joining?.venueId || role.isPending}>
{role.isPending ? t('admin.working') : t(`users.role.${joining?.role ?? 'admin'}`)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={action?.kind === 'logout'}
onOpenChange={(open) => !open && !logout.isPending && setAction(null)}
@@ -3,7 +3,8 @@
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 { RESET_SCOPES } from '@kooltap/shared';
import { adminApi, 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';
@@ -18,13 +19,13 @@ 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 { 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_event', scopes: ['tabs', 'customers', 'stock'] },
{ key: 'admin.reset.preset_full', scopes: [...RESET_SCOPES] },
];
@@ -36,7 +37,7 @@ export function VenuesSection() {
const sort = useSort<'name' | 'users' | 'terminals' | 'openTabs' | 'products' | 'lastSync'>();
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
const venues = data?.venues ?? [];
const venues = data ?? [];
const selected = venues.find((v) => v.id === selectedId) ?? null;
if (isLoading) return <LoadingState />;
@@ -118,7 +119,6 @@ export function VenuesSection() {
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);
@@ -148,7 +148,7 @@ function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: A
<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 });
viewVenue({ id: venue.id, name: venue.name });
})} disabled={busy !== null}>
<Eye className="h-4 w-4 mr-2" /> {t('admin.venues.view')}
</Button>
@@ -174,11 +174,9 @@ function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: A
<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>
)}
<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 && (
@@ -1,137 +0,0 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { commandsApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CommandStatusBadge } from '@/components/command-status-badge';
import { PaymentMethodBadge } from '@/components/payment-method-badge';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { Filter } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { formatCents } from '@/lib/utils';
const statusFilters = [
{ value: '', label: 'commands.all' },
{ value: 'pending', label: 'status.pending' },
{ value: 'completed', label: 'status.completed' },
{ value: 'failed', label: 'status.failed' },
{ value: 'conflict', label: 'status.conflict' },
{ value: 'expired', label: 'status.expired' },
];
export default function CommandsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const { t, tn, te, relativeTime } = useLang();
const { venueId } = useSession();
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0);
const limit = 20;
const { data, isLoading, error } = useQuery({
queryKey: ['commands', venueId, statusFilter, page],
queryFn: () => commandsApi.list(venueId, { limit, offset: page * limit, status: statusFilter || undefined }),
refetchInterval: live ? false : 10000,
});
const commands = data?.commands ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error, 'commands.load_failed')} />;
return (
<div>
<PageHeader
title={t('commands.title')}
description={tn('commands.count', total)}
actions={
<div className="flex gap-2 flex-wrap">
{statusFilters.map((f) => (
<Button
key={f.value}
variant={statusFilter === f.value ? 'default' : 'outline'}
size="sm"
onClick={() => { setStatusFilter(f.value); setPage(0); }}
>
{t(f.label)}
</Button>
))}
</div>
}
/>
{commands.length === 0 ? (
<EmptyState title={t('commands.empty')} description={t('commands.empty_desc')} />
) : (
<>
<div className="rounded-md border mb-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('commands.customer')}</TableHead>
<TableHead>{t('commands.amount')}</TableHead>
<TableHead>{t('commands.requested')}</TableHead>
<TableHead>{t('commands.payment')}</TableHead>
<TableHead>{t('commands.created')}</TableHead>
<TableHead>{t('commands.status')}</TableHead>
<TableHead>{t('commands.processed')}</TableHead>
<TableHead>{t('commands.result')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{commands.map((cmd) => (
<TableRow key={cmd.id}>
<TableCell className="font-medium">{cmd.customerName ?? <span className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</span>}</TableCell>
<TableCell className="tabular-nums">{cmd.amountInCents != null ? formatCents(cmd.amountInCents) : '-'}</TableCell>
<TableCell>{cmd.requestedBy}</TableCell>
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
<TableCell>{relativeTime(cmd.createdAt)}</TableCell>
<TableCell><CommandStatusBadge status={cmd.status} /></TableCell>
<TableCell>{cmd.processedAt ? relativeTime(cmd.processedAt) : '-'}</TableCell>
<TableCell className="max-w-[200px] truncate">{cmd.result || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{t('commands.page', { page: page + 1, total: totalPages })}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
{t('commands.previous')}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
>
{t('commands.next')}
</Button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -22,7 +22,6 @@ const paymentFilters = [
{ value: '', label: 'history.all_methods' },
{ value: 'cash', label: 'payment.cash' },
{ value: 'payconiq', label: 'payment.payconiq' },
{ value: 'payment_pending', label: 'payment.payment_pending' },
];
const limit = 25;
@@ -49,8 +48,8 @@ export default function HistoryPage() {
}, [search]);
const { data, isLoading, error } = useQuery({
queryKey: ['tab-history', venueId, debouncedSearch, paymentMethod, page],
queryFn: () => tabsApi.history(venueId, { search: debouncedSearch, paymentMethod, limit, offset: page * limit }),
queryKey: ['settlements', venueId, debouncedSearch, paymentMethod, page],
queryFn: () => tabsApi.settlements(venueId, { search: debouncedSearch, paymentMethod, limit, offset: page * limit }),
placeholderData: keepPreviousData,
refetchInterval: live ? false : 10000,
});
@@ -76,7 +75,7 @@ export default function HistoryPage() {
<PageHeader
title={t('history.title')}
description={`${tn('history.count', total)} · ${t('history.total', { amount: formatCents(data?.totalAmountInCents ?? 0) })}`}
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tab-history'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['settlements'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
/>
{total === 0 && !filtered ? (
+24 -35
View File
@@ -7,7 +7,6 @@ import {
LayoutDashboard,
ClipboardList,
Monitor,
Terminal,
Settings,
LogOut,
Menu,
@@ -23,14 +22,17 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
import { useLang } from '@/lib/i18n';
import { LanguageSelector } from '@/components/language-selector';
import { authApi, venuesApi } from '@/lib/api/client';
import { authApi } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
import { canAdministerVenue, readSession, Session, SessionProvider, viewVenue } from '@/lib/session';
import { RealtimeProvider } from '@/lib/realtime';
import { canAdministerVenue, hasVenue, Session, sessionFor, SessionProvider, viewVenue } from '@/lib/session';
import { LoadingState } from '@/components/loading-state';
// Who sees each item: everyone, admins (and superadmins), or superadmins only
type Access = 'all' | 'admin' | 'superadmin';
// Who sees each item: everyone in a venue, admins (and superadmins viewing a venue), superadmins only, or everyone
type Access = 'all' | 'admin' | 'superadmin' | 'always';
// The pages a superadmin reaches without viewing a venue
const VENUELESS_PAGES = ['/dashboard/admin', '/dashboard/account'];
const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' },
@@ -39,9 +41,8 @@ const navItems: { href: string; key: string; icon: typeof LayoutDashboard; acces
{ 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/account', key: 'nav.account', icon: UserCog, access: 'always' },
{ href: '/dashboard/admin', key: 'nav.admin', icon: ShieldAlert, access: 'superadmin' },
];
@@ -54,30 +55,30 @@ export default function DashboardLayout({
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [user, setUser] = useState<Session | null>(null);
const [venue, setVenue] = useState<{ name: string } | null>(null);
const { t } = useLang();
// Without a session the API call sends the user to the login page
useEffect(() => {
const session = readSession();
if (!session) {
router.replace('/login');
return;
}
setUser(session);
venuesApi.get(session.venueId).then(setVenue).catch(() => {});
authApi.me().then((me) => setUser(sessionFor(me)), () => router.replace('/login'));
}, [router]);
if (!user) return <LoadingState />;
const inVenue = user !== null && hasVenue(user);
// A superadmin without a venue starts on the admin page
useEffect(() => {
if (user && !inVenue && !VENUELESS_PAGES.includes(pathname)) router.replace('/dashboard/admin');
}, [user, inVenue, pathname, router]);
function handleLogout() {
authApi.logout();
if (!user || (!inVenue && !VENUELESS_PAGES.includes(pathname))) return <LoadingState />;
async function handleLogout() {
await authApi.logout().catch(() => {});
toast({ title: t('common.logged_out'), variant: 'default' });
router.push('/login');
}
return (
<SessionProvider session={user}>
<RealtimeProvider>
<RealtimeProvider enabled={inVenue}>
<div className="min-h-screen bg-background">
{/* Mobile sidebar toggle */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-14 px-4 border-b bg-background">
@@ -106,9 +107,7 @@ export default function DashboardLayout({
<div className="flex flex-col h-full">
<div className="p-6 border-b">
<h2 className="text-xl font-bold">KoolTap</h2>
{venue && (
<p className="text-sm text-muted-foreground mt-1">{venue.name}</p>
)}
<p className="text-sm text-muted-foreground mt-1">{inVenue ? user.venueName : t('admin.title')}</p>
</div>
<nav className="flex-1 p-4 space-y-1">
@@ -116,8 +115,9 @@ export default function DashboardLayout({
const Icon = item.icon;
const active = pathname === item.href;
if (item.access === 'admin' && !canAdministerVenue(user.role)) return null;
if (item.access === 'superadmin' && user.role !== 'superadmin') return null;
if ((item.access === 'all' || item.access === 'admin') && !inVenue) return null;
if (item.access === 'admin' && !canAdministerVenue(user.role)) return null;
return (
<Link
@@ -174,14 +174,3 @@ export default function DashboardLayout({
</SessionProvider>
);
}
function LiveIndicator() {
const { connected } = useRealtime();
const { t } = useLang();
return (
<div className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
<span className={cn('h-2 w-2 rounded-full', connected ? 'bg-green-500' : 'bg-muted-foreground/40')} />
{connected ? t('realtime.live') : t('realtime.offline')}
</div>
);
}
+34 -44
View File
@@ -1,59 +1,48 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { commandsApi, devicesApi, tabsApi } from '@/lib/api/client';
import { ClipboardList, Monitor, Euro, Users } from 'lucide-react';
import { customersApi, devicesApi, tabsApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { CommandStatusBadge } from '@/components/command-status-badge';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { ClipboardList, Terminal, Monitor, Euro } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
import { PaymentMethodBadge } from '@/components/payment-method-badge';
import { usePollingFallback } from '@/lib/realtime';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { formatCents } from '@/lib/utils';
export default function DashboardPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const { t, relativeTime } = useLang();
const { venueId } = useSession();
// Live updates make polling unnecessary; it only runs while the socket is down
const poll = usePollingFallback(10_000);
const tabsQuery = useQuery({
queryKey: ['tabs', venueId],
queryFn: () => tabsApi.list(venueId),
refetchInterval: live ? false : 10000,
const tabsQuery = useQuery({ queryKey: ['tabs', venueId], queryFn: () => tabsApi.list(venueId), refetchInterval: poll });
const customersQuery = useQuery({ queryKey: ['customers', venueId], queryFn: () => customersApi.list(venueId), refetchInterval: poll });
const recentQuery = useQuery({
queryKey: ['settlements', venueId, 'recent'],
queryFn: () => tabsApi.settlements(venueId, { limit: 5 }),
refetchInterval: poll,
});
const deviceQuery = useQuery({
queryKey: ['device', venueId],
const devicesQuery = useQuery({
queryKey: ['devices', venueId],
queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000,
refetchInterval: 30_000,
});
const recentQuery = useQuery({
queryKey: ['commands', venueId, 'recent'],
queryFn: () => commandsApi.list(venueId, { limit: 5 }),
refetchInterval: live ? false : 15000,
});
const pendingQuery = useQuery({
queryKey: ['commands', venueId, 'pending-count'],
queryFn: () => commandsApi.list(venueId, { status: 'pending', limit: 1 }),
refetchInterval: live ? false : 15000,
});
const tabs = tabsQuery.data?.tabs ?? [];
const devices = deviceQuery.data?.devices ?? [];
const tabs = tabsQuery.data ?? [];
const devices = devicesQuery.data ?? [];
// The terminal heard from most recently
const device = devices.reduce<(typeof devices)[number] | undefined>(
(latest, d) => (!latest || d.lastSeenAt > latest.lastSeenAt ? d : latest),
undefined,
);
const commands = recentQuery.data?.commands ?? [];
const settlements = recentQuery.data?.settlements ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
const isLoading = tabsQuery.isLoading || devicesQuery.isLoading || customersQuery.isLoading;
return (
<div>
@@ -89,8 +78,9 @@ export default function DashboardPage() {
<CardContent>
{device ? (
<>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold truncate">{device.name}</span>
{/* The badge goes under a long name rather than squeezing it */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-2xl font-bold truncate max-w-full">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
</div>
<p className="text-xs text-muted-foreground mt-1">
@@ -106,10 +96,10 @@ export default function DashboardPage() {
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{t('overview.pending')}</CardTitle>
<Terminal className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">{t('overview.customers')}</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent><div className="text-2xl font-bold">{pendingQuery.data?.total ?? 0}</div></CardContent>
<CardContent><div className="text-2xl font-bold">{customersQuery.data?.length ?? 0}</div></CardContent>
</Card>
</div>
)}
@@ -117,20 +107,20 @@ export default function DashboardPage() {
<Card>
<CardHeader><CardTitle className="text-lg">{t('overview.recent')}</CardTitle></CardHeader>
<CardContent>
{commands.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('overview.no_commands')}</p>
{settlements.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('overview.no_payments')}</p>
) : (
<div className="space-y-3">
{commands.map((cmd) => (
<div key={cmd.id} className="flex items-center justify-between gap-2 text-sm">
{settlements.map((settlement) => (
<div key={settlement.id} className="flex items-center justify-between gap-2 text-sm">
<div className="min-w-0">
<span className="font-medium">{cmd.customerName ?? cmd.tabId.substring(0, 8)}</span>
{cmd.amountInCents != null && <span className="ml-2 tabular-nums">{formatCents(cmd.amountInCents)}</span>}
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
<span className="font-medium">{settlement.customerName}</span>
<span className="ml-2 tabular-nums">{formatCents(settlement.amountInCents)}</span>
{settlement.settledBy && <span className="text-muted-foreground ml-2">{settlement.settledBy}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{relativeTime(cmd.createdAt)}</span>
<PaymentMethodBadge method={settlement.paymentMethod} />
<span className="text-muted-foreground">{relativeTime(settlement.settledAt)}</span>
</div>
</div>
))}
@@ -38,7 +38,7 @@ export default function RemindersPage() {
// Null until the admin changes the selection: then every eligible customer is selected
const [picked, setPicked] = useState<Set<string> | null>(null);
const [historyFor, setHistoryFor] = useState<string | null>(null);
const historySort = useSort<'customer' | 'email' | 'amount' | 'sentAt' | 'status'>();
const historySort = useSort<'customer' | 'email' | 'amount' | 'sentAt'>();
const candidateSort = useSort<'customer' | 'email' | 'amount' | 'count' | 'lastSent' | 'status'>();
const candidatesQuery = useQuery({
@@ -134,14 +134,13 @@ export default function RemindersPage() {
return <Badge variant="warning">{tn('reminders.state_cooldown', days)}</Badge>;
};
const reminders = historySort.apply(historyQuery.data?.reminders ?? [], {
const reminders = historySort.apply(historyQuery.data ?? [], {
customer: (r) => r.customerName,
email: (r) => r.email,
amount: (r) => r.amountInCents,
sentAt: (r) => new Date(r.sentAt),
status: (r) => r.status,
});
const personReminders = personHistoryQuery.data?.reminders ?? [];
const personReminders = personHistoryQuery.data ?? [];
return (
<div>
@@ -174,7 +173,6 @@ export default function RemindersPage() {
<SortableHead sort={historySort} column="email">{t('reminders.email')}</SortableHead>
<SortableHead sort={historySort} column="amount">{t('reminders.amount')}</SortableHead>
<SortableHead sort={historySort} column="sentAt">{t('reminders.sent_at')}</SortableHead>
<SortableHead sort={historySort} column="status">{t('reminders.status')}</SortableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -188,7 +186,6 @@ export default function RemindersPage() {
<TableCell>{r.email}</TableCell>
<TableCell>{formatCents(r.amountInCents)}</TableCell>
<TableCell title={dateTime(r.sentAt)}>{relativeTime(r.sentAt)}</TableCell>
<TableCell><Badge variant={r.status === 'sent' ? 'success' : 'secondary'}>{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
@@ -16,7 +16,7 @@ import { canAdministerVenue, canManageVenue, useSession } from '@/lib/session';
import { Monitor, Copy, Plus, Mail, X } from 'lucide-react';
export default function SettingsPage() {
const { venueId, role, viewing } = useSession();
const { venueId, role } = useSession();
const isAdmin = canAdministerVenue(role);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false);
@@ -30,13 +30,13 @@ export default function SettingsPage() {
});
const { data: deviceData } = useQuery({
queryKey: ['device', venueId],
queryKey: ['devices', venueId],
queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000,
});
const devices = deviceData?.devices ?? [];
const devices = deviceData ?? [];
const unpairMutation = useMutation({
mutationFn: (device: Device) => devicesApi.unpair(venueId, device.id),
@@ -44,14 +44,14 @@ export default function SettingsPage() {
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
setUnpairing(null);
queryClient.invalidateQueries({ queryKey: ['device'] });
queryClient.invalidateQueries({ queryKey: ['devices'] });
},
});
async function handleGenerateCode() {
setGenerating(true);
try {
const result = await devicesApi.generateCode(viewing?.id);
const result = await devicesApi.generateCode(venueId);
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
+32 -48
View File
@@ -2,7 +2,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useEffect, useState } from 'react';
import { imageUrl, stockApi, type NewStockAdjustment, type Product, type StockAdjustment } from '@/lib/api/client';
import { imageUrl, stockApi, type NewStockMovement, type Product, type StockMovement } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -47,21 +47,11 @@ function isLow(product: Product): boolean {
}
/** "+24", "−6" or "= 48". */
function formatChange(adjustment: Pick<StockAdjustment, 'type' | 'quantity'>): string {
if (adjustment.type === 'set') return `= ${adjustment.quantity}`;
return adjustment.quantity > 0 ? `+${adjustment.quantity}` : `−${-adjustment.quantity}`;
function formatChange(movement: Pick<StockMovement, 'type' | 'quantity'>): string {
if (movement.type === 'count') return `= ${movement.quantity}`;
return movement.quantity > 0 ? `+${movement.quantity}` : `−${-movement.quantity}`;
}
// Rejection reasons the terminal sends as a code; anything else is shown as it was sent
const KNOWN_REASONS = ['unknown_product', 'stock_not_tracked'];
const statusVariant = {
pending: 'warning',
applied: 'success',
rejected: 'destructive',
cancelled: 'secondary',
} as const;
export default function StockPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
@@ -79,21 +69,21 @@ export default function StockPage() {
const sort = useSort<'product' | 'price' | 'buyPrice' | 'stock' | 'toOrder' | 'countedAt'>();
const { data, isLoading, error } = useQuery({
queryKey: ['stock', venueId],
queryFn: () => stockApi.list(venueId),
queryKey: ['products', venueId],
queryFn: () => stockApi.products(venueId),
refetchInterval: live ? false : 15000,
});
const historyQuery = useQuery({
queryKey: ['stock-history', venueId],
queryFn: () => stockApi.history(venueId, { limit: 20 }),
queryKey: ['stock-movements', venueId],
queryFn: () => stockApi.movements(venueId, { limit: 20 }),
refetchInterval: live ? false : 15000,
});
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error, 'stock.load_failed')} />;
const allProducts = data?.products ?? [];
const allProducts = data ?? [];
// Archived products are deleted for the terminals; they only show under their own filter
const products = allProducts.filter((p) => p.archivedAt === null);
const lowCount = products.filter(isLow).length;
@@ -131,12 +121,10 @@ export default function StockPage() {
<div>
<PageHeader
title={t('stock.title')}
description={data?.reportedAt
? `${tn('stock.count', products.length)} · ${tn('stock.low_count', lowCount)} · ${t('stock.reported', { time: relativeTime(data.reportedAt) })}`
: undefined}
description={allProducts.length > 0 ? `${tn('stock.count', products.length)} · ${tn('stock.low_count', lowCount)}` : undefined}
actions={
<>
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['stock'] })}>
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['products'] })}>
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
</Button>
{products.some((p) => p.stock !== null) && (
@@ -207,7 +195,7 @@ export default function StockPage() {
</TableCell>
</TableRow>
{group.products.map((product) => (
<TableRow key={product.productId}>
<TableRow key={product.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<ProductThumbnail product={product} />
@@ -258,24 +246,20 @@ export default function StockPage() {
<CardTitle className="text-sm flex items-center gap-2"><History className="h-4 w-4" /> {t('stock.history')}</CardTitle>
</CardHeader>
<CardContent>
{(historyQuery.data?.adjustments ?? []).length === 0 ? (
{(historyQuery.data?.movements ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('stock.history_empty')}</p>
) : (
<ul className="space-y-2 text-sm">
{historyQuery.data!.adjustments.map((a) => (
{historyQuery.data!.movements.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<span className="font-medium">{a.productName}</span>
<span className="ml-2 tabular-nums">{formatChange(a)}</span>
{a.stockAfter !== null && <span className="ml-2 text-muted-foreground">{t('stock.now', { stock: a.stockAfter })}</span>}
<span className="ml-2 text-muted-foreground">{t('stock.now', { stock: a.stockAfter })}</span>
{a.note && <span className="ml-2 text-muted-foreground">· {a.note}</span>}
{a.status === 'rejected' && a.result && (
<span className="ml-2 text-destructive">· {KNOWN_REASONS.includes(a.result) ? t(`stock.reason.${a.result}`) : a.result}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 text-muted-foreground">
<Badge variant={statusVariant[a.status]}>{t(`stock.status.${a.status}`)}</Badge>
<span>{a.requestedBy}</span>
{a.userName && <span>{a.userName}</span>}
<span>{relativeTime(a.createdAt)}</span>
</div>
</li>
@@ -329,15 +313,15 @@ function useAdjustMutation(venueId: string, onDone: () => void) {
const queryClient = useQueryClient();
const { t, te } = useLang();
return useMutation({
mutationFn: (adjustments: NewStockAdjustment[]) => stockApi.adjust(venueId, adjustments),
mutationFn: (movements: NewStockMovement[]) => stockApi.addMovements(venueId, movements),
onSuccess: () => {
toast({ title: t('stock.sent'), description: t('stock.sent_desc'), variant: 'success' });
onDone();
},
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['stock'] });
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
queryClient.invalidateQueries({ queryKey: ['products'] });
queryClient.invalidateQueries({ queryKey: ['stock-movements'] });
},
});
}
@@ -359,8 +343,8 @@ function AdjustDialog({ product, venueId, onClose }: { product: Product; venueId
function submit() {
if (!valid) return;
mutation.mutate([{
productId: product.productId,
type: mode === 'count' ? 'set' : 'add',
productId: product.id,
type: mode === 'count' ? 'count' : 'add',
quantity: mode === 'remove' ? -amount! : amount!,
note: note.trim() || undefined,
}]);
@@ -440,17 +424,17 @@ function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; v
<Input type="search" placeholder={t('stock.search_placeholder')} value={search} onChange={(e) => setSearch(e.target.value)} aria-label={t('stock.search_placeholder')} />
<div className="flex-1 overflow-y-auto space-y-1 py-2 min-h-0">
{visible.map((product) => (
<div key={product.productId} className="flex items-center justify-between gap-3">
<Label htmlFor={`delivery-${product.productId}`} className="flex-1 min-w-0 truncate font-normal">
<div key={product.id} className="flex items-center justify-between gap-3">
<Label htmlFor={`delivery-${product.id}`} className="flex-1 min-w-0 truncate font-normal">
{product.name}
<span className="ml-2 text-muted-foreground tabular-nums">({product.stock})</span>
</Label>
<Input
id={`delivery-${product.productId}`}
id={`delivery-${product.id}`}
inputMode="numeric"
placeholder="0"
value={quantities[product.productId] ?? ''}
onChange={(e) => setQuantities((current) => ({ ...current, [product.productId]: e.target.value }))}
value={quantities[product.id] ?? ''}
onChange={(e) => setQuantities((current) => ({ ...current, [product.id]: e.target.value }))}
className="w-20 h-8 text-right"
/>
</div>
@@ -532,8 +516,8 @@ function ProductDialog({ product, categories, venueId, onClose }: {
function done(title: string) {
toast({ title, description: t('products.sync_desc'), variant: 'success' });
queryClient.invalidateQueries({ queryKey: ['stock'] });
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
queryClient.invalidateQueries({ queryKey: ['products'] });
queryClient.invalidateQueries({ queryKey: ['stock-movements'] });
onClose();
}
@@ -545,9 +529,9 @@ function ProductDialog({ product, categories, venueId, onClose }: {
};
const saved = product === null
? await stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null })
: await stockApi.updateProduct(venueId, product.productId, fields);
if (picture) return stockApi.uploadImage(venueId, saved.productId, picture);
if (removePicture && product?.imageId) return stockApi.removeImage(venueId, saved.productId);
: await stockApi.updateProduct(venueId, product.id, fields);
if (picture) return stockApi.uploadImage(venueId, saved.id, picture);
if (removePicture && product?.imageId) return stockApi.removeImage(venueId, saved.id);
return saved;
},
onSuccess: (saved) => done(t(isNew ? 'products.created' : 'products.saved', { name: saved.name })),
@@ -555,7 +539,7 @@ function ProductDialog({ product, categories, venueId, onClose }: {
});
const archive = useMutation({
mutationFn: (archived: boolean) => stockApi.updateProduct(venueId, product!.productId, { archived }),
mutationFn: (archived: boolean) => stockApi.updateProduct(venueId, product!.id, { archived }),
onSuccess: (saved, archived) => done(t(archived ? 'products.archived' : 'products.restored', { name: saved.name })),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
});
+34 -48
View File
@@ -2,7 +2,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { commandsApi, plannedTabsApi, tabsApi, type Tab } from '@/lib/api/client';
import { customersApi, tabsApi, type DashboardPaymentMethod, type Tab } from '@/lib/api/client';
import { TabOrders } from '@/components/tab-orders';
import { PageHeader } from '@/components/page-header';
import { Input } from '@/components/ui/input';
@@ -26,7 +26,6 @@ import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { canManageVenue, useSession } from '@/lib/session';
export default function TabsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
@@ -36,7 +35,7 @@ export default function TabsPage() {
const queryClient = useQueryClient();
const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [deleteTab, setDeleteTab] = useState<Tab | null>(null);
const [paymentMethod, setPaymentMethod] = useState('');
const [paymentMethod, setPaymentMethod] = useState<DashboardPaymentMethod | ''>('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const sort = useSort<'customer' | 'email' | 'amount' | 'items' | 'reported'>({ key: 'customer', direction: 'asc' });
@@ -53,25 +52,18 @@ export default function TabsPage() {
refetchInterval: live ? false : 10000,
});
const pendingQuery = useQuery({
queryKey: ['commands', venueId, 'pending'],
queryFn: () => commandsApi.list(venueId, { status: 'pending', limit: 200 }),
refetchInterval: live ? false : 5000,
});
const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId));
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId),
const customersQuery = useQuery({
queryKey: ['customers', venueId],
queryFn: () => customersApi.list(venueId),
refetchInterval: live ? false : 10000,
});
// Customers the terminal has not opened a tab for yet; they get one on its next sync
const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt);
// Customers who don't have a tab yet; the next sync opens one
const pendingPlanned = (customersQuery.data ?? []).filter((customer) => !customer.tabOpenedAt);
const createCommandMutation = useMutation({
mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) =>
commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }),
const settleMutation = useMutation({
mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: DashboardPaymentMethod }) =>
tabsApi.settle(venueId, tab.id, tab.version, paymentMethod),
onSuccess: (result, { tab }) => {
toast({
title: t('tabs.settled'),
@@ -87,12 +79,11 @@ export default function TabsPage() {
onSettled: () => {
setCloseTab(null);
setPaymentMethod('');
queryClient.invalidateQueries({ queryKey: ['commands'] });
},
});
const deleteTabMutation = useMutation({
mutationFn: (tab: Tab) => tabsApi.remove(venueId, tab.tabId, tab.version),
mutationFn: (tab: Tab) => tabsApi.remove(venueId, tab.id, tab.version),
onSuccess: (_, tab) => toast({ title: t('tabs.deleted', { name: tab.customerName }), variant: 'success' }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
@@ -108,7 +99,7 @@ export default function TabsPage() {
function confirmClose() {
if (!closeTab || !paymentMethod) return;
createCommandMutation.mutate({ tab: closeTab, paymentMethod });
settleMutation.mutate({ tab: closeTab, paymentMethod });
}
function openAddDialog() {
@@ -122,9 +113,9 @@ export default function TabsPage() {
if (!name || adding) return;
setAdding(true);
try {
await plannedTabsApi.create(venueId, name, plannedEmail.trim() || undefined);
await customersApi.create(venueId, name, plannedEmail.trim() || undefined);
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
queryClient.invalidateQueries({ queryKey: ['customers'] });
toast({ title: t('planned.added'), description: t('planned.added_desc', { name }) });
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
@@ -135,8 +126,8 @@ export default function TabsPage() {
async function removePlannedName(id: string) {
try {
await plannedTabsApi.remove(venueId, id);
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
await customersApi.remove(venueId, id);
queryClient.invalidateQueries({ queryKey: ['customers'] });
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
}
@@ -145,16 +136,16 @@ export default function TabsPage() {
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error, 'tabs.load_failed')} />;
const tabs = data?.tabs ?? [];
const tabs = data ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const query = search.trim().toLowerCase();
const visiblePlanned = pendingPlanned.filter((pt) => !query || pt.customerName.toLowerCase().includes(query));
const visibleTabs =sort.apply(tabs.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)), {
const visiblePlanned = pendingPlanned.filter((pt) => !query || pt.name.toLowerCase().includes(query));
const visibleTabs = sort.apply(tabs.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)), {
customer: (tab) => tab.customerName,
email: (tab) => tab.email,
amount: (tab) => tab.totalInCents,
items: (tab) => tab.itemCount,
reported: (tab) => new Date(tab.lastChangedAt),
reported: (tab) => new Date(tab.updatedAt),
});
return (
@@ -221,7 +212,7 @@ export default function TabsPage() {
{visiblePlanned.map((pt) => (
<TableRow key={pt.id} className="bg-blue-50/50 dark:bg-blue-950/20 text-muted-foreground">
<TableCell className="font-medium">
<span className="flex items-center gap-1 pl-5 italic">{pt.customerName}</span>
<span className="flex items-center gap-1 pl-5 italic">{pt.name}</span>
</TableCell>
<TableCell>
{pt.email ? (
@@ -247,8 +238,8 @@ export default function TabsPage() {
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => removePlannedName(pt.id)}
title={t('planned.remove', { name: pt.customerName })}
aria-label={t('planned.remove', { name: pt.customerName })}
title={t('planned.remove', { name: pt.name })}
aria-label={t('planned.remove', { name: pt.name })}
>
<X className="h-4 w-4" />
</Button>
@@ -257,14 +248,14 @@ export default function TabsPage() {
</TableRow>
))}
{visibleTabs.map((tab) => {
const expanded = expandedTabs.has(tab.tabId);
const expanded = expandedTabs.has(tab.id);
const toggle = () => setExpandedTabs((current) => {
const next = new Set(current);
if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId);
if (next.has(tab.id)) next.delete(tab.id); else next.add(tab.id);
return next;
});
return (
<Fragment key={tab.tabId}>
<Fragment key={tab.id}>
<TableRow>
<TableCell className="font-medium">
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={t(expanded ? 'tabs.hide_items' : 'tabs.show_items', { name: tab.customerName })}>
@@ -284,15 +275,11 @@ export default function TabsPage() {
</TableCell>
<TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
<TableCell>{relativeTime(tab.updatedAt)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{closingTabIds.has(tab.tabId) ? (
<Badge variant="warning">{t('tabs.closing')}</Badge>
) : (
// Closing settles the tab on the terminal; a tab without a balance has nothing to settle
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)} aria-label={t('tabs.close_label', { name: tab.customerName })}>{t('tabs.close')}</Button>
)}
{/* A tab without a balance has nothing to settle */}
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)} aria-label={t('tabs.close_label', { name: tab.customerName })}>{t('tabs.close')}</Button>
{canManage && (
<Button
variant="ghost"
@@ -331,7 +318,7 @@ export default function TabsPage() {
venueId={venueId}
open={importOpen}
onOpenChange={setImportOpen}
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
onImported={() => queryClient.invalidateQueries({ queryKey: ['customers'] })}
/>
<Dialog open={addOpen} onOpenChange={(open) => !adding && setAddOpen(open)}>
@@ -379,7 +366,7 @@ export default function TabsPage() {
/>
{closeTab && (
<Dialog open onOpenChange={(open) => !open && !createCommandMutation.isPending && setCloseTab(null)}>
<Dialog open onOpenChange={(open) => !open && !settleMutation.isPending && setCloseTab(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('tabs.close_title', { name: closeTab.customerName })}</DialogTitle>
@@ -391,17 +378,16 @@ export default function TabsPage() {
<div className="rounded-md border p-3"><TabOrders orders={closeTab.orders} /></div>
<div className="space-y-3 pt-2">
<Label className="text-base font-semibold">{t('tabs.payment')}</Label>
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
<RadioGroup value={paymentMethod} onValueChange={(value) => setPaymentMethod(value as DashboardPaymentMethod)}>
<div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">{t('payment.cash')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">{t('payment.payconiq')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">{t('payment.payment_pending')}</Label></div>
</RadioGroup>
{!paymentMethod && <p className="text-xs text-destructive">{t('tabs.select_payment')}</p>}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCloseTab(null)} disabled={createCommandMutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={confirmClose} disabled={createCommandMutation.isPending || !paymentMethod}>{createCommandMutation.isPending ? t('tabs.sending') : t('tabs.send')}</Button>
<Button variant="outline" onClick={() => setCloseTab(null)} disabled={settleMutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={confirmClose} disabled={settleMutation.isPending || !paymentMethod}>{settleMutation.isPending ? t('tabs.sending') : t('tabs.send')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -30,7 +30,7 @@ export default function UsersPage() {
const [deleting, setDeleting] = useState<User | null>(null);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
const [submitting, setSubmitting] = useState(false);
const sort = useSort<'name' | 'email' | 'role' | 'created'>();
const sort = useSort<'name' | 'email' | 'role' | 'created' | 'lastActive'>();
const { t, tn, te, relativeTime } = useLang();
const { data, isLoading, error } = useQuery({
@@ -38,7 +38,7 @@ export default function UsersPage() {
queryFn: () => usersApi.list(venueId),
});
const users = data?.users ?? [];
const users = data ?? [];
async function handleCreateUser() {
if (!form.email || !form.password || !form.name) return;
@@ -99,6 +99,7 @@ export default function UsersPage() {
<SortableHead sort={sort} column="email">{t('users.email')}</SortableHead>
<SortableHead sort={sort} column="role">{t('users.role')}</SortableHead>
<SortableHead sort={sort} column="created">{t('users.created')}</SortableHead>
<SortableHead sort={sort} column="lastActive">{t('users.last_active')}</SortableHead>
{isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
</TableRow>
</TableHeader>
@@ -108,12 +109,16 @@ export default function UsersPage() {
email: (u) => u.email,
role: (u) => t(`users.role.${u.role}`),
created: (u) => new Date(u.createdAt),
lastActive: (u) => (u.lastActiveAt ? new Date(u.lastActiveAt) : null),
}).map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'manager' ? 'secondary' : 'default'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
<TableCell>{relativeTime(u.createdAt)}</TableCell>
<TableCell className={u.lastActiveAt ? undefined : 'text-muted-foreground'}>
{u.lastActiveAt ? relativeTime(u.lastActiveAt) : t('users.never_active')}
</TableCell>
{isAdmin && (
<TableCell className="text-right">
{/* You can't delete yourself, so the venue always keeps an admin */}
@@ -182,7 +187,7 @@ export default function UsersPage() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>{t('common.cancel')}</Button>
<Button onClick={handleCreateUser} disabled={submitting || !form.email || !form.password || !form.name}>
<Button onClick={handleCreateUser} disabled={submitting || !form.email || form.password.length < 8 || !form.name}>
{submitting ? t('users.creating') : t('users.create')}
</Button>
</DialogFooter>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+2 -3
View File
@@ -2,7 +2,7 @@
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { authApi, setTokens } from '@/lib/api/client';
import { authApi } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -24,8 +24,7 @@ export default function LoginPage() {
setLoading(true);
try {
const result = await authApi.login(email, password);
setTokens(result.accessToken, result.refreshToken);
await authApi.login(email, password);
router.push('/dashboard');
} catch (err) {
setError(te(err, 'auth.invalid'));
+2 -2
View File
@@ -2,13 +2,13 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { readSession } from '@/lib/session';
import { authApi } from '@/lib/api/client';
export default function Home() {
const router = useRouter();
useEffect(() => {
router.replace(readSession() ? '/dashboard' : '/login');
authApi.me().then(() => router.replace('/dashboard'), () => router.replace('/login'));
}, [router]);
return null;
@@ -1,18 +0,0 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { useLang } from '@/lib/i18n';
const statusVariants: Record<string, 'default' | 'warning' | 'success' | 'destructive' | 'secondary' | 'info'> = {
pending: 'warning',
completed: 'success',
failed: 'destructive',
conflict: 'destructive',
expired: 'secondary',
};
export function CommandStatusBadge({ status }: { status: string }) {
const { t } = useLang();
const variant = statusVariants[status];
return <Badge variant={variant ?? 'default'}>{variant ? t(`status.${status}`) : status}</Badge>;
}
@@ -8,7 +8,7 @@ import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from '@/components/ui/toast';
import { plannedTabsApi } from '@/lib/api/client';
import { customersApi } from '@/lib/api/client';
import { useLang } from '@/lib/i18n';
import { buildPreview, columnLabel, detectMapping, readSheet, type ColumnMapping, type SheetData } from '@/lib/customer-import';
@@ -66,8 +66,8 @@ export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported
if (!preview || preview.entries.length === 0) return;
setSubmitting(true);
try {
const result = await plannedTabsApi.import(venueId, preview.entries, mode);
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
const result = await customersApi.import(venueId, preview.entries, mode);
toast({ title: t('import.done'), description: t('import.result', { ...result }), variant: 'success' });
onImported();
handleOpenChange(false);
} catch (err) {
@@ -173,7 +173,7 @@ export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported
<TableBody>
{preview.entries.slice(0, PREVIEW_ROWS).map((entry, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{entry.customerName}</TableCell>
<TableCell className="font-medium">{entry.name}</TableCell>
<TableCell className="text-muted-foreground">{entry.email ?? '—'}</TableCell>
</TableRow>
))}
@@ -3,9 +3,9 @@
import { Badge } from '@/components/ui/badge';
import { useLang } from '@/lib/i18n';
const KNOWN_METHODS = ['cash', 'payconiq', 'payment_pending'];
import type { PaymentMethod } from '@/lib/api/client';
export function PaymentMethodBadge({ method }: { method: string }) {
export function PaymentMethodBadge({ method }: { method: PaymentMethod }) {
const { t } = useLang();
return <Badge variant="outline">{KNOWN_METHODS.includes(method) ? t(`payment.${method}`) : method}</Badge>;
return <Badge variant="outline">{t(`payment.${method}`)}</Badge>;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { buildPreview, detectMapping } from '../customer-import';
const sheet = (rows: string[][]) => ({ rows, columnCount: Math.max(...rows.map((row) => row.length)) });
describe('customer import', () => {
it('joins first and last names and keeps valid email addresses', () => {
const data = sheet([
['Voornaam', 'Achternaam', 'E-mail'],
['Jan', 'Peeters', 'jan@example.com'],
[' Els ', '', 'not an email'],
['', '', 'nobody@example.com'],
]);
const preview = buildPreview(data, detectMapping(data));
expect(preview).toEqual({
entries: [{ name: 'Jan Peeters', email: 'jan@example.com' }, { name: 'Els' }],
skippedRows: 1,
invalidEmails: 1,
});
});
it('finds the email column by its values when there is no header', () => {
const data = sheet([['Jan', 'jan@example.com'], ['Els', 'els@example.com']]);
expect(detectMapping(data)).toMatchObject({ hasHeader: false, nameColumn: 0, emailColumn: 1 });
});
});
@@ -0,0 +1,29 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import en from '../../locales/en.json';
import nl from '../../locales/nl.json';
/** Every source file of the dashboard. */
function sources(dir = join(__dirname, '..', '..')): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return name === '__tests__' ? [] : sources(path);
return /\.tsx?$/.test(name) ? [path] : [];
});
}
describe('translations', () => {
it('has every key in both languages', () => {
expect(Object.keys(nl).sort()).toEqual(Object.keys(en).sort());
});
it('has every key the dashboard asks for by name', () => {
const asked = new Set<string>();
for (const file of sources()) {
for (const [, key] of readFileSync(file, 'utf8').matchAll(/\bt[ne]?\(\s*'([a-z_]+(?:\.[a-z0-9_]+)+)'/g)) asked.add(key);
}
const missing = [...asked].filter((key) => !(key in en) && !(`${key}_plural` in en));
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { parseEuros } from '../utils';
describe('parseEuros', () => {
it('reads amounts as they are typed', () => {
expect(parseEuros('2,50')).toBe(250);
expect(parseEuros('€ 2.5')).toBe(250);
expect(parseEuros('2')).toBe(200);
});
it('refuses what is not an amount', () => {
expect(parseEuros('')).toBeNull();
expect(parseEuros('2,505')).toBeNull();
expect(parseEuros('-1')).toBeNull();
});
});
+115 -520
View File
@@ -1,3 +1,11 @@
import type {
AdminDevice, AdminRegistrationCode, AdminUser, AdminVenue, AuditEntry, Customer, DASHBOARD_PAYMENT_METHODS, Device, Finding,
ImportResult, Me, Product, PurgeTarget, RegistrationCode, Reminder, ReminderCandidate, ResetScope, Role, SendRemindersResult,
SettlementPage, StockMovement, StockMovementType, SystemHealth, Tab, User, Venue,
} from '@kooltap/shared';
export type * from '@kooltap/shared';
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
export interface ApiError {
@@ -5,597 +13,184 @@ export interface ApiError {
message: string;
}
/**
* 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 };
/** Drinks ordered together on a terminal, shown under the time the order started. */
export interface TabOrder {
// Null for drinks from older app versions, grouped by time instead
orderId: string | null;
startedAt: string;
// `removed`: drinks taken off again, only in the history
items: (TabLine & { removed?: number })[];
}
/** An open tab as the server holds it: every terminal of the venue shows the same. */
export interface Tab {
tabId: string;
customerName: string;
version: number;
totalInCents: number;
itemCount: number;
items: TabLine[] | null;
orders: TabOrder[];
lastChangedAt: string;
plannedTabId: string | null;
email: string | null;
}
/** A paid tab, settled on a terminal or on the dashboard. */
export interface PaidTab {
id: string;
tabId: string;
customerName: string;
settledAt: string;
paymentMethod: string;
amountInCents: number;
itemCount: number;
items: (TabLine & { removed: number })[];
orders: TabOrder[];
settledBy: string | null;
onDashboard: boolean;
}
export type CommandStatus ='pending' | 'completed' | 'failed' | 'conflict' | 'expired';
export interface Command {
id: string;
tabId: string;
customerName: string | null;
amountInCents: number | null;
type: string;
status: CommandStatus;
paymentMethod: string;
requestedBy: string;
expectedVersion: number;
createdAt: string;
processedAt: string | null;
result: string | null;
/** Null for a tab the server settled itself */
deviceId: string | null;
}
export interface Device {
id: string;
name: string;
lastSeenAt: string;
createdAt: string;
}
export interface User {
id: string;
email: string;
name: string;
role: Role;
createdAt: string;
}
export interface PlannedTab {
id: string;
customerName: string;
email: string | null;
position: number;
createdAt: string;
deliveredAt: string | null;
}
export interface ReminderCandidate {
tabId: string;
customerName: string;
/** Null when the customer has no email address on file */
email: string | null;
amountInCents: number;
itemCount: number;
items: TabLine[] | null;
reminderCount: number;
lastReminderAt: string | null;
nextEligibleAt: string | null;
/** Has an email and is past the cooldown */
eligible: boolean;
}
export interface Reminder {
id: string;
tabId: string;
customerName: string;
email: string;
amountInCents: number;
sentAt: string;
status: string;
}
export type StockAdjustmentType = 'add' | 'set';
export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled';
export interface StockAdjustment {
id: string;
productId: string;
productName: string;
/** add: the stock changes by quantity (negative takes stock out); set: the stock is now quantity */
type: StockAdjustmentType;
quantity: number;
note: string | null;
requestedBy: string;
status: StockAdjustmentStatus;
/** The terminal's stock right after applying it */
stockAfter: number | null;
/** Why the terminal rejected it */
result: string | null;
createdAt: string;
processedAt: string | null;
}
export interface Product {
productId: string;
name: string;
category: string | null;
/** The sell price, charged on the terminals */
priceInCents: number;
/** What the bar pays for one; null when not entered. Never sent to the terminals */
buyPriceInCents: number | null;
/** Null when stock is not tracked for this product; can be negative */
stock: number | null;
lowStockThreshold: number | null;
/** How many the bar wants in stock; null when not set. Never sent to the terminals */
desiredStock: number | null;
/** The latest count, on the dashboard or a terminal; null when never counted */
countedAt: string | null;
/** When it was created or last changed */
reportedAt: string;
/** Archived (deleted): hidden on the terminals */
archivedAt: string | null;
/** The picture, at imageUrl(imageId); null: none */
imageId: string | null;
}
export type DashboardPaymentMethod = (typeof DASHBOARD_PAYMENT_METHODS)[number];
/** Pictures never change at an address, so browsers cache them for good. */
export function imageUrl(imageId: string): string {
return `${API_URL}/images/${imageId}`;
}
export type ProductFields = {
name: string; category: string | null; priceInCents: number; buyPriceInCents: number | null; lowStockThreshold: number | null;
desiredStock: number | null;
};
// The session lives in httpOnly cookies on the API, so no script here can read the tokens. Refresh tokens rotate on
// use, so concurrent 401s share a single refresh request
let refreshInFlight: Promise<Me | null> | null = null;
export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
export function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
return document.cookie
.split('; ')
.find((row) => row.startsWith(`${name}=`))
?.split('=')[1] ?? null;
}
function setTokens(accessToken: string, refreshToken: string) {
// The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an
// expired JWT triggers a 401 which is transparently refreshed below.
const attributes = `path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`;
document.cookie = `access_token=${accessToken}; ${attributes}`;
document.cookie = `refresh_token=${refreshToken}; ${attributes}`;
}
function clearTokens() {
document.cookie = 'access_token=; path=/; max-age=0';
document.cookie = 'refresh_token=; path=/; max-age=0';
}
// Refresh tokens rotate on use, so concurrent 401s must share a single refresh request
let refreshInFlight: Promise<string | null> | null = null;
function refreshAccessToken(): Promise<string | null> {
if (!refreshInFlight) {
refreshInFlight = doRefreshAccessToken().finally(() => {
/** A new access token from the refresh cookie; null when the session is over. */
export function refreshSession(): Promise<Me | null> {
refreshInFlight ??= fetch(`${API_URL}/auth/refresh`, { method: 'POST', credentials: 'include' })
.then((res) => (res.ok ? res.json() : null), () => null)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
async function doRefreshAccessToken(): Promise<string | null> {
const refreshToken = getCookie('refresh_token');
if (!refreshToken) return null;
async function request(endpoint: string, init: RequestInit): Promise<Response> {
try {
const res = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
return await fetch(`${API_URL}${endpoint}`, {
...init,
credentials: 'include',
// A file upload sets its own multipart content type
headers: init.body instanceof FormData ? init.headers : { 'Content-Type': 'application/json', ...init.headers },
});
if (!res.ok) return null;
const data = await res.json();
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch {
return null;
}
}
async function request(endpoint: string, options: RequestInit, headers: Record<string, string>): Promise<Response> {
try {
return await fetch(`${API_URL}${endpoint}`, { ...options, headers });
} catch (error) {
throw { code: 'network_error', message: String(error) } satisfies ApiError;
}
}
export async function api<T = unknown>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const accessToken = getCookie('access_token');
const headers: Record<string, string> = {
// A file upload sets its own multipart content type
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(options.headers as Record<string, string>),
};
export async function api<T = unknown>(endpoint: string, init: RequestInit = {}): Promise<T> {
let res = await request(endpoint, init);
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
let res = await request(endpoint, options, headers);
if (res.status === 401 && accessToken) {
const newToken = await refreshAccessToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
res = await request(endpoint, options, headers);
if (res.status === 401 && !endpoint.startsWith('/auth/')) {
if (await refreshSession()) {
res = await request(endpoint, init);
} else {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
if (typeof window !== 'undefined') window.location.href = '/login';
throw { code: 'session_expired', message: 'Session expired' } satisfies ApiError;
}
}
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
const error: ApiError = errorBody?.error || {
code: 'unknown',
message: `Request failed with status ${res.status}`,
};
throw error;
const body = await res.json().catch(() => null);
throw (body?.error ?? { code: 'unknown', message: `Request failed with status ${res.status}` }) as ApiError;
}
return res.json();
}
// Auth API
const post = <T>(endpoint: string, body: unknown = {}) => api<T>(endpoint, { method: 'POST', body: JSON.stringify(body) });
const patch = <T>(endpoint: string, body: unknown) => api<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) });
const del = <T>(endpoint: string) => api<T>(endpoint, { method: 'DELETE' });
/** `?a=1&b=2` for the values that are set. */
function query(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== '') search.set(key, String(value));
const qs = search.toString();
return qs ? `?${qs}` : '';
}
export const authApi = {
login: (email: string, password: string) =>
api<{ accessToken: string; refreshToken: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
logout: () => {
const refreshToken = getCookie('refresh_token');
clearTokens();
if (refreshToken) {
api('/auth/logout', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
}).catch(() => {});
}
},
/** Signs out every other session; this one carries on with the new tokens */
changePassword: async (currentPassword: string, newPassword: string) => {
const result = await api<{ accessToken: string; refreshToken: string }>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
setTokens(result.accessToken, result.refreshToken);
},
login: (email: string, password: string) => post<Me>('/auth/login', { email, password }),
/** The signed-in user; refreshes the session once when the access token expired */
me: () => api<Me>('/auth/me'),
logout: () => post('/auth/logout'),
/** Signs out every other session; this one carries on */
changePassword: (currentPassword: string, newPassword: string) => post<Me>('/auth/change-password', { currentPassword, newPassword }),
};
// Tabs API
export const tabsApi = {
list: (venueId: string) =>
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
history: (venueId: string, params: { search?: string; paymentMethod?: string; limit?: number; offset?: number }) => {
const searchParams = new URLSearchParams();
if (params.search) searchParams.set('search', params.search);
if (params.paymentMethod) searchParams.set('paymentMethod', params.paymentMethod);
if (params.limit) searchParams.set('limit', String(params.limit));
if (params.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return api<{ settlements: PaidTab[]; total: number; totalAmountInCents: number }>(
`/venues/${venueId}/tabs/history${qs ? `?${qs}` : ''}`,
);
},
/** Deletes the tab without settling it; refused when it changed since [expectedVersion] */
remove: (venueId: string, tabId: string, expectedVersion: number) =>
api(`/venues/${venueId}/tabs/${encodeURIComponent(tabId)}?expectedVersion=${expectedVersion}`, { method: 'DELETE' }),
};
const venue = (venueId: string) => `/venues/${venueId}`;
// Devices API
export const devicesApi = {
list: (venueId: string) =>
api<{ devices: Device[] }>(`/venues/${venueId}/devices`),
unpair: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }),
/** [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
export const usersApi = {
list: (venueId: string) =>
api<{ users: User[] }>(`/venues/${venueId}/users`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
remove: (venueId: string, userId: string) =>
api(`/venues/${venueId}/users/${userId}`, { method: 'DELETE' }),
};
// Commands API
export const commandsApi = {
list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return api<{ commands: Command[]; total: number }>(
`/venues/${venueId}/commands${qs ? `?${qs}` : ''}`,
);
},
create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) =>
api<{ id: string; status: string; amountInCents: number }>(`/venues/${venueId}/commands`, {
method: 'POST',
body: JSON.stringify(data),
}),
};
// Venues API
export const venuesApi = {
get: (id: string) => api<{ id: string; name: string; cashPaymentEmails: string[] }>(`/venues/${id}`),
get: (venueId: string) => api<Venue>(venue(venueId)),
/** Who gets an email for every tab paid in cash, on a terminal or here */
updateCashPaymentEmails: (id: string, emails: string[]) =>
api<{ emails: string[] }>(`/venues/${id}/cash-payment-emails`, { method: 'PUT', body: JSON.stringify({ emails }) }),
updateCashPaymentEmails: (venueId: string, cashPaymentEmails: string[]) => patch<Venue>(venue(venueId), { cashPaymentEmails }),
};
// Planned Tabs API
export const plannedTabsApi = {
list: (venueId: string) => api<{ plannedTabs: PlannedTab[] }>(`/venues/${venueId}/planned-tabs`),
create: (venueId: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
update: (venueId: string, id: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }),
remove: (venueId: string, id: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
import: (venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') =>
api<{ created: number; updated: number; unchanged: number; removed: number }>(`/venues/${venueId}/planned-tabs/import`, {
method: 'POST',
body: JSON.stringify({ entries, mode }),
}),
export const tabsApi = {
list: (venueId: string) => api<Tab[]>(`${venue(venueId)}/tabs`),
/** Refused when the tab changed since [expectedVersion] */
settle: (venueId: string, tabId: string, expectedVersion: number, paymentMethod: DashboardPaymentMethod) =>
post<{ id: string; amountInCents: number }>(`${venue(venueId)}/tabs/${tabId}/settle`, { expectedVersion, paymentMethod }),
/** Deletes the tab without settling it; refused when it changed since [expectedVersion] */
remove: (venueId: string, tabId: string, expectedVersion: number) => del(`${venue(venueId)}/tabs/${tabId}${query({ expectedVersion })}`),
settlements: (venueId: string, params: { search?: string; paymentMethod?: string; limit?: number; offset?: number }) =>
api<SettlementPage>(`${venue(venueId)}/settlements${query(params)}`),
};
export const devicesApi = {
list: (venueId: string) => api<Device[]>(`${venue(venueId)}/devices`),
unpair: (venueId: string, deviceId: string) => del(`${venue(venueId)}/devices/${deviceId}`),
generateCode: (venueId: string) => post<RegistrationCode>(`${venue(venueId)}/registration-codes`),
};
export const usersApi = {
list: (venueId: string) => api<User[]>(`${venue(venueId)}/users`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) => post(`${venue(venueId)}/users`, data),
remove: (venueId: string, userId: string) => del(`${venue(venueId)}/users/${userId}`),
};
export const customersApi = {
list: (venueId: string) => api<Customer[]>(`${venue(venueId)}/customers`),
create: (venueId: string, name: string, email?: string) => post<Customer>(`${venue(venueId)}/customers`, { name, email }),
remove: (venueId: string, id: string) => del(`${venue(venueId)}/customers/${id}`),
import: (venueId: string, entries: { name: string; email?: string }[], mode: 'merge' | 'replace') =>
post<ImportResult>(`${venue(venueId)}/customers/import`, { entries, mode }),
};
// Reminders API
export const remindersApi = {
candidates: (venueId: string) =>
api<{ candidates: ReminderCandidate[]; cooldownDays: number }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string, tabIds: string[]) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, {
method: 'POST',
body: JSON.stringify({ tabIds }),
}),
history: (venueId: string, customerName?: string) =>
api<{ reminders: Reminder[] }>(
`/venues/${venueId}/reminders${customerName ? `?customerName=${encodeURIComponent(customerName)}` : ''}`,
),
sendTest: (venueId: string, plannedTabId: string) =>
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
candidates: (venueId: string) => api<{ candidates: ReminderCandidate[]; cooldownDays: number }>(`${venue(venueId)}/reminders/candidates`),
send: (venueId: string, tabIds: string[]) => post<SendRemindersResult>(`${venue(venueId)}/reminders/send`, { tabIds }),
history: (venueId: string, customerName?: string) => api<Reminder[]>(`${venue(venueId)}/reminders${query({ customerName })}`),
};
// Stock API
export type ProductFields = Pick<Product, 'name' | 'category' | 'priceInCents' | 'buyPriceInCents' | 'lowStockThreshold' | 'desiredStock'>;
export type NewStockMovement = { productId: string; type: StockMovementType; quantity: number; note?: string };
export const stockApi = {
list: (venueId: string) =>
api<{ products: Product[]; reportedAt: string | null }>(`/venues/${venueId}/stock`),
history: (venueId: string, params?: { productId?: string; limit?: number; offset?: number }) => {
const searchParams = new URLSearchParams();
if (params?.productId) searchParams.set('productId', params.productId);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return api<{ adjustments: StockAdjustment[]; total: number }>(`/venues/${venueId}/stock/adjustments${qs ? `?${qs}` : ''}`);
},
adjust: (venueId: string, adjustments: NewStockAdjustment[]) =>
api<{ adjustments: StockAdjustment[] }>(`/venues/${venueId}/stock/adjustments`, {
method: 'POST',
body: JSON.stringify({ adjustments }),
}),
products: (venueId: string) => api<Product[]>(`${venue(venueId)}/stock/products`),
movements: (venueId: string, params: { productId?: string; limit?: number; offset?: number } = {}) =>
api<{ movements: StockMovement[]; total: number }>(`${venue(venueId)}/stock/movements${query(params)}`),
addMovements: (venueId: string, movements: NewStockMovement[]) =>
post<{ movements: StockMovement[] }>(`${venue(venueId)}/stock/movements`, { movements }),
/** [stock] is the starting count; null: not tracked */
createProduct: (venueId: string, product: ProductFields & { stock: number | null }) =>
api<Product>(`/venues/${venueId}/stock/products`, {
method: 'POST',
body: JSON.stringify(product),
}),
createProduct: (venueId: string, product: ProductFields & { stock: number | null }) => post<Product>(`${venue(venueId)}/stock/products`, product),
/** Only the fields that are sent change; archived: true deletes (hides) the product */
updateProduct: (venueId: string, productId: string, changes: Partial<ProductFields> & { archived?: boolean }) =>
patch<Product>(`${venue(venueId)}/stock/products/${productId}`, changes),
/** Cropped to a square and stored on the server; the terminals download it on their next sync */
uploadImage: (venueId: string, productId: string, file: Blob) => {
const body = new FormData();
body.append('file', file);
return api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}/image`, { method: 'POST', body });
return api<Product>(`${venue(venueId)}/stock/products/${productId}/image`, { method: 'POST', body });
},
removeImage: (venueId: string, productId: string) =>
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}/image`, { method: 'DELETE' }),
/** Only the fields that are sent change; archived: true deletes (hides) the product */
updateProduct: (venueId: string, productId: string, changes: Partial<ProductFields> & { archived?: boolean }) =>
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, {
method: 'PATCH',
body: JSON.stringify(changes),
}),
removeImage: (venueId: string, productId: string) => del<Product>(`${venue(venueId)}/stock/products/${productId}/image`),
};
// 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) });
// Superadmin: every venue, terminal and user. Destructive calls take dryRun to preview what they would change
export const adminApi = {
venues: () => api<{ venues: AdminVenue[] }>('/admin/venues'),
venues: () => api<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 }) }),
renameVenue: (venueId: string, name: string) => patch(`/admin/venues/${venueId}`, { name }),
/** [confirmName] must be the venue's name */
deleteVenue: (venueId: string, confirmName: string) =>
api(`/admin/venues/${venueId}?confirmName=${encodeURIComponent(confirmName)}`, { method: 'DELETE' }),
deleteVenue: (venueId: string, confirmName: string) => del(`/admin/venues/${venueId}${query({ confirmName })}`),
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 }),
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`),
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`),
createRegistrationCode: (venueId: string) => post<RegistrationCode>(`/admin/venues/${venueId}/registration-codes`),
devices: () => api<{ devices: AdminDevice[] }>('/admin/devices'),
devices: () => api<AdminDevice[]>('/admin/devices'),
revokeDevice: (deviceId: string) => post(`/admin/devices/${deviceId}/revoke`),
registrationCodes: () => api<{ codes: RegistrationCode[]; stale: number }>('/admin/registration-codes'),
registrationCodes: () => api<{ codes: AdminRegistrationCode[]; 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 }) }),
users: () => api<AdminUser[]>('/admin/users'),
/** [venueId]: the venue a superadmin joins as an admin or manager */
updateRole: (userId: string, role: Role, venueId?: string) => patch(`/admin/users/${userId}/role`, { role, venueId }),
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' }),
deleteUser: (userId: string) => del(`/admin/users/${userId}`),
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 }),
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}` : ''}`);
},
audit: (params: { venueId?: string; action?: string; limit?: number; offset?: number }) =>
api<{ entries: AuditEntry[]; total: number }>(`/admin/audit${query(params)}`),
health: () => api<SystemHealth>('/admin/health'),
};
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
return getCookie('access_token');
}
+4 -4
View File
@@ -11,7 +11,7 @@ export interface ColumnMapping {
}
export interface ImportEntry {
customerName: string;
name: string;
email?: string;
}
@@ -88,8 +88,8 @@ export function buildPreview(sheet: SheetData, mapping: ColumnMapping): ImportPr
for (const row of dataRows) {
const parts = [row[mapping.nameColumn]];
if (mapping.lastNameColumn !== null) parts.push(row[mapping.lastNameColumn]);
const customerName = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 100);
if (!customerName) {
const name = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 100);
if (!name) {
skippedRows++;
continue;
}
@@ -104,7 +104,7 @@ export function buildPreview(sheet: SheetData, mapping: ColumnMapping): ImportPr
}
}
entries.push(email ? { customerName, email } : { customerName });
entries.push(email ? { name, email } : { name });
}
return { entries, skippedRows, invalidEmails };
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -3,33 +3,35 @@
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 { API_URL, refreshSession, type RealtimeTopic } 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[][]> = {
const TOPIC_QUERIES: Record<RealtimeTopic, string[][]> = {
// A settlement changes the tabs, so it comes with this topic
tabs: [['tabs'], ['reminder-candidates'], ['tab-history']],
commands: [['commands']],
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
device: [['device']],
tabs: [['tabs'], ['reminder-candidates'], ['settlements']],
customers: [['customers'], ['reminder-candidates']],
devices: [['devices']],
reminders: [['reminder-candidates'], ['reminder-history']],
users: [['users']],
stock: [['stock'], ['stock-history']],
stock: [['products'], ['stock-movements']],
};
const RealtimeContext = createContext({ connected: false });
export function RealtimeProvider({ children }: { children: ReactNode }) {
/** [enabled]: false without a venue to follow (a superadmin not viewing one). */
export function RealtimeProvider({ enabled = true, children }: { enabled?: boolean; children: ReactNode }) {
const queryClient = useQueryClient();
const { venueId } = useSession();
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!enabled) return;
// Authenticated with the session cookie. The venue is only used for a superadmin viewing another venue; everyone
// else follows their own
const socket = io(`${new URL(API_URL).origin}/realtime`, {
// 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 }),
withCredentials: true,
auth: { venueId },
transports: ['websocket', 'polling'],
});
@@ -47,28 +49,26 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
socket.on('disconnect', () => setConnected(false));
socket.on('invalidate', ({ topics }: { topics: string[] }) => {
socket.on('invalidate', ({ topics }: { topics: RealtimeTopic[] }) => {
for (const topic of topics) {
for (const queryKey of TOPIC_QUERIES[topic] ?? []) {
queryClient.invalidateQueries({ queryKey });
}
for (const queryKey of TOPIC_QUERIES[topic] ?? []) queryClient.invalidateQueries({ queryKey });
}
});
// The access token expired: refresh it and reconnect (rejected handshakes are not retried
// automatically). If refreshing fails, the next API call sends the user to the login page.
// The access token expired: refresh it and reconnect (rejected handshakes are not retried automatically). If
// refreshing fails, the next API call sends the user to the login page
socket.on('connect_error', async (error) => {
if (error.message !== 'unauthorized' || refreshing || ++rejections > 3) return;
refreshing = true;
const token = await refreshAccessToken();
const me = await refreshSession();
refreshing = false;
if (token) socket.connect();
if (me) socket.connect();
});
return () => {
socket.disconnect();
};
}, [queryClient, venueId]);
}, [enabled, queryClient, venueId]);
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
}
+27 -32
View File
@@ -1,17 +1,20 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
import { getCookie, type Role } from '@/lib/api/client';
import type { Me, Role } from '@/lib/api/client';
export interface Session {
userId: string;
email: string;
name: string;
role: Role;
/** The venue the dashboard shows: the user's own, or the one a superadmin is viewing */
/**
* The venue the dashboard shows: the user's own, or the one a superadmin is viewing. Empty for a superadmin who isn't
* viewing one: they only reach the admin page and their account then
*/
venueId: string;
/** The venue the user belongs to */
homeVenueId: string;
/** Set while a superadmin views another venue */
venueName: string;
/** Set while a superadmin views a venue, as its admin */
viewing: ViewedVenue | null;
}
@@ -19,31 +22,23 @@ 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');
if (!token) return null;
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
if (!payload.venueId || !payload.role) return null;
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
const role = payload.role === 'viewer' ? 'manager' : payload.role;
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;
}
/** The session for the signed-in user: a superadmin belongs to no venue and may be viewing one. */
export function sessionFor(me: Me): Session {
const viewing = me.role === 'superadmin' ? readViewing() : null;
return {
userId: me.id,
email: me.email,
name: me.name,
role: me.role,
venueId: viewing?.id ?? me.venueId ?? '',
venueName: viewing?.name ?? me.venueName ?? '',
viewing,
};
}
/** Whether the session shows a venue; a superadmin only does while viewing one. */
export const hasVenue = (session: Session) => session.venueId !== '';
function readViewing(): ViewedVenue | null {
try {
const stored = JSON.parse(localStorage.getItem(VIEWING_KEY) ?? 'null');
@@ -54,15 +49,15 @@ function readViewing(): ViewedVenue | 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.
* Shows the dashboard as [venue], or stops viewing with null (back to the admin page). Reloads, so every page and
* query starts over.
*/
export function viewVenue(venue: ViewedVenue | null, path = '/dashboard') {
export function viewVenue(venue: ViewedVenue | null, path = venue ? '/dashboard' : '/dashboard/admin') {
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
// Without storage a superadmin can't view a venue
}
window.location.href = path;
}
+548
View File
@@ -0,0 +1,548 @@
{
"nav.overview": "Overview",
"nav.tabs": "Open Tabs",
"nav.reminders": "Reminders",
"nav.users": "Users",
"nav.settings": "Settings",
"nav.account": "My Account",
"nav.logout": "Logout",
"common.refresh": "Refresh",
"common.loading": "Loading...",
"common.error": "Something went wrong",
"common.unknown_error": "An error occurred",
"common.failed": "Failed",
"common.cancel": "Cancel",
"common.close": "Close",
"common.copied": "Copied",
"common.logged_out": "Logged out",
"errors.invalid_credentials": "Invalid email or password",
"errors.too_many_requests": "Too many attempts. Wait a minute and try again.",
"errors.validation_error": "Some of the input is not valid",
"errors.version_mismatch": "The tab changed since it was loaded. Check it and try again.",
"errors.tab_not_found": "This tab is no longer open",
"errors.tab_empty": "There is nothing on this tab to settle",
"errors.device_revoked": "The terminal for this tab has been unpaired",
"errors.name_exists": "This customer already exists",
"errors.email_exists": "A user with this email already exists",
"errors.resend_not_configured": "Sending email is not set up on the server",
"errors.reminders_in_progress": "Reminders are already being sent",
"errors.no_email": "This customer has no email address",
"errors.forbidden": "You don't have permission to do this",
"errors.wrong_password": "The current password is not correct",
"errors.cannot_delete_self": "You cannot delete your own account",
"errors.user_not_found": "This user no longer exists",
"errors.venue_forbidden": "You don't have permission to do this",
"errors.internal_error": "Something went wrong on the server",
"errors.network_error": "Can't reach the server. Check the connection.",
"errors.session_expired": "Your session has expired. Log in again.",
"errors.unknown": "An error occurred",
"status_page.home": "Back to KoolTap",
"not_found.title": "Page not found",
"not_found.desc": "This page doesn't exist or has been moved.",
"error_page.title": "Something went wrong",
"error_page.desc": "This page could not be shown. Try again, or go back to the start.",
"error_page.retry": "Try again",
"time.just_now": "just now",
"orders.today": "{time} today",
"orders.yesterday": "{time} yesterday",
"time.minutes_ago": "{count}m ago",
"time.hours_ago": "{count}h ago",
"time.days_ago": "{count}d ago",
"device_status.online": "Online",
"device_status.recently_seen": "Recently seen",
"device_status.stale": "Stale",
"device_status.offline": "Offline",
"device_status.revoked": "Revoked",
"payment.cash": "Cash",
"payment.payconiq": "Payconiq",
"payment.other": "Other",
"tabs.title": "Open Tabs",
"tabs.count": "{count} open tab",
"tabs.count_plural": "{count} open tabs",
"tabs.empty": "No open tabs",
"tabs.empty_desc": "Open tabs appear here when the terminal syncs.",
"tabs.load_failed": "Failed to load tabs",
"tabs.customer": "Customer",
"tabs.email": "Email",
"tabs.amount": "Amount",
"tabs.items": "Items",
"tabs.search_placeholder": "Search customers…",
"tabs.outstanding": "{amount} outstanding",
"tabs.no_matches": "No tabs match \"{query}\"",
"tabs.reported": "Last updated",
"tabs.actions": "Actions",
"tabs.close": "Close",
"tabs.show_items": "Show items for {name}",
"tabs.hide_items": "Hide items for {name}",
"tabs.lines_unknown": "Items aren't reported by this terminal yet. Update the app on the tablet to see them.",
"tabs.lines_empty": "No items on this tab.",
"tabs.each": "each",
"tabs.close_title": "Close {name}'s tab?",
"tabs.close_desc": "Settles everything on the tab now. The terminals show it after their next sync.",
"tabs.payment": "Payment method",
"tabs.select_payment": "Please select a payment method",
"tabs.send": "Settle tab",
"tabs.sending": "Settling…",
"tabs.close_label": "Close {name}'s tab",
"tabs.delete": "Delete",
"tabs.delete_label": "Delete {name}'s tab",
"tabs.delete_title": "Delete {name}'s tab?",
"tabs.delete_desc": "The tab is removed from every terminal on its next sync.",
"tabs.delete_desc_unpaid": "There is still {amount} on this tab. It is removed without being paid, from every terminal on its next sync. To record a payment, close the tab instead.",
"tabs.deleted": "{name}'s tab is deleted",
"tabs.settled": "Tab settled",
"tabs.settled_desc": "{name}: {amount}",
"nav.history": "Tab History",
"history.title": "Tab History",
"history.count": "{count} paid tab",
"history.count_plural": "{count} paid tabs",
"history.total": "{amount} paid",
"history.search_placeholder": "Search by name...",
"history.all_methods": "All",
"history.customer": "Customer",
"history.paid_at": "Paid",
"history.amount": "Amount",
"history.items": "Items",
"history.payment": "Payment",
"history.settled_by": "Settled by",
"history.dashboard": "Dashboard ({name})",
"history.removed": "{count} removed",
"history.show_items": "Show {name}'s items",
"history.hide_items": "Hide {name}'s items",
"history.empty": "No paid tabs yet",
"history.empty_desc": "Tabs settled on a terminal or here appear once the terminal has synced.",
"history.no_matches": "No paid tabs match your filters.",
"history.load_failed": "Failed to load the tab history",
"history.previous": "Previous",
"history.next": "Next",
"history.page": "Page {page} of {total}",
"planned.name_placeholder": "Customer name",
"planned.email_placeholder": "Email (optional)",
"planned.name": "Name",
"planned.email": "Email",
"planned.add": "Add customer",
"planned.add_desc": "The customer gets a tab on the terminal at its next sync.",
"planned.remove": "Remove {name}",
"planned.pending": "Next sync",
"planned.pending_hint": "Gets a tab on the terminal at its next sync",
"planned.pending_count": "{count} waiting for next sync",
"planned.pending_count_plural": "{count} waiting for next sync",
"planned.added": "Added",
"planned.added_desc": "\"{name}\" will get a tab on the terminal",
"import.button": "Import from Excel",
"import.title": "Import customers",
"import.description": "Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.",
"import.choose_file": "Choose a file",
"import.empty_file": "The file contains no rows.",
"import.unreadable": "This file could not be read. Use an .xlsx, .xls or .csv file.",
"import.name_column": "Name (or first name)",
"import.last_name_column": "Last name",
"import.email_column": "Email",
"import.column_none": "— none —",
"import.has_header": "First row contains column headers",
"import.summary": "{count} customers found, {emails} with an email address.",
"import.warnings": "{skipped} rows without a name skipped, {invalid} invalid email addresses ignored.",
"import.email": "Email",
"import.more": "and {count} more…",
"import.mode": "Existing customers",
"import.mode_merge": "Keep customers that are not in the file",
"import.mode_replace": "Remove customers that are not in the file (their open tabs on the terminal are kept)",
"import.submit": "Import {count} customers",
"import.importing": "Importing...",
"import.done": "Import complete",
"import.result": "{created} added, {updated} updated, {unchanged} unchanged, {removed} removed.",
"import.failed": "Import failed",
"users.title": "Users",
"users.count": "{count} user",
"users.count_plural": "{count} users",
"users.load_failed": "Failed to load users",
"users.empty": "No users",
"users.empty_desc": "Add users who can manage tabs from this dashboard.",
"users.add": "Add User",
"users.name": "Name",
"users.email": "Email",
"users.role": "Role",
"users.created": "Created",
"users.last_active": "Last active",
"users.never_active": "Never",
"users.add_title": "Add User",
"users.add_desc": "Create a new user who can access the dashboard.",
"users.name_placeholder": "Bartender name",
"users.password": "Password",
"users.password_placeholder": "At least 8 characters",
"users.role_manager": "Manager (runs the venue: products, stock, tabs, reminders)",
"users.role_admin": "Admin (also users and terminals)",
"users.role.manager": "Manager",
"users.role.admin": "Admin",
"users.creating": "Creating...",
"users.create": "Create User",
"users.created_toast": "User created",
"users.created_toast_desc": "{email} added as {role}",
"users.create_failed": "Failed to create user",
"users.delete": "Delete",
"users.delete_label": "Delete {name}",
"users.delete_title": "Delete {name}?",
"users.delete_desc": "{email} can no longer sign in to the dashboard and is signed out within a few minutes.",
"users.deleted": "{email} is deleted",
"settings.title": "Settings",
"settings.desc": "Venue and terminal",
"settings.cash_emails": "Cash payment emails",
"settings.cash_emails_desc": "These addresses get an email every time a tab is paid in cash, on a terminal or here.",
"settings.cash_emails_empty": "Nobody gets an email yet.",
"settings.cash_emails_placeholder": "name@example.com",
"settings.cash_emails_add": "Add",
"settings.cash_emails_remove": "Remove {email}",
"settings.cash_emails_invalid": "Enter a valid email address",
"settings.cash_emails_saved": "Cash payment emails saved",
"settings.venue": "Venue",
"settings.device": "Terminal",
"settings.paired": "Paired as \"{name}\" — last seen {time}",
"settings.not_paired": "No terminal paired yet",
"settings.since": "Since {date}",
"settings.pair_desc": "Pair a bar terminal to start receiving tabs.",
"settings.pair": "Pair Terminal",
"settings.repair": "Pair Another Terminal",
"settings.unpair": "Unpair",
"settings.unpair_label": "Unpair {name}",
"settings.unpair_title": "Unpair {name}?",
"settings.unpair_desc": "The terminal stops syncing and has to be paired again to reconnect. Its changes that have not been synced yet are lost.",
"settings.unpaired": "{name} is unpaired",
"settings.terminals": "{count} terminals paired",
"settings.generating": "Generating...",
"settings.pair_title": "Pair a Terminal",
"settings.pair_instructions": "Enter this code on the bar terminal to connect it to your venue.",
"settings.pair_code": "Registration Code",
"settings.pair_expires": "Expires at {time}",
"settings.pair_steps": "Instructions:",
"settings.pair_step1": "Open the bar terminal app",
"settings.pair_step2": "Select your venue from the list",
"settings.pair_step3": "Enter this registration code",
"settings.pair_step4": "Give the terminal a name",
"settings.pair_step5": "Complete registration",
"settings.copy": "Copy Code",
"settings.copied_desc": "Registration code copied to clipboard",
"account.title": "My Account",
"account.change_password": "Change Password",
"account.change_password_desc": "Your other sessions are signed out; this one stays signed in.",
"account.current_password": "Current password",
"account.new_password": "New password",
"account.confirm_password": "Repeat new password",
"account.too_short": "Use at least 8 characters",
"account.mismatch": "The passwords don't match",
"account.save": "Change Password",
"account.saving": "Saving...",
"account.password_changed": "Password changed",
"account.password_changed_desc": "Use your new password the next time you sign in.",
"account.password_failed": "Failed to change password",
"overview.title": "Overview",
"overview.open_tabs": "Open Tabs",
"overview.device": "Terminal",
"overview.outstanding": "Outstanding",
"overview.recent": "Recent payments",
"overview.not_connected": "Not connected",
"overview.last_seen": "Last seen {time}",
"overview.terminals": "{count} terminals, last seen {time}",
"auth.login": "Sign in",
"auth.logging_in": "Signing in...",
"auth.email": "Email",
"auth.password": "Password",
"auth.invalid": "Invalid email or password",
"lang.en": "English",
"lang.nl": "Nederlands",
"lang.label": "Language",
"reminders.title": "Payment Reminders",
"reminders.candidates_desc": "These customers have an unpaid tab and an email address on file. Reminders are sent via Resend.",
"reminders.no_candidates": "No unpaid tabs",
"reminders.no_candidates_desc": "Customers with an open balance show up here.",
"reminders.send": "Send Reminders",
"reminders.sending": "Sending...",
"reminders.admin_only": "Only admins can send reminders.",
"reminders.confirm": "This emails a payment reminder to {count} customer(s) with an unpaid tab.",
"reminders.sent_title": "Reminders Sent",
"reminders.partial": "{sent} of {total} sent",
"reminders.error_detail": "Error: {error}",
"reminders.zero_sent": "No reminders were sent. Check that Resend is configured.",
"reminders.success": "Successfully sent {sent} reminder email(s)",
"reminders.history": "Sent Reminders",
"reminders.history_empty": "No reminders have been sent yet",
"reminders.customer": "Customer",
"reminders.email": "Email",
"reminders.amount": "Amount",
"reminders.sent_at": "Sent At",
"reminders.status": "Status",
"reminders.customers": "Customers with an unpaid tab",
"reminders.search": "Search name or email",
"reminders.select_eligible": "Select eligible",
"reminders.select_all": "Select all",
"reminders.select_none": "Clear",
"reminders.count": "Reminders",
"reminders.last_sent": "Last reminder",
"reminders.never": "Never",
"reminders.state_eligible": "Eligible",
"reminders.state_no_email": "No email",
"reminders.state_cooldown": "Eligible in {count} day",
"reminders.state_cooldown_plural": "Eligible in {count} days",
"reminders.view_history": "View reminders sent to this customer",
"reminders.no_matches": "No customers match your search",
"reminders.selected": "{count} customer selected · {amount} outstanding",
"reminders.selected_plural": "{count} customers selected · {amount} outstanding",
"reminders.confirm_cooldown": "{count} of them was already reminded in the last {days} days.",
"reminders.confirm_cooldown_plural": "{count} of them were already reminded in the last {days} days.",
"reminders.recent": "Recently reminded",
"reminders.person_history": "Reminders for {name}",
"reminders.person_history_desc": "{count} reminder sent",
"reminders.person_history_desc_plural": "{count} reminders sent",
"nav.stock": "Products",
"errors.product_not_found": "This product no longer exists",
"errors.stock_not_tracked": "Stock isn't tracked for this product yet. Count it first.",
"errors.invalid_quantity": "Enter a valid quantity",
"stock.title": "Products & stock",
"stock.count": "{count} product",
"stock.count_plural": "{count} products",
"stock.low_count": "{count} running low",
"stock.low_count_plural": "{count} running low",
"stock.load_failed": "Failed to load stock",
"stock.empty": "No products yet",
"stock.empty_desc": "Add the products you sell. The terminals get them on their next sync.",
"stock.search_placeholder": "Search products",
"stock.filter_all": "All",
"stock.filter_low": "Running low",
"stock.filter_untracked": "Not tracked",
"stock.filter_archived": "Archived",
"stock.archived": "Archived",
"products.new": "New product",
"products.edit_title": "Edit {name}",
"products.name": "Name",
"products.choose_picture": "Choose picture",
"products.change_picture": "Change picture",
"products.remove_picture": "Remove",
"products.duplicate": "Duplicate",
"products.duplicate_desc": "Another product has the same name, for example created on two terminals at once. Archive the one you do not need.",
"products.category": "Category",
"products.category_placeholder": "e.g. Frisdrank",
"products.price": "Sell price (€)",
"products.buy_price": "Buy price (€)",
"products.track_stock": "Track stock",
"products.starting_stock": "In stock now",
"products.save": "Save",
"products.saving": "Saving…",
"products.created": "{name} added",
"products.saved": "{name} saved",
"products.sync_desc": "The terminals show it after their next sync.",
"products.archive": "Archive",
"products.archive_desc": "An archived product disappears from the terminals. Open tabs and the history keep it.",
"products.archived": "{name} archived",
"products.restore": "Restore",
"products.restore_desc": "This product is archived. Restoring it puts it back on the terminals.",
"products.restored": "{name} is back",
"errors.product_name_exists": "A product with this name already exists",
"stock.no_matches": "No products match",
"stock.no_category": "Other",
"stock.product": "Product",
"stock.price": "Sell price",
"stock.buy_price": "Buy price",
"stock.stock": "Stock",
"stock.counted_at": "Last counted",
"stock.never_counted": "Never",
"stock.actions": "Actions",
"stock.not_tracked": "Not tracked",
"stock.low": "Low",
"stock.out": "Out",
"stock.adjust": "Adjust",
"stock.start_tracking": "Track stock",
"stock.start_tracking_desc": "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.",
"stock.current": "Currently {stock} in stock.",
"stock.mode_add": "Add (delivery)",
"stock.mode_remove": "Take out (breakage, own use…)",
"stock.mode_count": "Count (set the stock to what you counted)",
"stock.quantity": "Quantity",
"stock.counted": "Counted",
"stock.preview": "{from} → {to}",
"stock.count_hint": "Drinks rung up before your count are in it: they are not subtracted again, also not when an offline terminal sends them later.",
"stock.note": "Note (optional)",
"stock.note_placeholder": "e.g. delivery from the brewery",
"stock.threshold": "Warn when stock is at or below",
"stock.threshold_none": "No warning",
"stock.desired": "Desired stock",
"stock.desired_none": "Not set",
"stock.to_order": "To order",
"stock.to_order_desc": "Desired stock minus the current stock",
"stock.send": "Save",
"stock.sending": "Sending…",
"stock.sent": "Stock updated",
"stock.sent_desc": "The terminals show it after their next sync.",
"stock.delivery": "Register delivery",
"stock.delivery_title": "Register delivery",
"stock.delivery_desc": "Enter how many of each product were delivered. Products you leave empty are not changed.",
"stock.delivery_note_placeholder": "e.g. supplier or delivery note number",
"stock.delivery_send": "Add {count} product",
"stock.delivery_send_plural": "Add {count} products",
"stock.history": "Recent stock changes",
"stock.history_empty": "No stock changes from the dashboard yet",
"stock.now": "→ {stock}",
"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.customers": "Planned customers",
"admin.reset.scope.customers_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.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.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.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.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.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",
"overview.customers": "Customers",
"overview.no_payments": "No paid tabs yet",
"admin.health.images": "Pictures on disk",
"errors.customer_not_found": "This customer no longer exists",
"admin.users.pick_venue": "Which venue does {name} join?",
"admin.users.pick_venue_desc": "An admin or manager belongs to one venue; a superadmin is above them all.",
"admin.users.no_venue": "Above the venues",
"errors.venue_required": "Pick the venue this user joins",
"admin.users.pick_venue_placeholder": "Pick a venue"
}
+548
View File
@@ -0,0 +1,548 @@
{
"nav.overview": "Overzicht",
"nav.tabs": "Open poefs",
"nav.reminders": "Herinneringen",
"nav.users": "Gebruikers",
"nav.settings": "Instellingen",
"nav.account": "Mijn account",
"nav.logout": "Uitloggen",
"common.refresh": "Vernieuwen",
"common.loading": "Laden...",
"common.error": "Er is iets misgegaan",
"common.unknown_error": "Er is een fout opgetreden",
"common.failed": "Mislukt",
"common.cancel": "Annuleren",
"common.close": "Sluiten",
"common.copied": "Gekopieerd",
"common.logged_out": "Uitgelogd",
"errors.invalid_credentials": "Ongeldig e-mailadres of wachtwoord",
"errors.too_many_requests": "Te veel pogingen. Wacht een minuutje en probeer opnieuw.",
"errors.validation_error": "Niet alles is correct ingevuld",
"errors.version_mismatch": "De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.",
"errors.tab_not_found": "Deze poef staat niet meer open",
"errors.tab_empty": "Er staat niets op deze poef om af te rekenen",
"errors.device_revoked": "De kassa van deze poef is ontkoppeld",
"errors.name_exists": "Deze klant bestaat al",
"errors.email_exists": "Er bestaat al een gebruiker met dit e-mailadres",
"errors.resend_not_configured": "E-mail versturen is niet ingesteld op de server",
"errors.reminders_in_progress": "Herinneringen worden al verstuurd",
"errors.no_email": "Deze klant heeft geen e-mailadres",
"errors.forbidden": "Je hebt geen toestemming om dit te doen",
"errors.wrong_password": "Het huidige wachtwoord klopt niet",
"errors.cannot_delete_self": "Je kan je eigen account niet verwijderen",
"errors.user_not_found": "Deze gebruiker bestaat niet meer",
"errors.venue_forbidden": "Je hebt geen toestemming om dit te doen",
"errors.internal_error": "Er ging iets mis op de server",
"errors.network_error": "Kan de server niet bereiken. Controleer de verbinding.",
"errors.session_expired": "Je sessie is verlopen. Log opnieuw in.",
"errors.unknown": "Er is een fout opgetreden",
"status_page.home": "Terug naar KoolTap",
"not_found.title": "Pagina niet gevonden",
"not_found.desc": "Deze pagina bestaat niet of is verplaatst.",
"error_page.title": "Er is iets misgegaan",
"error_page.desc": "Deze pagina kon niet getoond worden. Probeer het opnieuw of ga terug naar het begin.",
"error_page.retry": "Opnieuw proberen",
"time.just_now": "zonet",
"orders.today": "{time} vandaag",
"orders.yesterday": "{time} gisteren",
"time.minutes_ago": "{count} min geleden",
"time.hours_ago": "{count} u geleden",
"time.days_ago": "{count} d geleden",
"device_status.online": "Online",
"device_status.recently_seen": "Recent gezien",
"device_status.stale": "Inactief",
"device_status.offline": "Offline",
"device_status.revoked": "Ingetrokken",
"payment.cash": "Contant",
"payment.payconiq": "Payconiq",
"payment.other": "Andere",
"tabs.title": "Open poefs",
"tabs.count": "{count} open poef",
"tabs.count_plural": "{count} open poefs",
"tabs.empty": "Geen open poefs",
"tabs.empty_desc": "Open poefs verschijnen hier zodra de terminal synchroniseert.",
"tabs.load_failed": "Poefs konden niet geladen worden",
"tabs.customer": "Klant",
"tabs.email": "E-mail",
"tabs.amount": "Bedrag",
"tabs.items": "Items",
"tabs.search_placeholder": "Zoek klant…",
"tabs.outstanding": "{amount} openstaand",
"tabs.no_matches": "Geen poefs gevonden voor \"{query}\"",
"tabs.reported": "Laatst bijgewerkt",
"tabs.actions": "Acties",
"tabs.close": "Sluiten",
"tabs.show_items": "Items van {name} tonen",
"tabs.hide_items": "Items van {name} verbergen",
"tabs.lines_unknown": "Deze terminal stuurt de items nog niet door. Werk de app op de tablet bij om ze te zien.",
"tabs.lines_empty": "Geen items op deze poef.",
"tabs.each": "per stuk",
"tabs.close_title": "Poef van {name} sluiten?",
"tabs.close_desc": "Rekent alles op de poef nu af. De terminals tonen het na hun volgende synchronisatie.",
"tabs.payment": "Betaalmethode",
"tabs.select_payment": "Kies een betaalmethode",
"tabs.send": "Poef afrekenen",
"tabs.sending": "Bezig met afrekenen…",
"tabs.close_label": "Poef van {name} sluiten",
"tabs.delete": "Verwijderen",
"tabs.delete_label": "Poef van {name} verwijderen",
"tabs.delete_title": "Poef van {name} verwijderen?",
"tabs.delete_desc": "De poef verdwijnt van elke terminal bij de volgende synchronisatie.",
"tabs.delete_desc_unpaid": "Er staat nog {amount} op deze poef. Ze wordt verwijderd zonder betaling, van elke terminal bij de volgende synchronisatie. Wil je een betaling registreren, sluit de poef dan.",
"tabs.deleted": "Poef van {name} is verwijderd",
"tabs.settled": "Poef afgerekend",
"tabs.settled_desc": "{name}: {amount}",
"nav.history": "Poefgeschiedenis",
"history.title": "Poefgeschiedenis",
"history.count": "{count} betaalde poef",
"history.count_plural": "{count} betaalde poefs",
"history.total": "{amount} betaald",
"history.search_placeholder": "Zoek op naam...",
"history.all_methods": "Alle",
"history.customer": "Klant",
"history.paid_at": "Betaald",
"history.amount": "Bedrag",
"history.items": "Items",
"history.payment": "Betaling",
"history.settled_by": "Afgerekend door",
"history.dashboard": "Dashboard ({name})",
"history.removed": "{count} verwijderd",
"history.show_items": "Toon de items van {name}",
"history.hide_items": "Verberg de items van {name}",
"history.empty": "Nog geen betaalde poefs",
"history.empty_desc": "Poefs die op een kassa of hier afgerekend zijn, verschijnen zodra de kassa gesynchroniseerd is.",
"history.no_matches": "Geen betaalde poefs voor deze filters.",
"history.load_failed": "Kon de poefgeschiedenis niet laden",
"history.previous": "Vorige",
"history.next": "Volgende",
"history.page": "Pagina {page} van {total}",
"planned.name_placeholder": "Naam van de klant",
"planned.email_placeholder": "E-mail (optioneel)",
"planned.name": "Naam",
"planned.email": "E-mail",
"planned.add": "Klant toevoegen",
"planned.add_desc": "De klant krijgt een poef op de terminal bij de volgende synchronisatie.",
"planned.remove": "{name} verwijderen",
"planned.pending": "Volgende sync",
"planned.pending_hint": "Krijgt een poef op de terminal bij de volgende synchronisatie",
"planned.pending_count": "{count} wacht op volgende sync",
"planned.pending_count_plural": "{count} wachten op volgende sync",
"planned.added": "Toegevoegd",
"planned.added_desc": "\"{name}\" krijgt een poef op de terminal",
"import.button": "Importeren uit Excel",
"import.title": "Klanten importeren",
"import.description": "Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een poef op de terminal.",
"import.choose_file": "Kies een bestand",
"import.empty_file": "Het bestand bevat geen rijen.",
"import.unreadable": "Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.",
"import.name_column": "Naam (of voornaam)",
"import.last_name_column": "Achternaam",
"import.email_column": "E-mail",
"import.column_none": "— geen —",
"import.has_header": "De eerste rij bevat kolomnamen",
"import.summary": "{count} klanten gevonden, {emails} met een e-mailadres.",
"import.warnings": "{skipped} rijen zonder naam overgeslagen, {invalid} ongeldige e-mailadressen genegeerd.",
"import.email": "E-mail",
"import.more": "en nog {count}…",
"import.mode": "Bestaande klanten",
"import.mode_merge": "Klanten die niet in het bestand staan behouden",
"import.mode_replace": "Klanten die niet in het bestand staan verwijderen (hun open poefs op de terminal blijven bestaan)",
"import.submit": "{count} klanten importeren",
"import.importing": "Bezig met importeren...",
"import.done": "Import voltooid",
"import.result": "{created} toegevoegd, {updated} bijgewerkt, {unchanged} ongewijzigd, {removed} verwijderd.",
"import.failed": "Importeren mislukt",
"users.title": "Gebruikers",
"users.count": "{count} gebruiker",
"users.count_plural": "{count} gebruikers",
"users.load_failed": "Gebruikers konden niet geladen worden",
"users.empty": "Geen gebruikers",
"users.empty_desc": "Voeg gebruikers toe die poefs kunnen beheren via dit dashboard.",
"users.add": "Gebruiker toevoegen",
"users.name": "Naam",
"users.email": "E-mail",
"users.role": "Rol",
"users.created": "Aangemaakt",
"users.last_active": "Laatst actief",
"users.never_active": "Nooit",
"users.add_title": "Gebruiker toevoegen",
"users.add_desc": "Maak een nieuwe gebruiker aan die toegang krijgt tot het dashboard.",
"users.name_placeholder": "Naam van de medewerker",
"users.password": "Wachtwoord",
"users.password_placeholder": "Minstens 8 tekens",
"users.role_manager": "Beheerder (runt de zaak: producten, voorraad, poefs, herinneringen)",
"users.role_admin": "Admin (ook gebruikers en kassa's)",
"users.role.manager": "Beheerder",
"users.role.admin": "Admin",
"users.creating": "Bezig met aanmaken...",
"users.create": "Gebruiker aanmaken",
"users.created_toast": "Gebruiker aangemaakt",
"users.created_toast_desc": "{email} toegevoegd als {role}",
"users.create_failed": "Gebruiker aanmaken mislukt",
"users.delete": "Verwijderen",
"users.delete_label": "{name} verwijderen",
"users.delete_title": "{name} verwijderen?",
"users.delete_desc": "{email} kan niet meer inloggen op het dashboard en wordt binnen enkele minuten uitgelogd.",
"users.deleted": "{email} is verwijderd",
"settings.title": "Instellingen",
"settings.desc": "Zaak en terminal",
"settings.cash_emails": "E-mails bij cashbetaling",
"settings.cash_emails_desc": "Deze adressen krijgen een e-mail telkens een poef cash betaald wordt, op een kassa of hier.",
"settings.cash_emails_empty": "Nog niemand krijgt een e-mail.",
"settings.cash_emails_placeholder": "naam@voorbeeld.be",
"settings.cash_emails_add": "Toevoegen",
"settings.cash_emails_remove": "{email} verwijderen",
"settings.cash_emails_invalid": "Geef een geldig e-mailadres in",
"settings.cash_emails_saved": "E-mails bij cashbetaling opgeslagen",
"settings.venue": "Zaak",
"settings.device": "Terminal",
"settings.paired": "Gekoppeld als \"{name}\" — laatst gezien {time}",
"settings.not_paired": "Nog geen terminal gekoppeld",
"settings.since": "Sinds {date}",
"settings.pair_desc": "Koppel een barterminal om poefs te ontvangen.",
"settings.pair": "Terminal koppelen",
"settings.repair": "Nog een terminal koppelen",
"settings.unpair": "Ontkoppelen",
"settings.unpair_label": "{name} ontkoppelen",
"settings.unpair_title": "{name} ontkoppelen?",
"settings.unpair_desc": "De terminal synchroniseert niet meer en moet opnieuw gekoppeld worden. Wijzigingen die nog niet gesynchroniseerd zijn, gaan verloren.",
"settings.unpaired": "{name} is ontkoppeld",
"settings.terminals": "{count} terminals gekoppeld",
"settings.generating": "Code aanmaken...",
"settings.pair_title": "Terminal koppelen",
"settings.pair_instructions": "Voer deze code in op de barterminal om hem met je zaak te verbinden.",
"settings.pair_code": "Registratiecode",
"settings.pair_expires": "Verloopt om {time}",
"settings.pair_steps": "Stappen:",
"settings.pair_step1": "Open de app op de barterminal",
"settings.pair_step2": "Kies je zaak uit de lijst",
"settings.pair_step3": "Voer deze registratiecode in",
"settings.pair_step4": "Geef de terminal een naam",
"settings.pair_step5": "Rond de registratie af",
"settings.copy": "Code kopiëren",
"settings.copied_desc": "Registratiecode gekopieerd naar het klembord",
"account.title": "Mijn account",
"account.change_password": "Wachtwoord wijzigen",
"account.change_password_desc": "Je andere sessies worden uitgelogd; deze blijft ingelogd.",
"account.current_password": "Huidig wachtwoord",
"account.new_password": "Nieuw wachtwoord",
"account.confirm_password": "Herhaal nieuw wachtwoord",
"account.too_short": "Gebruik minstens 8 tekens",
"account.mismatch": "De wachtwoorden komen niet overeen",
"account.save": "Wachtwoord wijzigen",
"account.saving": "Opslaan...",
"account.password_changed": "Wachtwoord gewijzigd",
"account.password_changed_desc": "Gebruik je nieuwe wachtwoord de volgende keer dat je inlogt.",
"account.password_failed": "Wachtwoord wijzigen mislukt",
"overview.title": "Overzicht",
"overview.open_tabs": "Open poefs",
"overview.device": "Terminal",
"overview.outstanding": "Openstaand",
"overview.recent": "Recente betalingen",
"overview.not_connected": "Niet verbonden",
"overview.last_seen": "Laatst gezien {time}",
"overview.terminals": "{count} terminals, laatst gezien {time}",
"auth.login": "Inloggen",
"auth.logging_in": "Bezig met inloggen...",
"auth.email": "E-mail",
"auth.password": "Wachtwoord",
"auth.invalid": "Ongeldig e-mailadres of wachtwoord",
"lang.en": "English",
"lang.nl": "Nederlands",
"lang.label": "Taal",
"reminders.title": "Betalingsherinneringen",
"reminders.candidates_desc": "Deze klanten hebben een openstaande poef en een gekend e-mailadres. Herinneringen worden verstuurd via Resend.",
"reminders.no_candidates": "Niemand heeft een openstaande poef",
"reminders.no_candidates_desc": "Klanten met een openstaand bedrag verschijnen hier.",
"reminders.send": "Herinneringen versturen",
"reminders.sending": "Bezig met versturen...",
"reminders.admin_only": "Alleen beheerders kunnen herinneringen versturen.",
"reminders.confirm": "Er wordt een betalingsherinnering gemaild naar {count} klant(en) met een openstaande poef.",
"reminders.sent_title": "Herinneringen verstuurd",
"reminders.partial": "{sent} van {total} verstuurd",
"reminders.error_detail": "Fout: {error}",
"reminders.zero_sent": "Er zijn geen herinneringen verstuurd. Controleer of Resend is ingesteld.",
"reminders.success": "{sent} herinnering(en) verstuurd",
"reminders.history": "Verstuurde herinneringen",
"reminders.history_empty": "Nog geen herinneringen verstuurd",
"reminders.customer": "Klant",
"reminders.email": "E-mail",
"reminders.amount": "Bedrag",
"reminders.sent_at": "Verstuurd op",
"reminders.status": "Status",
"reminders.customers": "Klanten met een openstaande poef",
"reminders.search": "Zoek op naam of e-mail",
"reminders.select_eligible": "Selecteer in aanmerking",
"reminders.select_all": "Alles selecteren",
"reminders.select_none": "Wissen",
"reminders.count": "Herinneringen",
"reminders.last_sent": "Laatste herinnering",
"reminders.never": "Nooit",
"reminders.state_eligible": "In aanmerking",
"reminders.state_no_email": "Geen e-mail",
"reminders.state_cooldown": "Over {count} dag",
"reminders.state_cooldown_plural": "Over {count} dagen",
"reminders.view_history": "Bekijk de herinneringen aan deze klant",
"reminders.no_matches": "Geen klanten gevonden",
"reminders.selected": "{count} klant geselecteerd · {amount} openstaand",
"reminders.selected_plural": "{count} klanten geselecteerd · {amount} openstaand",
"reminders.confirm_cooldown": "{count} ervan kreeg de laatste {days} dagen al een herinnering.",
"reminders.confirm_cooldown_plural": "{count} ervan kregen de laatste {days} dagen al een herinnering.",
"reminders.recent": "Recent herinnerd",
"reminders.person_history": "Herinneringen voor {name}",
"reminders.person_history_desc": "{count} herinnering verstuurd",
"reminders.person_history_desc_plural": "{count} herinneringen verstuurd",
"nav.stock": "Producten",
"errors.product_not_found": "Dit product bestaat niet meer",
"errors.stock_not_tracked": "Voor dit product wordt nog geen voorraad bijgehouden. Tel het eerst.",
"errors.invalid_quantity": "Vul een geldig aantal in",
"stock.title": "Producten & voorraad",
"stock.count": "{count} product",
"stock.count_plural": "{count} producten",
"stock.low_count": "{count} bijna op",
"stock.low_count_plural": "{count} bijna op",
"stock.load_failed": "Voorraad laden mislukt",
"stock.empty": "Nog geen producten",
"stock.empty_desc": "Voeg de producten toe die je verkoopt. De kassa's krijgen ze bij hun volgende synchronisatie.",
"stock.search_placeholder": "Zoek producten",
"stock.filter_all": "Alle",
"stock.filter_low": "Bijna op",
"stock.filter_untracked": "Niet bijgehouden",
"stock.filter_archived": "Gearchiveerd",
"stock.archived": "Gearchiveerd",
"products.new": "Nieuw product",
"products.edit_title": "{name} bewerken",
"products.name": "Naam",
"products.choose_picture": "Foto kiezen",
"products.change_picture": "Andere foto",
"products.remove_picture": "Verwijderen",
"products.duplicate": "Dubbel",
"products.duplicate_desc": "Een ander product heeft dezelfde naam, bijvoorbeeld tegelijk aangemaakt op twee kassa's. Archiveer het product dat je niet nodig hebt.",
"products.category": "Categorie",
"products.category_placeholder": "bv. Frisdrank",
"products.price": "Verkoopprijs (€)",
"products.buy_price": "Aankoopprijs (€)",
"products.track_stock": "Voorraad bijhouden",
"products.starting_stock": "Nu in voorraad",
"products.save": "Opslaan",
"products.saving": "Opslaan…",
"products.created": "{name} toegevoegd",
"products.saved": "{name} opgeslagen",
"products.sync_desc": "De kassa's tonen het na hun volgende synchronisatie.",
"products.archive": "Archiveren",
"products.archive_desc": "Een gearchiveerd product verdwijnt van de kassa's. Open poefs en de geschiedenis houden het.",
"products.archived": "{name} gearchiveerd",
"products.restore": "Terugzetten",
"products.restore_desc": "Dit product is gearchiveerd. Terugzetten zet het weer op de kassa's.",
"products.restored": "{name} staat terug",
"errors.product_name_exists": "Er bestaat al een product met deze naam",
"stock.no_matches": "Geen producten gevonden",
"stock.no_category": "Overige",
"stock.product": "Product",
"stock.price": "Verkoopprijs",
"stock.buy_price": "Aankoopprijs",
"stock.stock": "Voorraad",
"stock.counted_at": "Laatst geteld",
"stock.never_counted": "Nooit",
"stock.actions": "Acties",
"stock.not_tracked": "Niet bijgehouden",
"stock.low": "Bijna op",
"stock.out": "Op",
"stock.adjust": "Aanpassen",
"stock.start_tracking": "Voorraad bijhouden",
"stock.start_tracking_desc": "Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.",
"stock.current": "Nu {stock} in voorraad.",
"stock.mode_add": "Bijvullen (levering)",
"stock.mode_remove": "Afboeken (breuk, eigen gebruik…)",
"stock.mode_count": "Tellen (voorraad wordt wat je telde)",
"stock.quantity": "Aantal",
"stock.counted": "Geteld",
"stock.preview": "{from} → {to}",
"stock.count_hint": "Wat voor je telling aangeslagen werd, zit erin: het wordt niet nog eens afgetrokken, ook niet als een offline kassa het later doorstuurt.",
"stock.note": "Notitie (optioneel)",
"stock.note_placeholder": "bv. levering brouwerij",
"stock.threshold": "Waarschuw vanaf",
"stock.threshold_none": "Geen waarschuwing",
"stock.desired": "Gewenste stock",
"stock.desired_none": "Niet ingesteld",
"stock.to_order": "Bij te bestellen",
"stock.to_order_desc": "Gewenste stock min de huidige stock",
"stock.send": "Opslaan",
"stock.sending": "Versturen…",
"stock.sent": "Voorraad bijgewerkt",
"stock.sent_desc": "De kassa's tonen het na hun volgende synchronisatie.",
"stock.delivery": "Levering invoeren",
"stock.delivery_title": "Levering invoeren",
"stock.delivery_desc": "Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.",
"stock.delivery_note_placeholder": "bv. leverancier of leveringsbon",
"stock.delivery_send": "{count} product bijvullen",
"stock.delivery_send_plural": "{count} producten bijvullen",
"stock.history": "Recente voorraadwijzigingen",
"stock.history_empty": "Nog geen voorraadwijzigingen vanuit het dashboard",
"stock.now": "→ {stock}",
"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.customers": "Geplande klanten",
"admin.reset.scope.customers_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.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.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.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.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.email": "E-mail",
"admin.health.configured": "Ingesteld",
"admin.health.not_configured": "Niet ingesteld",
"admin.health.node": "Node.js",
"admin.health.tables": "Rijen per tabel",
"overview.customers": "Klanten",
"overview.no_payments": "Nog geen betaalde poefs",
"admin.health.images": "Foto’s op schijf",
"errors.customer_not_found": "Deze klant bestaat niet meer",
"admin.users.pick_venue": "Bij welke zaak hoort {name}?",
"admin.users.pick_venue_desc": "Een admin of manager hoort bij één zaak; een superadmin staat boven alle zaken.",
"admin.users.no_venue": "Boven de zaken",
"errors.venue_required": "Kies bij welke zaak deze gebruiker hoort",
"admin.users.pick_venue_placeholder": "Kies een zaak"
}
+12
View File
@@ -0,0 +1,12 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@/': fileURLToPath(new URL('./src/', import.meta.url)),
'@kooltap/shared': fileURLToPath(new URL('../../packages/shared/src/index.ts', import.meta.url)),
},
},
test: { environment: 'node', include: ['src/**/*.test.ts'] },
});