Vendored
+3
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' }),
|
||||
});
|
||||
|
||||
@@ -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,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,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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+4
-1197
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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'] },
|
||||
});
|
||||
Reference in New Issue
Block a user