@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user