feat: add image support to the products
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-21 12:45:19 +02:00
parent ec77dd0e27
commit 5c604e0851
38 changed files with 1241 additions and 77 deletions
@@ -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) })}
+98 -12
View File
@@ -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>
+19 -2
View File
@@ -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)}`, {
+19 -7
View File
@@ -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 (€)',
+3 -1
View File
@@ -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;
}