feat: add stuff
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 14:45:22 +02:00
parent ee06af8395
commit 1e4438ec5a
24 changed files with 1734 additions and 14 deletions
@@ -13,6 +13,7 @@ import {
Menu,
X,
Bell,
Package,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
@@ -28,6 +29,7 @@ const navItems = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, adminOnly: false },
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
@@ -0,0 +1,445 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { stockApi, type NewStockAdjustment, type Product, type StockAdjustment } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils';
import { RefreshCw, Search, Truck, X, History } from 'lucide-react';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
type Filter = 'all' | 'low' | 'untracked';
type Mode = 'add' | 'remove' | 'count';
const filters: { value: Filter; label: string }[] = [
{ value: 'all', label: 'stock.filter_all' },
{ value: 'low', label: 'stock.filter_low' },
{ value: 'untracked', label: 'stock.filter_untracked' },
];
/** At or below its warning level, or out of stock. */
function isLow(product: Product): boolean {
if (product.stock === null) return false;
return product.stock <= 0 || (product.lowStockThreshold !== null && product.stock <= product.lowStockThreshold);
}
/** "+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}`;
}
// 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();
const { venueId, role } = useSession();
const isAdmin = role === 'admin';
const queryClient = useQueryClient();
const { t, tn, te, relativeTime } = useLang();
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<Filter>('all');
const [adjusting, setAdjusting] = useState<Product | null>(null);
const [deliveryOpen, setDeliveryOpen] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ['stock', venueId],
queryFn: () => stockApi.list(venueId),
refetchInterval: live ? false : 15000,
});
const historyQuery = useQuery({
queryKey: ['stock-history', venueId],
queryFn: () => stockApi.history(venueId, { limit: 20 }),
refetchInterval: live ? false : 15000,
});
const cancelMutation = useMutation({
mutationFn: (id: string) => stockApi.cancel(venueId, id),
onSuccess: () => toast({ title: t('stock.cancelled') }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['stock'] });
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
},
});
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error, 'stock.load_failed')} />;
const products = data?.products ?? [];
const lowCount = products.filter(isLow).length;
const query = search.trim().toLowerCase();
const visible = products.filter((p) => {
if (query && !p.name.toLowerCase().includes(query) && !(p.category ?? '').toLowerCase().includes(query)) return false;
if (filter === 'low') return isLow(p);
if (filter === 'untracked') return p.stock === null;
return true;
});
// Products arrive sorted by category, then name
const groups: { category: string | null; products: Product[] }[] = [];
for (const product of visible) {
const last = groups[groups.length - 1];
if (last && last.category === product.category) last.products.push(product);
else groups.push({ category: product.category, products: [product] });
}
return (
<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}
actions={
<>
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['stock'] })}>
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
</Button>
{products.some((p) => p.stock !== null) && (
<Button size="sm" onClick={() => setDeliveryOpen(true)}>
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
</Button>
)}
</>
}
/>
{products.length === 0 ? (
<EmptyState title={t('stock.empty')} description={t('stock.empty_desc')} />
) : (
<>
<div className="flex gap-2 mb-3 flex-wrap">
<div className="relative flex-1 min-w-[12rem] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t('stock.search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label={t('stock.search_placeholder')}
/>
</div>
{filters.map((f) => (
<Button key={f.value} variant={filter === f.value ? 'default' : 'outline'} size="sm" onClick={() => setFilter(f.value)}>
{t(f.label)}
</Button>
))}
</div>
<div className="rounded-md border mb-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('stock.product')}</TableHead>
<TableHead>{t('stock.price')}</TableHead>
<TableHead>{t('stock.stock')}</TableHead>
<TableHead>{t('stock.pending')}</TableHead>
<TableHead className="text-right">{t('stock.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
{t('stock.no_matches')}
</TableCell>
</TableRow>
)}
{groups.map((group) => (
<Fragment key={group.category ?? ''}>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={5} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group.category ?? t('stock.no_category')}
</TableCell>
</TableRow>
{group.products.map((product) => (
<TableRow key={product.productId}>
<TableCell className="font-medium">{product.name}</TableCell>
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
<TableCell><StockLevel product={product} /></TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{product.pendingAdjustments.map((a) => (
<span key={a.id} className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs tabular-nums" title={a.note ?? undefined}>
{formatChange(a)}
<button
onClick={() => cancelMutation.mutate(a.id)}
disabled={cancelMutation.isPending}
className="text-muted-foreground hover:text-destructive"
aria-label={t('stock.cancel_adjustment', { change: formatChange(a), name: product.name })}
>
<X className="h-3 w-3" />
</button>
</span>
))}
{product.pendingAdjustments.length === 0 && <span className="text-muted-foreground">—</span>}
</div>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" onClick={() => setAdjusting(product)}>
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
</Button>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
</div>
</>
)}
<Card>
<CardHeader className="pb-3">
<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 ? (
<p className="text-sm text-muted-foreground">{t('stock.history_empty')}</p>
) : (
<ul className="space-y-2 text-sm">
{historyQuery.data!.adjustments.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>}
{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>
<span>{relativeTime(a.createdAt)}</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} canSetThreshold={isAdmin} onClose={() => setAdjusting(null)} />}
{deliveryOpen && (
<DeliveryDialog products={products.filter((p) => p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} />
)}
</div>
);
}
function StockLevel({ product }: { product: Product }) {
const { t } = useLang();
if (product.stock === null) return <span className="text-muted-foreground">{t('stock.not_tracked')}</span>;
return (
<div className="flex items-center gap-2">
<span className={product.stock <= 0 ? 'font-semibold text-destructive tabular-nums' : 'font-semibold tabular-nums'}>{product.stock}</span>
{product.stock <= 0
? <Badge variant="destructive">{t('stock.out')}</Badge>
: isLow(product) && <Badge variant="warning">{t('stock.low')}</Badge>}
</div>
);
}
function useAdjustMutation(venueId: string, onDone: () => void) {
const queryClient = useQueryClient();
const { t, te } = useLang();
return useMutation({
mutationFn: (adjustments: NewStockAdjustment[]) => stockApi.adjust(venueId, adjustments),
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'] });
},
});
}
function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product: Product; venueId: string; canSetThreshold: boolean; onClose: () => void }) {
const { t, te } = useLang();
const queryClient = useQueryClient();
const tracked = product.stock !== null;
const [mode, setMode] = useState<Mode>(tracked ? 'add' : 'count');
const [quantity, setQuantity] = useState('');
const [note, setNote] = useState('');
const [threshold, setThreshold] = useState(product.lowStockThreshold?.toString() ?? '');
const mutation = useAdjustMutation(venueId, onClose);
const amount = /^\d+$/.test(quantity.trim()) ? parseInt(quantity, 10) : null;
const valid = amount !== null && (mode === 'count' || amount > 0);
const preview = valid && tracked
? mode === 'count' ? amount! : product.stock! + (mode === 'add' ? amount! : -amount!)
: null;
const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined;
const thresholdMutation = useMutation({
mutationFn: () => stockApi.setLowStockThreshold(venueId, product.productId, thresholdValue!),
onSuccess: () => {
toast({ title: t('stock.threshold_saved') });
queryClient.invalidateQueries({ queryKey: ['stock'] });
},
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
});
function submit() {
if (!valid) return;
mutation.mutate([{
productId: product.productId,
type: mode === 'count' ? 'set' : 'add',
quantity: mode === 'remove' ? -amount! : amount!,
note: note.trim() || undefined,
}]);
}
return (
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{product.name}</DialogTitle>
<DialogDescription>
{tracked ? t('stock.current', { stock: product.stock! }) : t('stock.start_tracking_desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{tracked && (
<RadioGroup value={mode} onValueChange={(value) => setMode(value as Mode)}>
<div className="flex items-center space-x-2"><RadioGroupItem value="add" id="mode-add" /><Label htmlFor="mode-add">{t('stock.mode_add')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="remove" id="mode-remove" /><Label htmlFor="mode-remove">{t('stock.mode_remove')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="count" id="mode-count" /><Label htmlFor="mode-count">{t('stock.mode_count')}</Label></div>
</RadioGroup>
)}
<div className="space-y-1.5">
<Label htmlFor="quantity">{mode === 'count' ? t('stock.counted') : t('stock.quantity')}</Label>
<Input
id="quantity"
inputMode="numeric"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
autoFocus
/>
{preview !== null && <p className="text-xs text-muted-foreground">{t('stock.preview', { from: product.stock!, to: preview })}</p>}
{mode === 'count' && tracked && <p className="text-xs text-muted-foreground">{t('stock.count_hint')}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="note">{t('stock.note')}</Label>
<Input id="note" value={note} maxLength={200} placeholder={t('stock.note_placeholder')} onChange={(e) => setNote(e.target.value)} />
</div>
{/* The warning level is an admin setting; everyone can change the stock itself */}
{tracked && canSetThreshold && (
<div className="space-y-1.5 border-t pt-4">
<Label htmlFor="threshold">{t('stock.threshold')}</Label>
<div className="flex gap-2">
<Input id="threshold" inputMode="numeric" value={threshold} placeholder={t('stock.threshold_none')} onChange={(e) => setThreshold(e.target.value)} className="max-w-[8rem]" />
<Button
variant="outline"
size="sm"
disabled={thresholdValue === undefined || thresholdValue === product.lowStockThreshold || thresholdMutation.isPending}
onClick={() => thresholdMutation.mutate()}
>
{t('stock.threshold_save')}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!valid || mutation.isPending}>{mutation.isPending ? t('stock.sending') : t('stock.send')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** One delivery adds to several products at once. */
function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; venueId: string; onClose: () => void }) {
const { t, tn } = useLang();
const [quantities, setQuantities] = useState<Record<string, string>>({});
const [note, setNote] = useState('');
const [search, setSearch] = useState('');
const mutation = useAdjustMutation(venueId, onClose);
const entries = Object.entries(quantities)
.filter(([, value]) => /^\d+$/.test(value.trim()) && parseInt(value, 10) > 0)
.map(([productId, value]) => ({ productId, quantity: parseInt(value, 10) }));
const invalid = Object.values(quantities).some((value) => value.trim() !== '' && !/^\d+$/.test(value.trim()));
const query = search.trim().toLowerCase();
const visible = products.filter((p) => !query || p.name.toLowerCase().includes(query));
function submit() {
if (entries.length === 0 || invalid) return;
mutation.mutate(entries.map((e) => ({ productId: e.productId, type: 'add', quantity: e.quantity, note: note.trim() || undefined })));
}
return (
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
<DialogContent className="max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t('stock.delivery_title')}</DialogTitle>
<DialogDescription>{t('stock.delivery_desc')}</DialogDescription>
</DialogHeader>
<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">
{product.name}
<span className="ml-2 text-muted-foreground tabular-nums">({product.stock})</span>
</Label>
<Input
id={`delivery-${product.productId}`}
inputMode="numeric"
placeholder="0"
value={quantities[product.productId] ?? ''}
onChange={(e) => setQuantities((current) => ({ ...current, [product.productId]: e.target.value }))}
className="w-20 h-8 text-right"
/>
</div>
))}
</div>
<div className="space-y-1.5">
<Label htmlFor="delivery-note">{t('stock.note')}</Label>
<Input id="delivery-note" value={note} maxLength={200} placeholder={t('stock.delivery_note_placeholder')} onChange={(e) => setNote(e.target.value)} />
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={entries.length === 0 || invalid || mutation.isPending}>
{mutation.isPending ? t('stock.sending') : tn('stock.delivery_send', entries.length)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+61
View File
@@ -92,6 +92,41 @@ export interface Reminder {
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;
priceInCents: number;
/** Null when the terminal doesn't track stock for this product; can be negative */
stock: number | null;
lowStockThreshold: number | null;
reportedAt: string;
pendingAdjustments: StockAdjustment[];
}
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
@@ -294,6 +329,32 @@ export const remindersApi = {
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
// Stock API
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 }),
}),
cancel: (venueId: string, id: string) =>
api(`/venues/${venueId}/stock/adjustments/${id}/cancel`, { method: 'POST' }),
setLowStockThreshold: (venueId: string, productId: string, lowStockThreshold: number | null) =>
api(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, {
method: 'PUT',
body: JSON.stringify({ lowStockThreshold }),
}),
};
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
+134
View File
@@ -262,6 +262,73 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.person_history': 'Reminders for {name}',
'reminders.person_history_desc': '{count} reminder sent',
'reminders.person_history_desc_plural': '{count} reminders sent',
'nav.stock': 'Stock',
'errors.product_not_found': 'This product is no longer on the terminal',
'errors.stock_not_tracked': "Stock isn't tracked for this product yet. Count it first.",
'errors.invalid_quantity': 'Enter a valid quantity',
'errors.adjustment_not_pending': 'The terminal already processed this change',
'errors.adjustment_not_found': 'This stock change no longer exists',
'stock.title': 'Stock',
'stock.count': '{count} product',
'stock.count_plural': '{count} products',
'stock.low_count': '{count} running low',
'stock.low_count_plural': '{count} running low',
'stock.reported': 'reported {time}',
'stock.load_failed': 'Failed to load stock',
'stock.empty': 'No products yet',
'stock.empty_desc': 'The terminal reports its products and stock once it runs an app version with stock.',
'stock.search_placeholder': 'Search products',
'stock.filter_all': 'All',
'stock.filter_low': 'Running low',
'stock.filter_untracked': 'Not tracked',
'stock.no_matches': 'No products match',
'stock.no_category': 'Other',
'stock.product': 'Product',
'stock.price': 'Price',
'stock.stock': 'Stock',
'stock.pending': 'Waiting for terminal',
'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': "The terminal doesn't track stock for this product yet. Enter how many you have to start tracking it.",
'stock.cancel_adjustment': 'Cancel {change} for {name}',
'stock.cancelled': 'Change cancelled',
'stock.current': 'Currently {stock} in stock on the terminal.',
'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 sold between your count and the terminal receiving it are not subtracted.',
'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.threshold_save': 'Save',
'stock.threshold_saved': 'Warning level saved',
'stock.send': 'Send to terminal',
'stock.sending': 'Sending…',
'stock.sent': 'Sent to the terminal',
'stock.sent_desc': 'The stock changes on the terminal at its 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}',
'stock.status.pending': 'Waiting',
'stock.status.applied': 'Applied',
'stock.status.rejected': 'Rejected',
'stock.status.cancelled': 'Cancelled',
'stock.reason.unknown_product': 'product not on the terminal',
'stock.reason.stock_not_tracked': 'stock not tracked on the terminal',
},
nl: {
'nav.overview': 'Overzicht',
@@ -521,6 +588,73 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.person_history': 'Herinneringen voor {name}',
'reminders.person_history_desc': '{count} herinnering verstuurd',
'reminders.person_history_desc_plural': '{count} herinneringen verstuurd',
'nav.stock': 'Voorraad',
'errors.product_not_found': 'Dit product staat niet meer op de kassa',
'errors.stock_not_tracked': 'Voor dit product wordt nog geen voorraad bijgehouden. Tel het eerst.',
'errors.invalid_quantity': 'Vul een geldig aantal in',
'errors.adjustment_not_pending': 'De kassa heeft deze wijziging al verwerkt',
'errors.adjustment_not_found': 'Deze voorraadwijziging bestaat niet meer',
'stock.title': 'Voorraad',
'stock.count': '{count} product',
'stock.count_plural': '{count} producten',
'stock.low_count': '{count} bijna op',
'stock.low_count_plural': '{count} bijna op',
'stock.reported': 'gemeld {time}',
'stock.load_failed': 'Voorraad laden mislukt',
'stock.empty': 'Nog geen producten',
'stock.empty_desc': 'De kassa meldt haar producten en voorraad zodra ze een app-versie met voorraad heeft.',
'stock.search_placeholder': 'Zoek producten',
'stock.filter_all': 'Alle',
'stock.filter_low': 'Bijna op',
'stock.filter_untracked': 'Niet bijgehouden',
'stock.no_matches': 'Geen producten gevonden',
'stock.no_category': 'Overige',
'stock.product': 'Product',
'stock.price': 'Prijs',
'stock.stock': 'Voorraad',
'stock.pending': 'Wacht op kassa',
'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': 'De kassa houdt voor dit product nog geen voorraad bij. Vul in hoeveel je er hebt om te beginnen.',
'stock.cancel_adjustment': '{change} voor {name} annuleren',
'stock.cancelled': 'Wijziging geannuleerd',
'stock.current': 'Nu {stock} in voorraad op de kassa.',
'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 verkocht wordt tussen je telling en het moment dat de kassa ze ontvangt, wordt niet afgetrokken.',
'stock.note': 'Notitie (optioneel)',
'stock.note_placeholder': 'bv. levering brouwerij',
'stock.threshold': 'Waarschuw vanaf',
'stock.threshold_none': 'Geen waarschuwing',
'stock.threshold_save': 'Opslaan',
'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen',
'stock.send': 'Naar kassa sturen',
'stock.sending': 'Versturen…',
'stock.sent': 'Naar de kassa gestuurd',
'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de 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}',
'stock.status.pending': 'Wacht',
'stock.status.applied': 'Verwerkt',
'stock.status.rejected': 'Geweigerd',
'stock.status.cancelled': 'Geannuleerd',
'stock.reason.unknown_product': 'product staat niet op de kassa',
'stock.reason.stock_not_tracked': 'voorraad niet bijgehouden op de kassa',
},
};
+1
View File
@@ -13,6 +13,7 @@ const TOPIC_QUERIES: Record<string, string[][]> = {
device: [['device']],
reminders: [['reminder-candidates'], ['reminder-history']],
users: [['users']],
stock: [['stock'], ['stock-history']],
};
const RealtimeContext = createContext({ connected: false });