feat: make products server managed
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 23:55:40 +02:00
parent 0ca9f4d16a
commit e7f59f7583
14 changed files with 697 additions and 181 deletions
+199 -73
View File
@@ -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>
);
}
+19 -8
View File
@@ -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),
}),
};
+58 -18
View File
@@ -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',
+12
View File
@@ -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';