This commit is contained in:
@@ -28,7 +28,8 @@ export default function RemindersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, tn, te, relativeTime, dateTime } = useLang();
|
||||
const { venueId, role } = useSession();
|
||||
const isAdmin = role === 'admin';
|
||||
// Admins and managers both run the venue
|
||||
const canManage = role === 'admin' || role === 'manager';
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -205,7 +206,7 @@ export default function RemindersPage() {
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<div className="flex gap-2 sm:ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => setPicked(null)}>
|
||||
{t('reminders.select_eligible')}
|
||||
@@ -224,7 +225,7 @@ export default function RemindersPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<TableHead className="w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -250,10 +251,10 @@ export default function RemindersPage() {
|
||||
<TableRow
|
||||
key={c.tabId}
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className={isAdmin && c.email ? 'cursor-pointer' : undefined}
|
||||
onClick={isAdmin && c.email ? () => toggle(c.tabId) : undefined}
|
||||
className={canManage && c.email ? 'cursor-pointer' : undefined}
|
||||
onClick={canManage && c.email ? () => toggle(c.tabId) : undefined}
|
||||
>
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -290,7 +291,7 @@ export default function RemindersPage() {
|
||||
})}
|
||||
{visible.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isAdmin ? 7 : 6} className="text-center text-sm text-muted-foreground py-6">
|
||||
<TableCell colSpan={canManage ? 7 : 6} className="text-center text-sm text-muted-foreground py-6">
|
||||
{t('reminders.no_matches')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -299,7 +300,7 @@ export default function RemindersPage() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
{canManage ? (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tn('reminders.selected', selected.size, { amount: formatCents(selectedTotal) })}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'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 { Fragment, useEffect, useState } from 'react';
|
||||
import { imageUrl, 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';
|
||||
@@ -17,7 +17,7 @@ import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { centsToInput, formatCents, parseEuros } from '@/lib/utils';
|
||||
import { RefreshCw, Search, Truck, History, Plus, Pencil } from 'lucide-react';
|
||||
import { RefreshCw, Search, Truck, History, Plus, Pencil, ImageIcon, Trash2, Upload } from 'lucide-react';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
@@ -58,7 +58,8 @@ 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';
|
||||
// Admins and managers both run the venue
|
||||
const canManage = role === 'admin' || role === 'manager';
|
||||
const queryClient = useQueryClient();
|
||||
const { t, tn, te, relativeTime, dateTime } = useLang();
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -88,6 +89,9 @@ export default function StockPage() {
|
||||
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();
|
||||
// Two offline terminals can both create "Cola": flagged, so one can be archived
|
||||
const nameKey = (p: Product) => p.name.trim().toLowerCase();
|
||||
const duplicateNames = new Set(products.map(nameKey).filter((key, i, keys) => keys.indexOf(key) !== i));
|
||||
const query = search.trim().toLowerCase();
|
||||
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;
|
||||
@@ -121,7 +125,7 @@ export default function StockPage() {
|
||||
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<Button size="sm" onClick={() => setEditing('new')}>
|
||||
<Plus className="h-4 w-4 mr-2" />{t('products.new')}
|
||||
</Button>
|
||||
@@ -182,8 +186,14 @@ export default function StockPage() {
|
||||
{group.products.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className="font-medium">
|
||||
{product.name}
|
||||
{product.archivedAt !== null && <Badge variant="secondary" className="ml-2">{t('stock.archived')}</Badge>}
|
||||
<div className="flex items-center gap-3">
|
||||
<ProductThumbnail product={product} />
|
||||
<span>{product.name}</span>
|
||||
{product.archivedAt !== null && <Badge variant="secondary">{t('stock.archived')}</Badge>}
|
||||
{product.archivedAt === null && duplicateNames.has(nameKey(product)) && (
|
||||
<Badge variant="warning" title={t('products.duplicate_desc')}>{t('products.duplicate')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
|
||||
<TableCell><StockLevel product={product} /></TableCell>
|
||||
@@ -200,7 +210,7 @@ export default function StockPage() {
|
||||
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<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>
|
||||
@@ -423,6 +433,17 @@ function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; v
|
||||
);
|
||||
}
|
||||
|
||||
function ProductThumbnail({ product, size = 36 }: { product: Product; size?: number }) {
|
||||
return product.imageId ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imageUrl(product.imageId)} alt="" width={size} height={size} className="rounded-md bg-muted object-contain shrink-0" />
|
||||
) : (
|
||||
<div style={{ width: size, height: size }} className="rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -442,6 +463,22 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
const [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? '');
|
||||
const [trackStock, setTrackStock] = useState(true);
|
||||
const [stock, setStock] = useState('');
|
||||
// A picked picture is uploaded on save; the server crops it to a square
|
||||
const [picture, setPicture] = useState<File | null>(null);
|
||||
const [removePicture, setRemovePicture] = useState(false);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!picture) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(picture);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [picture]);
|
||||
|
||||
const shownPicture = preview ?? (!removePicture && product?.imageId ? imageUrl(product.imageId) : null);
|
||||
|
||||
const priceInCents = parseEuros(price);
|
||||
const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined;
|
||||
@@ -457,11 +494,14 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: async () => {
|
||||
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);
|
||||
const saved = product === null
|
||||
? await stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null })
|
||||
: await stockApi.updateProduct(venueId, product.productId, fields);
|
||||
if (picture) return stockApi.uploadImage(venueId, saved.productId, picture);
|
||||
if (removePicture && product?.imageId) return stockApi.removeImage(venueId, saved.productId);
|
||||
return saved;
|
||||
},
|
||||
onSuccess: (saved) => done(t(isNew ? 'products.created' : 'products.saved', { name: saved.name })),
|
||||
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
@@ -487,6 +527,52 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
<DialogDescription>{t('products.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{shownPicture ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={shownPicture} alt="" className="h-24 w-24 rounded-lg bg-muted object-contain" />
|
||||
) : (
|
||||
<div className="h-24 w-24 rounded-lg bg-muted flex items-center justify-center">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<label className="cursor-pointer">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{shownPicture ? t('products.change_picture') : t('products.choose_picture')}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
e.target.value = '';
|
||||
if (file) {
|
||||
setPicture(file);
|
||||
setRemovePicture(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
{shownPicture && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPicture(null);
|
||||
setRemovePicture(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />{t('products.remove_picture')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('products.picture_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 />
|
||||
|
||||
@@ -35,7 +35,8 @@ export default function TabsPage() {
|
||||
// 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';
|
||||
// Admins and managers both run the venue
|
||||
const canManage = role === 'admin' || role === 'manager';
|
||||
const queryClient = useQueryClient();
|
||||
const [closeTab, setCloseTab] = useState<Tab | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState('');
|
||||
@@ -157,7 +158,7 @@ export default function TabsPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> {t('planned.title')}</CardTitle>
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
|
||||
</Button>
|
||||
@@ -165,7 +166,7 @@ export default function TabsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input
|
||||
placeholder={t('planned.name_placeholder')}
|
||||
@@ -195,7 +196,7 @@ export default function TabsPage() {
|
||||
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
|
||||
{pt.customerName}
|
||||
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
|
||||
{isAdmin && pt.email && (
|
||||
{canManage && pt.email && (
|
||||
<button
|
||||
onClick={() => sendTestMutation.mutate(pt.id)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
@@ -206,7 +207,7 @@ export default function TabsPage() {
|
||||
<Send className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5" aria-label={t('planned.remove', { name: pt.customerName })}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -283,7 +284,7 @@ export default function TabsPage() {
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate max-w-[200px]">{tab.email}</span>
|
||||
{isAdmin && tab.plannedTabId && (
|
||||
{canManage && tab.plannedTabId && (
|
||||
<button
|
||||
onClick={() => sendTestMutation.mutate(tab.plannedTabId!)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { venueId, role } = useSession();
|
||||
const [addDialog, setAddDialog] = useState(false);
|
||||
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
|
||||
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { t, tn, te, relativeTime } = useLang();
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function UsersPage() {
|
||||
await usersApi.create(venueId, form);
|
||||
toast({ title: t('users.created_toast'), description: t('users.created_toast_desc', { email: form.email, role: t(`users.role.${form.role}`) }), variant: 'success' });
|
||||
setAddDialog(false);
|
||||
setForm({ email: '', password: '', name: '', role: 'viewer' });
|
||||
setForm({ email: '', password: '', name: '', role: 'manager' });
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
} catch (err) {
|
||||
toast({ title: t('users.create_failed'), description: te(err), variant: 'destructive' });
|
||||
@@ -123,7 +123,7 @@ export default function UsersPage() {
|
||||
<Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">{t('users.role_viewer')}</SelectItem>
|
||||
<SelectItem value="manager">{t('users.role_manager')}</SelectItem>
|
||||
<SelectItem value="admin">{t('users.role_admin')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -5,7 +5,8 @@ export interface ApiError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type Role = 'admin' | 'viewer';
|
||||
/** admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders) */
|
||||
export type Role = 'admin' | 'manager';
|
||||
|
||||
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
@@ -125,6 +126,13 @@ export interface Product {
|
||||
reportedAt: string;
|
||||
/** Archived (deleted): hidden on the terminals */
|
||||
archivedAt: string | null;
|
||||
/** The picture, at imageUrl(imageId); null: none */
|
||||
imageId: string | null;
|
||||
}
|
||||
|
||||
/** Pictures never change at an address, so browsers cache them for good. */
|
||||
export function imageUrl(imageId: string): string {
|
||||
return `${API_URL}/images/${imageId}`;
|
||||
}
|
||||
|
||||
export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null };
|
||||
@@ -199,7 +207,8 @@ export async function api<T = unknown>(
|
||||
): Promise<T> {
|
||||
const accessToken = getCookie('access_token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
// A file upload sets its own multipart content type
|
||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
@@ -358,6 +367,14 @@ export const stockApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(product),
|
||||
}),
|
||||
/** Cropped to a square and stored on the server; the terminals download it on their next sync */
|
||||
uploadImage: (venueId: string, productId: string, file: Blob) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
return api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}/image`, { method: 'POST', body });
|
||||
},
|
||||
removeImage: (venueId: string, productId: string) =>
|
||||
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}/image`, { method: 'DELETE' }),
|
||||
/** Only the fields that are sent change; archived: true deletes (hides) the product */
|
||||
updateProduct: (venueId: string, productId: string, changes: Partial<ProductFields> & { archived?: boolean }) =>
|
||||
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, {
|
||||
|
||||
@@ -151,9 +151,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'users.name_placeholder': 'Bartender name',
|
||||
'users.password': 'Password',
|
||||
'users.password_placeholder': 'At least 6 characters',
|
||||
'users.role_viewer': 'Viewer (can close tabs)',
|
||||
'users.role_admin': 'Admin (full access)',
|
||||
'users.role.viewer': 'Viewer',
|
||||
'users.role_manager': 'Manager (runs the venue: products, stock, tabs, reminders)',
|
||||
'users.role_admin': 'Admin (also users and terminals)',
|
||||
'users.role.manager': 'Manager',
|
||||
'users.role.admin': 'Admin',
|
||||
'users.creating': 'Creating...',
|
||||
'users.create': 'Create User',
|
||||
@@ -296,6 +296,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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.choose_picture': 'Choose picture',
|
||||
'products.change_picture': 'Change picture',
|
||||
'products.remove_picture': 'Remove',
|
||||
'products.picture_hint': 'Made square: a glass is cropped, a bottle is shown whole. The terminals keep it for offline use.',
|
||||
'products.duplicate': 'Duplicate',
|
||||
'products.duplicate_desc': 'Another product has the same name, for example created on two terminals at once. Archive the one you do not need.',
|
||||
'products.category': 'Category',
|
||||
'products.category_placeholder': 'e.g. Frisdrank',
|
||||
'products.price': 'Price (€)',
|
||||
@@ -506,10 +512,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'users.name_placeholder': 'Naam van de medewerker',
|
||||
'users.password': 'Wachtwoord',
|
||||
'users.password_placeholder': 'Minstens 6 tekens',
|
||||
'users.role_viewer': 'Medewerker (kan poefs sluiten)',
|
||||
'users.role_admin': 'Beheerder (volledige toegang)',
|
||||
'users.role.viewer': 'Medewerker',
|
||||
'users.role.admin': 'Beheerder',
|
||||
'users.role_manager': 'Beheerder (runt de zaak: producten, voorraad, poefs, herinneringen)',
|
||||
'users.role_admin': 'Admin (ook gebruikers en kassa\'s)',
|
||||
'users.role.manager': 'Beheerder',
|
||||
'users.role.admin': 'Admin',
|
||||
'users.creating': 'Bezig met aanmaken...',
|
||||
'users.create': 'Gebruiker aanmaken',
|
||||
'users.created_toast': 'Gebruiker aangemaakt',
|
||||
@@ -651,6 +657,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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.choose_picture': 'Foto kiezen',
|
||||
'products.change_picture': 'Andere foto',
|
||||
'products.remove_picture': 'Verwijderen',
|
||||
'products.picture_hint': "Wordt vierkant: een glas wordt bijgesneden, een fles blijft heel. De kassa's bewaren ze om offline te tonen.",
|
||||
'products.duplicate': 'Dubbel',
|
||||
'products.duplicate_desc': 'Een ander product heeft dezelfde naam, bijvoorbeeld tegelijk aangemaakt op twee kassa\'s. Archiveer het product dat je niet nodig hebt.',
|
||||
'products.category': 'Categorie',
|
||||
'products.category_placeholder': 'bv. Frisdrank',
|
||||
'products.price': 'Prijs (€)',
|
||||
|
||||
@@ -18,7 +18,9 @@ export function readSession(): Session | null {
|
||||
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(base64));
|
||||
if (!payload.venueId || !payload.role) return null;
|
||||
return { userId: payload.sub, email: payload.email, role: payload.role, venueId: payload.venueId };
|
||||
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
|
||||
const role = payload.role === 'viewer' ? 'manager' : payload.role;
|
||||
return { userId: payload.sub, email: payload.email, role, venueId: payload.venueId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user