This commit is contained in:
@@ -16,19 +16,20 @@ 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 { centsToInput, formatCents, parseEuros } from '@/lib/utils';
|
||||
import { RefreshCw, Search, Truck, History, Plus, Pencil } from 'lucide-react';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
type Filter = 'all' | 'low' | 'untracked';
|
||||
type Filter = 'all' | 'low' | 'untracked' | 'archived';
|
||||
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' },
|
||||
{ value: 'archived', label: 'stock.filter_archived' },
|
||||
];
|
||||
|
||||
/** At or below its warning level, or out of stock. */
|
||||
@@ -59,11 +60,13 @@ export default function StockPage() {
|
||||
const { venueId, role } = useSession();
|
||||
const isAdmin = role === 'admin';
|
||||
const queryClient = useQueryClient();
|
||||
const { t, tn, te, relativeTime } = useLang();
|
||||
const { t, tn, te, relativeTime, dateTime } = useLang();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [adjusting, setAdjusting] = useState<Product | null>(null);
|
||||
const [deliveryOpen, setDeliveryOpen] = useState(false);
|
||||
// null: closed; 'new': adding a product
|
||||
const [editing, setEditing] = useState<Product | 'new' | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['stock', venueId],
|
||||
@@ -77,23 +80,16 @@ export default function StockPage() {
|
||||
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 allProducts = data?.products ?? [];
|
||||
// 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;
|
||||
const categories = [...new Set(allProducts.map((p) => p.category).filter((c): c is string => c !== null))].sort();
|
||||
const query = search.trim().toLowerCase();
|
||||
const visible = products.filter((p) => {
|
||||
const visible = (filter === 'archived' ? allProducts.filter((p) => p.archivedAt !== null) : 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;
|
||||
@@ -121,15 +117,20 @@ export default function StockPage() {
|
||||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||||
</Button>
|
||||
{products.some((p) => p.stock !== null) && (
|
||||
<Button size="sm" onClick={() => setDeliveryOpen(true)}>
|
||||
<Button size="sm" variant="outline" onClick={() => setDeliveryOpen(true)}>
|
||||
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={() => setEditing('new')}>
|
||||
<Plus className="h-4 w-4 mr-2" />{t('products.new')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{products.length === 0 ? (
|
||||
{allProducts.length === 0 ? (
|
||||
<EmptyState title={t('stock.empty')} description={t('stock.empty_desc')} />
|
||||
) : (
|
||||
<>
|
||||
@@ -159,7 +160,7 @@ export default function StockPage() {
|
||||
<TableHead>{t('stock.product')}</TableHead>
|
||||
<TableHead>{t('stock.price')}</TableHead>
|
||||
<TableHead>{t('stock.stock')}</TableHead>
|
||||
<TableHead>{t('stock.pending')}</TableHead>
|
||||
<TableHead>{t('stock.counted_at')}</TableHead>
|
||||
<TableHead className="text-right">{t('stock.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -180,31 +181,30 @@ export default function StockPage() {
|
||||
</TableRow>
|
||||
{group.products.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className="font-medium">{product.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{product.name}
|
||||
{product.archivedAt !== null && <Badge variant="secondary" className="ml-2">{t('stock.archived')}</Badge>}
|
||||
</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 className="text-muted-foreground">
|
||||
{product.stock === null
|
||||
? '—'
|
||||
: product.countedAt
|
||||
? <span title={dateTime(product.countedAt)}>{relativeTime(product.countedAt)}</span>
|
||||
: t('stock.never_counted')}
|
||||
</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 className="text-right whitespace-nowrap">
|
||||
{product.archivedAt === null && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAdjusting(product)}>
|
||||
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant="ghost" className="ml-1" onClick={() => setEditing(product)} aria-label={t('products.edit_title', { name: product.name })}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -248,7 +248,15 @@ export default function StockPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} canSetThreshold={isAdmin} onClose={() => setAdjusting(null)} />}
|
||||
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} onClose={() => setAdjusting(null)} />}
|
||||
{editing && (
|
||||
<ProductDialog
|
||||
product={editing === 'new' ? null : editing}
|
||||
categories={categories}
|
||||
venueId={venueId}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{deliveryOpen && (
|
||||
<DeliveryDialog products={products.filter((p) => p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} />
|
||||
)}
|
||||
@@ -286,14 +294,12 @@ function useAdjustMutation(venueId: string, onDone: () => void) {
|
||||
});
|
||||
}
|
||||
|
||||
function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product: Product; venueId: string; canSetThreshold: boolean; onClose: () => void }) {
|
||||
const { t, te } = useLang();
|
||||
const queryClient = useQueryClient();
|
||||
function AdjustDialog({ product, venueId, onClose }: { product: Product; venueId: string; onClose: () => void }) {
|
||||
const { t } = useLang();
|
||||
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;
|
||||
@@ -302,16 +308,6 @@ function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product:
|
||||
? 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([{
|
||||
@@ -356,23 +352,6 @@ function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product:
|
||||
<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>
|
||||
@@ -443,3 +422,150 @@ function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; v
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a product, or changes or archives one (admin). The terminals get it on their next sync; drinks already on a tab
|
||||
* keep the name and price they were rung up with.
|
||||
*/
|
||||
function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
product: Product | null;
|
||||
categories: string[];
|
||||
venueId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, te } = useLang();
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = product === null;
|
||||
const [name, setName] = useState(product?.name ?? '');
|
||||
const [category, setCategory] = useState(product?.category ?? '');
|
||||
const [price, setPrice] = useState(product ? centsToInput(product.priceInCents) : '');
|
||||
const [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? '');
|
||||
const [trackStock, setTrackStock] = useState(true);
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
const priceInCents = parseEuros(price);
|
||||
const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined;
|
||||
const stockValue = /^\d+$/.test(stock.trim()) ? parseInt(stock, 10) : null;
|
||||
const valid = name.trim() !== '' && priceInCents !== null && thresholdValue !== undefined
|
||||
&& (!isNew || !trackStock || stockValue !== null);
|
||||
|
||||
function done(title: string) {
|
||||
toast({ title, description: t('products.sync_desc'), variant: 'success' });
|
||||
queryClient.invalidateQueries({ queryKey: ['stock'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
|
||||
onClose();
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const fields = { name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, lowStockThreshold: thresholdValue! };
|
||||
return product === null
|
||||
? stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null })
|
||||
: stockApi.updateProduct(venueId, product.productId, fields);
|
||||
},
|
||||
onSuccess: (saved) => done(t(isNew ? 'products.created' : 'products.saved', { name: saved.name })),
|
||||
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: (archived: boolean) => stockApi.updateProduct(venueId, product!.productId, { 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' }),
|
||||
});
|
||||
|
||||
const busy = save.isPending || archive.isPending;
|
||||
|
||||
function submit() {
|
||||
if (valid && !busy) save.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isNew ? t('products.new') : t('products.edit_title', { name: product.name })}</DialogTitle>
|
||||
<DialogDescription>{t('products.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-name">{t('products.name')}</Label>
|
||||
<Input id="product-name" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-category">{t('products.category')}</Label>
|
||||
<Input
|
||||
id="product-category"
|
||||
list="product-categories"
|
||||
value={category}
|
||||
maxLength={100}
|
||||
placeholder={t('products.category_placeholder')}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
<datalist id="product-categories">
|
||||
{categories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-price">{t('products.price')}</Label>
|
||||
<Input
|
||||
id="product-price"
|
||||
inputMode="decimal"
|
||||
value={price}
|
||||
placeholder="2,50"
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={trackStock} onChange={(e) => setTrackStock(e.target.checked)} className="h-4 w-4" />
|
||||
{t('products.track_stock')}
|
||||
</label>
|
||||
{trackStock && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-stock">{t('products.starting_stock')}</Label>
|
||||
<Input id="product-stock" inputMode="numeric" value={stock} onChange={(e) => setStock(e.target.value)} className="max-w-[8rem]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!isNew ? product.stock !== null : trackStock) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-threshold">{t('stock.threshold')}</Label>
|
||||
<Input
|
||||
id="product-threshold"
|
||||
inputMode="numeric"
|
||||
value={threshold}
|
||||
placeholder={t('stock.threshold_none')}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
className="max-w-[8rem]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isNew && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{product.archivedAt === null ? t('products.archive_desc') : t('products.restore_desc')}
|
||||
</p>
|
||||
<Button
|
||||
variant={product.archivedAt === null ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => archive.mutate(product.archivedAt === null)}
|
||||
>
|
||||
{product.archivedAt === null ? t('products.archive') : t('products.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={!valid || busy}>{save.isPending ? t('products.saving') : t('products.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,13 +116,19 @@ export interface Product {
|
||||
name: string;
|
||||
category: string | null;
|
||||
priceInCents: number;
|
||||
/** Null when the terminal doesn't track stock for this product; can be negative */
|
||||
/** Null when stock is not tracked for this product; can be negative */
|
||||
stock: number | null;
|
||||
lowStockThreshold: 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;
|
||||
pendingAdjustments: StockAdjustment[];
|
||||
/** Archived (deleted): hidden on the terminals */
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null };
|
||||
|
||||
export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
|
||||
|
||||
export function getCookie(name: string): string | null {
|
||||
@@ -346,12 +352,17 @@ export const stockApi = {
|
||||
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 }),
|
||||
/** [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),
|
||||
}),
|
||||
/** 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),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -269,31 +269,55 @@ 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',
|
||||
'nav.stock': 'Products',
|
||||
'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.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.reported': 'reported {time}',
|
||||
'stock.reported': 'updated {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.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.desc': 'The terminals get it on their next sync. Drinks already on a tab keep their name and price.',
|
||||
'products.name': 'Name',
|
||||
'products.category': 'Category',
|
||||
'products.category_placeholder': 'e.g. Frisdrank',
|
||||
'products.price': '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': 'Price',
|
||||
'stock.stock': 'Stock',
|
||||
'stock.pending': 'Waiting for terminal',
|
||||
'stock.counted_at': 'Last counted',
|
||||
'stock.never_counted': 'Never',
|
||||
'stock.actions': 'Actions',
|
||||
'stock.not_tracked': 'Not tracked',
|
||||
'stock.low': 'Low',
|
||||
@@ -301,8 +325,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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.cancel_adjustment': 'Cancel {change} for {name}',
|
||||
'stock.cancelled': 'Change cancelled',
|
||||
'stock.current': 'Currently {stock} in stock.',
|
||||
'stock.mode_add': 'Add (delivery)',
|
||||
'stock.mode_remove': 'Take out (breakage, own use…)',
|
||||
@@ -315,8 +337,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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': 'Save',
|
||||
'stock.sending': 'Sending…',
|
||||
'stock.sent': 'Stock updated',
|
||||
@@ -602,31 +622,55 @@ 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',
|
||||
'nav.stock': 'Producten',
|
||||
'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.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.reported': 'gemeld {time}',
|
||||
'stock.reported': 'bijgewerkt {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.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.desc': "De kassa's krijgen het bij hun volgende synchronisatie. Wat al op een poef staat, houdt zijn naam en prijs.",
|
||||
'products.name': 'Naam',
|
||||
'products.category': 'Categorie',
|
||||
'products.category_placeholder': 'bv. Frisdrank',
|
||||
'products.price': 'Prijs (€)',
|
||||
'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': 'Prijs',
|
||||
'stock.stock': 'Voorraad',
|
||||
'stock.pending': 'Wacht op kassa',
|
||||
'stock.counted_at': 'Laatst geteld',
|
||||
'stock.never_counted': 'Nooit',
|
||||
'stock.actions': 'Acties',
|
||||
'stock.not_tracked': 'Niet bijgehouden',
|
||||
'stock.low': 'Bijna op',
|
||||
@@ -634,8 +678,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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.cancel_adjustment': '{change} voor {name} annuleren',
|
||||
'stock.cancelled': 'Wijziging geannuleerd',
|
||||
'stock.current': 'Nu {stock} in voorraad.',
|
||||
'stock.mode_add': 'Bijvullen (levering)',
|
||||
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
|
||||
@@ -648,8 +690,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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': 'Opslaan',
|
||||
'stock.sending': 'Versturen…',
|
||||
'stock.sent': 'Voorraad bijgewerkt',
|
||||
|
||||
@@ -6,6 +6,18 @@ export function formatCents(cents: number, currency: string = 'EUR'): string {
|
||||
}).format(euros);
|
||||
}
|
||||
|
||||
/** "2,50", "2.5" or "2" as cents; null when it isn't an amount. */
|
||||
export function parseEuros(text: string): number | null {
|
||||
const value = text.trim().replace(/^€\s*/, '');
|
||||
if (!/^\d+([.,]\d{1,2})?$/.test(value)) return null;
|
||||
return Math.round(parseFloat(value.replace(',', '.')) * 100);
|
||||
}
|
||||
|
||||
/** Cents as an amount to edit: 250 → "2,50". */
|
||||
export function centsToInput(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
// Relative times and dates are language-dependent: use relativeTime/dateTime from useLang()
|
||||
|
||||
export { cn } from './cn';
|
||||
Reference in New Issue
Block a user