@@ -8,6 +8,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { SortableHead } from '@/components/ui/sortable-head';
|
||||
import { useSort } from '@/lib/use-sort';
|
||||
import { getDeviceStatus } from '@/lib/device-status';
|
||||
import { DeviceStatusBadge } from '@/components/device-status-badge';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
@@ -16,11 +19,15 @@ import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { Checkbox } from './shared';
|
||||
|
||||
// Sorting on status puts the terminals that are online first
|
||||
const STATUS_ORDER = ['online', 'recently_seen', 'stale', 'offline', 'revoked'];
|
||||
|
||||
export function DevicesSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, te, relativeTime, dateTime } = useLang();
|
||||
const [showUnpaired, setShowUnpaired] = useState(false);
|
||||
const [unpairing, setUnpairing] = useState<AdminDevice | null>(null);
|
||||
const sort = useSort<'name' | 'venue' | 'status' | 'lastSeen' | 'tokenExpires' | 'pending'>();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['admin', 'devices'],
|
||||
@@ -54,7 +61,14 @@ export function DevicesSection() {
|
||||
if (error) return <ErrorState message={te(error)} />;
|
||||
|
||||
const all = data?.devices ?? [];
|
||||
const devices = showUnpaired ? all : all.filter((d) => !d.revokedAt);
|
||||
const devices = sort.apply(showUnpaired ? all : all.filter((d) => !d.revokedAt), {
|
||||
name: (d) => d.name,
|
||||
venue: (d) => d.venueName,
|
||||
status: (d) => STATUS_ORDER.indexOf(getDeviceStatus(d.lastSeenAt, d.revokedAt)),
|
||||
lastSeen: (d) => new Date(d.lastSeenAt),
|
||||
tokenExpires: (d) => new Date(d.tokenExpiresAt),
|
||||
pending: (d) => d.pendingCommands,
|
||||
});
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
@@ -70,12 +84,12 @@ export function DevicesSection() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.devices.name')}</TableHead>
|
||||
<TableHead>{t('admin.devices.venue')}</TableHead>
|
||||
<TableHead>{t('admin.devices.status')}</TableHead>
|
||||
<TableHead>{t('admin.devices.last_seen')}</TableHead>
|
||||
<TableHead>{t('admin.devices.token_expires')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.devices.pending')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('admin.devices.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="venue">{t('admin.devices.venue')}</SortableHead>
|
||||
<SortableHead sort={sort} column="status">{t('admin.devices.status')}</SortableHead>
|
||||
<SortableHead sort={sort} column="lastSeen">{t('admin.devices.last_seen')}</SortableHead>
|
||||
<SortableHead sort={sort} column="tokenExpires">{t('admin.devices.token_expires')}</SortableHead>
|
||||
<SortableHead sort={sort} column="pending" className="text-right">{t('admin.devices.pending')}</SortableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { SortableHead } from '@/components/ui/sortable-head';
|
||||
import { useSort } from '@/lib/use-sort';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
@@ -28,6 +30,7 @@ export function UsersSection() {
|
||||
const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [logoutAll, setLogoutAll] = useState(false);
|
||||
const sort = useSort<'name' | 'venue' | 'role' | 'sessions' | 'created'>();
|
||||
|
||||
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users });
|
||||
|
||||
@@ -73,8 +76,14 @@ export function UsersSection() {
|
||||
if (error) return <ErrorState message={te(error)} />;
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const users = (data?.users ?? []).filter((u) =>
|
||||
!query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query)));
|
||||
const users = sort.apply((data?.users ?? []).filter((u) =>
|
||||
!query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query))), {
|
||||
name: (u) => u.name,
|
||||
venue: (u) => u.venueName,
|
||||
role: (u) => t(`users.role.${u.role}`),
|
||||
sessions: (u) => u.activeSessions,
|
||||
created: (u) => new Date(u.createdAt),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -94,11 +103,11 @@ export function UsersSection() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('users.name')}</TableHead>
|
||||
<TableHead>{t('admin.devices.venue')}</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.users.sessions')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('users.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="venue">{t('admin.devices.venue')}</SortableHead>
|
||||
<SortableHead sort={sort} column="role">{t('users.role')}</SortableHead>
|
||||
<SortableHead sort={sort} column="sessions" className="text-right">{t('admin.users.sessions')}</SortableHead>
|
||||
<SortableHead sort={sort} column="created">{t('users.created')}</SortableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { SortableHead } from '@/components/ui/sortable-head';
|
||||
import { useSort } from '@/lib/use-sort';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
@@ -31,6 +33,7 @@ export function VenuesSection() {
|
||||
const { t, te, relativeTime } = useLang();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const sort = useSort<'name' | 'users' | 'terminals' | 'openTabs' | 'products' | 'lastSync'>();
|
||||
|
||||
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
|
||||
const venues = data?.venues ?? [];
|
||||
@@ -57,16 +60,23 @@ export function VenuesSection() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.venues.name')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.users')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.terminals')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.open_tabs')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.venues.products')}</TableHead>
|
||||
<TableHead>{t('admin.venues.last_sync')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('admin.venues.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="users" className="text-right">{t('admin.venues.users')}</SortableHead>
|
||||
<SortableHead sort={sort} column="terminals" className="text-right">{t('admin.venues.terminals')}</SortableHead>
|
||||
<SortableHead sort={sort} column="openTabs" className="text-right">{t('admin.venues.open_tabs')}</SortableHead>
|
||||
<SortableHead sort={sort} column="products" className="text-right">{t('admin.venues.products')}</SortableHead>
|
||||
<SortableHead sort={sort} column="lastSync">{t('admin.venues.last_sync')}</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{venues.map((venue) => (
|
||||
{sort.apply(venues, {
|
||||
name: (v) => v.name,
|
||||
users: (v) => v.users,
|
||||
terminals: (v) => v.devices,
|
||||
openTabs: (v) => v.openTabs,
|
||||
products: (v) => v.products,
|
||||
lastSync: (v) => (v.lastSyncAt ? new Date(v.lastSyncAt) : null),
|
||||
}).map((venue) => (
|
||||
<TableRow
|
||||
key={venue.id}
|
||||
className={venue.id === selectedId ? 'bg-muted' : 'cursor-pointer'}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { tabsApi, type PaidTab } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyState } from '@/components/empty-state';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { PaymentMethodBadge } from '@/components/payment-method-badge';
|
||||
import { formatCents } from '@/lib/utils';
|
||||
import { RefreshCw, ChevronRight, ChevronDown, Search } from 'lucide-react';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
const paymentFilters = [
|
||||
{ value: '', label: 'history.all_methods' },
|
||||
{ value: 'cash', label: 'payment.cash' },
|
||||
{ value: 'payconiq', label: 'payment.payconiq' },
|
||||
{ value: 'payment_pending', label: 'payment.payment_pending' },
|
||||
];
|
||||
|
||||
const limit = 25;
|
||||
|
||||
export default function HistoryPage() {
|
||||
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||
const { connected: live } = useRealtime();
|
||||
const { t, tn, te, dateTime } = useLang();
|
||||
const { venueId } = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [paymentMethod, setPaymentMethod] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
// The search runs on the server, so wait until typing stops
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(search.trim());
|
||||
setPage(0);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tab-history', venueId, debouncedSearch, paymentMethod, page],
|
||||
queryFn: () => tabsApi.history(venueId, { search: debouncedSearch, paymentMethod, limit, offset: page * limit }),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: live ? false : 10000,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState message={te(error, 'history.load_failed')} />;
|
||||
|
||||
const settlements = data?.settlements ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const filtered = debouncedSearch !== '' || paymentMethod !== '';
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={t('history.title')}
|
||||
description={`${tn('history.count', total)} · ${t('history.total', { amount: formatCents(data?.totalAmountInCents ?? 0) })}`}
|
||||
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tab-history'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
|
||||
/>
|
||||
|
||||
{total === 0 && !filtered ? (
|
||||
<EmptyState title={t('history.empty')} description={t('history.empty_desc')} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 mb-3 flex-wrap items-center">
|
||||
<div className="relative flex-1 min-w-[12rem] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t('history.search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
aria-label={t('history.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{paymentFilters.map((f) => (
|
||||
<Button
|
||||
key={f.value}
|
||||
variant={paymentMethod === f.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => { setPaymentMethod(f.value); setPage(0); }}
|
||||
>
|
||||
{t(f.label)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border mb-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('history.customer')}</TableHead>
|
||||
<TableHead>{t('history.paid_at')}</TableHead>
|
||||
<TableHead>{t('history.amount')}</TableHead>
|
||||
<TableHead>{t('history.items')}</TableHead>
|
||||
<TableHead>{t('history.payment')}</TableHead>
|
||||
<TableHead>{t('history.settled_by')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settlements.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('history.no_matches')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{settlements.map((paid) => {
|
||||
const open = expanded.has(paid.id);
|
||||
return (
|
||||
<Fragment key={paid.id}>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">
|
||||
<button onClick={() => toggle(paid.id)} className="flex items-center gap-1 hover:text-primary" aria-expanded={open} aria-label={t(open ? 'history.hide_items' : 'history.show_items', { name: paid.customerName })}>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
{paid.customerName}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{dateTime(paid.settledAt)}</TableCell>
|
||||
<TableCell className="tabular-nums">{formatCents(paid.amountInCents)}</TableCell>
|
||||
<TableCell>{paid.itemCount}</TableCell>
|
||||
<TableCell><PaymentMethodBadge method={paid.paymentMethod} /></TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{paid.onDashboard ? t('history.dashboard', { name: paid.settledBy ?? '—' }) : paid.settledBy ?? '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open && (
|
||||
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
|
||||
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
|
||||
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
|
||||
<PaidLines items={paid.items} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('history.page', { page: page + 1, total: totalPages })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
{t('history.previous')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>
|
||||
{t('history.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The paid lines, e.g. "10× Cola · €2.50 each · €25.00", with what was taken off before paying. */
|
||||
function PaidLines({ items }: { items: PaidTab['items'] }) {
|
||||
const { t } = useLang();
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex justify-between gap-4">
|
||||
<span className={item.quantity === 0 ? 'text-muted-foreground line-through' : undefined}>
|
||||
<span className="font-medium">{item.quantity}×</span> {item.productName}
|
||||
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
|
||||
{item.removed > 0 && <span className="text-destructive text-xs"> · {t('history.removed', { count: item.removed })}</span>}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
UserCog,
|
||||
ShieldAlert,
|
||||
Eye,
|
||||
History,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -34,6 +35,7 @@ type Access = 'all' | 'admin' | 'superadmin';
|
||||
const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [
|
||||
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' },
|
||||
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, access: 'all' },
|
||||
{ href: '/dashboard/history', key: 'nav.history', icon: History, access: 'all' },
|
||||
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, access: 'all' },
|
||||
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' },
|
||||
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' },
|
||||
|
||||
@@ -6,6 +6,8 @@ import { remindersApi, type ReminderCandidate } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { SortableHead } from '@/components/ui/sortable-head';
|
||||
import { useSort } from '@/lib/use-sort';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -36,6 +38,8 @@ export default function RemindersPage() {
|
||||
// Null until the admin changes the selection: then every eligible customer is selected
|
||||
const [picked, setPicked] = useState<Set<string> | null>(null);
|
||||
const [historyFor, setHistoryFor] = useState<string | null>(null);
|
||||
const historySort = useSort<'customer' | 'email' | 'amount' | 'sentAt' | 'status'>();
|
||||
const candidateSort = useSort<'customer' | 'email' | 'amount' | 'count' | 'lastSent' | 'status'>();
|
||||
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ['reminder-candidates', venueId],
|
||||
@@ -71,9 +75,17 @@ export default function RemindersPage() {
|
||||
const selectedInCooldown = selectedCandidates.filter((c) => !c.eligible).length;
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const visible = query
|
||||
const visible = candidateSort.apply(query
|
||||
? candidates.filter((c) => c.customerName.toLowerCase().includes(query) || c.email?.toLowerCase().includes(query))
|
||||
: candidates;
|
||||
: candidates, {
|
||||
customer: (c) => c.customerName,
|
||||
email: (c) => c.email,
|
||||
amount: (c) => c.amountInCents,
|
||||
count: (c) => c.reminderCount,
|
||||
lastSent: (c) => (c.lastReminderAt ? new Date(c.lastReminderAt) : null),
|
||||
// Ready to send first, then by when the cooldown ends; without an email last
|
||||
status: (c) => (c.email ? (c.eligible ? 0 : new Date(c.nextEligibleAt!).getTime()) : null),
|
||||
});
|
||||
const visibleSendable = visible.filter((c) => c.email);
|
||||
const allVisibleSelected = visibleSendable.length > 0 && visibleSendable.every((c) => selected.has(c.tabId));
|
||||
|
||||
@@ -122,7 +134,13 @@ export default function RemindersPage() {
|
||||
return <Badge variant="warning">{tn('reminders.state_cooldown', days)}</Badge>;
|
||||
};
|
||||
|
||||
const reminders = historyQuery.data?.reminders ?? [];
|
||||
const reminders = historySort.apply(historyQuery.data?.reminders ?? [], {
|
||||
customer: (r) => r.customerName,
|
||||
email: (r) => r.email,
|
||||
amount: (r) => r.amountInCents,
|
||||
sentAt: (r) => new Date(r.sentAt),
|
||||
status: (r) => r.status,
|
||||
});
|
||||
const personReminders = personHistoryQuery.data?.reminders ?? [];
|
||||
|
||||
return (
|
||||
@@ -152,11 +170,11 @@ export default function RemindersPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('reminders.customer')}</TableHead>
|
||||
<TableHead>{t('reminders.email')}</TableHead>
|
||||
<TableHead>{t('reminders.amount')}</TableHead>
|
||||
<TableHead>{t('reminders.sent_at')}</TableHead>
|
||||
<TableHead>{t('reminders.status')}</TableHead>
|
||||
<SortableHead sort={historySort} column="customer">{t('reminders.customer')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="email">{t('reminders.email')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="amount">{t('reminders.amount')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="sentAt">{t('reminders.sent_at')}</SortableHead>
|
||||
<SortableHead sort={historySort} column="status">{t('reminders.status')}</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -236,12 +254,12 @@ export default function RemindersPage() {
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>{t('reminders.customer')}</TableHead>
|
||||
<TableHead>{t('reminders.email')}</TableHead>
|
||||
<TableHead className="text-right">{t('reminders.amount')}</TableHead>
|
||||
<TableHead className="text-center">{t('reminders.count')}</TableHead>
|
||||
<TableHead>{t('reminders.last_sent')}</TableHead>
|
||||
<TableHead>{t('reminders.status')}</TableHead>
|
||||
<SortableHead sort={candidateSort} column="customer">{t('reminders.customer')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="email">{t('reminders.email')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="amount" className="text-right">{t('reminders.amount')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="count" className="text-center">{t('reminders.count')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="lastSent">{t('reminders.last_sent')}</SortableHead>
|
||||
<SortableHead sort={candidateSort} column="status">{t('reminders.status')}</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { SortableHead } from '@/components/ui/sortable-head';
|
||||
import { useSort } from '@/lib/use-sort';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
@@ -33,6 +35,12 @@ const filters: { value: Filter; label: string }[] = [
|
||||
];
|
||||
|
||||
/** At or below its warning level, or out of stock. */
|
||||
/** How many to order to reach the desired stock; null when stock isn't tracked or no desired stock is set. */
|
||||
function toOrder(product: Product): number | null {
|
||||
if (product.stock === null || product.desiredStock === null) return null;
|
||||
return Math.max(0, product.desiredStock - product.stock);
|
||||
}
|
||||
|
||||
function isLow(product: Product): boolean {
|
||||
if (product.stock === null) return false;
|
||||
return product.stock <= 0 || (product.lowStockThreshold !== null && product.stock <= product.lowStockThreshold);
|
||||
@@ -68,6 +76,7 @@ export default function StockPage() {
|
||||
const [deliveryOpen, setDeliveryOpen] = useState(false);
|
||||
// null: closed; 'new': adding a product
|
||||
const [editing, setEditing] = useState<Product | 'new' | null>(null);
|
||||
const sort = useSort<'product' | 'price' | 'buyPrice' | 'stock' | 'toOrder' | 'countedAt'>();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['stock', venueId],
|
||||
@@ -100,13 +109,23 @@ export default function StockPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Products arrive sorted by category, then name
|
||||
// Products arrive sorted by category, then name; a sorted column sorts within each category
|
||||
const groups: { category: string | null; products: Product[] }[] = [];
|
||||
for (const product of visible) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.category === product.category) last.products.push(product);
|
||||
else groups.push({ category: product.category, products: [product] });
|
||||
}
|
||||
for (const group of groups) {
|
||||
group.products = sort.apply(group.products, {
|
||||
product: (p) => p.name,
|
||||
price: (p) => p.priceInCents,
|
||||
buyPrice: (p) => p.buyPriceInCents,
|
||||
stock: (p) => p.stock,
|
||||
toOrder: toOrder,
|
||||
countedAt: (p) => (p.stock !== null && p.countedAt ? new Date(p.countedAt) : null),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -161,17 +180,21 @@ export default function StockPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('stock.product')}</TableHead>
|
||||
<TableHead>{t('stock.price')}</TableHead>
|
||||
<TableHead>{t('stock.stock')}</TableHead>
|
||||
<TableHead>{t('stock.counted_at')}</TableHead>
|
||||
<SortableHead sort={sort} column="product">{t('stock.product')}</SortableHead>
|
||||
<SortableHead sort={sort} column="price">{t('stock.price')}</SortableHead>
|
||||
<SortableHead sort={sort} column="buyPrice">{t('stock.buy_price')}</SortableHead>
|
||||
<SortableHead sort={sort} column="stock">{t('stock.stock')}</SortableHead>
|
||||
<SortableHead sort={sort} column="toOrder" className="text-right">
|
||||
<span title={t('stock.to_order_desc')}>{t('stock.to_order')}</span>
|
||||
</SortableHead>
|
||||
<SortableHead sort={sort} column="countedAt">{t('stock.counted_at')}</SortableHead>
|
||||
<TableHead className="text-right">{t('stock.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
|
||||
<TableCell colSpan={7} className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('stock.no_matches')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -179,7 +202,7 @@ export default function StockPage() {
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.category ?? ''}>
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableCell colSpan={5} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<TableCell colSpan={7} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.category ?? t('stock.no_category')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -196,7 +219,11 @@ export default function StockPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{product.buyPriceInCents === null ? '—' : formatCents(product.buyPriceInCents)}
|
||||
</TableCell>
|
||||
<TableCell><StockLevel product={product} /></TableCell>
|
||||
<TableCell className="text-right tabular-nums"><ToOrder product={product} /></TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{product.stock === null
|
||||
? '—'
|
||||
@@ -274,6 +301,17 @@ export default function StockPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ToOrder({ product }: { product: Product }) {
|
||||
const { t } = useLang();
|
||||
const count = toOrder(product);
|
||||
if (count === null) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className={count > 0 ? 'font-medium' : 'text-muted-foreground'} title={`${t('stock.desired')}: ${product.desiredStock}`}>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StockLevel({ product }: { product: Product }) {
|
||||
const { t } = useLang();
|
||||
if (product.stock === null) return <span className="text-muted-foreground">{t('stock.not_tracked')}</span>;
|
||||
@@ -460,7 +498,9 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
const [name, setName] = useState(product?.name ?? '');
|
||||
const [category, setCategory] = useState(product?.category ?? '');
|
||||
const [price, setPrice] = useState(product ? centsToInput(product.priceInCents) : '');
|
||||
const [buyPrice, setBuyPrice] = useState(product?.buyPriceInCents != null ? centsToInput(product.buyPriceInCents) : '');
|
||||
const [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? '');
|
||||
const [desired, setDesired] = useState(product?.desiredStock?.toString() ?? '');
|
||||
const [trackStock, setTrackStock] = useState(true);
|
||||
const [stock, setStock] = useState('');
|
||||
// A picked picture is uploaded on save; the server crops it to a square
|
||||
@@ -481,9 +521,13 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
const shownPicture = preview ?? (!removePicture && product?.imageId ? imageUrl(product.imageId) : null);
|
||||
|
||||
const priceInCents = parseEuros(price);
|
||||
// Optional: empty is not entered, anything else must be a price
|
||||
const buyPriceInCents = buyPrice.trim() === '' ? null : parseEuros(buyPrice) ?? undefined;
|
||||
const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined;
|
||||
const desiredValue = desired.trim() === '' ? null : /^\d+$/.test(desired.trim()) ? parseInt(desired, 10) : undefined;
|
||||
const stockValue = /^\d+$/.test(stock.trim()) ? parseInt(stock, 10) : null;
|
||||
const valid = name.trim() !== '' && priceInCents !== null && thresholdValue !== undefined
|
||||
const valid = name.trim() !== '' && priceInCents !== null && buyPriceInCents !== undefined && thresholdValue !== undefined
|
||||
&& desiredValue !== undefined
|
||||
&& (!isNew || !trackStock || stockValue !== null);
|
||||
|
||||
function done(title: string) {
|
||||
@@ -495,7 +539,10 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const fields = { name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, lowStockThreshold: thresholdValue! };
|
||||
const fields = {
|
||||
name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, buyPriceInCents: buyPriceInCents!,
|
||||
lowStockThreshold: thresholdValue!, desiredStock: desiredValue!,
|
||||
};
|
||||
const saved = product === null
|
||||
? await stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null })
|
||||
: await stockApi.updateProduct(venueId, product.productId, fields);
|
||||
@@ -571,11 +618,11 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
</div>
|
||||
</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 />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-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="space-y-1.5">
|
||||
<Label htmlFor="product-category">{t('products.category')}</Label>
|
||||
<Input
|
||||
@@ -590,6 +637,8 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
{categories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-price">{t('products.price')}</Label>
|
||||
<Input
|
||||
@@ -601,32 +650,52 @@ function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-buy-price">{t('products.buy_price')}</Label>
|
||||
<Input
|
||||
id="product-buy-price"
|
||||
inputMode="decimal"
|
||||
value={buyPrice}
|
||||
placeholder="1,20"
|
||||
onChange={(e) => setBuyPrice(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>
|
||||
<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>
|
||||
)}
|
||||
{(!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 className="grid gap-4 sm:grid-cols-2">
|
||||
{isNew && (
|
||||
<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)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-desired">{t('stock.desired')}</Label>
|
||||
<Input
|
||||
id="product-desired"
|
||||
inputMode="numeric"
|
||||
value={desired}
|
||||
placeholder={t('stock.desired_none')}
|
||||
onChange={(e) => setDesired(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isNew && (
|
||||
|
||||
@@ -7,12 +7,13 @@ import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { SortableHead } from '@/components/ui/sortable-head';
|
||||
import { useSort } from '@/lib/use-sort';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { EmptyState } from '@/components/empty-state';
|
||||
import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
@@ -25,12 +26,6 @@ import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { canManageVenue, useSession } from '@/lib/session';
|
||||
|
||||
type SortKey = 'name' | 'amount';
|
||||
|
||||
const comparators: Record<SortKey, (a: Tab, b: Tab) => number> = {
|
||||
name: (a, b) => a.customerName.localeCompare(b.customerName, undefined, { sensitivity: 'base' }),
|
||||
amount: (a, b) => b.totalInCents - a.totalInCents,
|
||||
};
|
||||
|
||||
export default function TabsPage() {
|
||||
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||
@@ -44,7 +39,7 @@ export default function TabsPage() {
|
||||
const [paymentMethod, setPaymentMethod] = useState('');
|
||||
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortKey>('name');
|
||||
const sort = useSort<'customer' | 'email' | 'amount' | 'items' | 'reported'>({ key: 'customer', direction: 'asc' });
|
||||
const [plannedName, setPlannedName] = useState('');
|
||||
const [plannedEmail, setPlannedEmail] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -143,9 +138,13 @@ export default function TabsPage() {
|
||||
const tabs = data?.tabs ?? [];
|
||||
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
|
||||
const query = search.trim().toLowerCase();
|
||||
const visibleTabs = tabs
|
||||
.filter((tab) => !query || tab.customerName.toLowerCase().includes(query))
|
||||
.sort(comparators[sort]);
|
||||
const visibleTabs = sort.apply(tabs.filter((tab) => !query || tab.customerName.toLowerCase().includes(query)), {
|
||||
customer: (tab) => tab.customerName,
|
||||
email: (tab) => tab.email,
|
||||
amount: (tab) => tab.totalInCents,
|
||||
items: (tab) => tab.itemCount,
|
||||
reported: (tab) => new Date(tab.lastChangedAt),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -225,24 +224,17 @@ export default function TabsPage() {
|
||||
aria-label={t('tabs.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<Select value={sort} onValueChange={(value) => setSort(value as SortKey)}>
|
||||
<SelectTrigger className="w-44" aria-label={t('tabs.sort')}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">{t('tabs.sort_name')}</SelectItem>
|
||||
<SelectItem value="amount">{t('tabs.sort_amount')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('tabs.customer')}</TableHead>
|
||||
<TableHead>{t('tabs.email')}</TableHead>
|
||||
<TableHead>{t('tabs.amount')}</TableHead>
|
||||
<TableHead>{t('tabs.items')}</TableHead>
|
||||
<TableHead>{t('tabs.reported')}</TableHead>
|
||||
<SortableHead sort={sort} column="customer">{t('tabs.customer')}</SortableHead>
|
||||
<SortableHead sort={sort} column="email">{t('tabs.email')}</SortableHead>
|
||||
<SortableHead sort={sort} column="amount">{t('tabs.amount')}</SortableHead>
|
||||
<SortableHead sort={sort} column="items">{t('tabs.items')}</SortableHead>
|
||||
<SortableHead sort={sort} column="reported">{t('tabs.reported')}</SortableHead>
|
||||
<TableHead className="text-right">{t('tabs.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useState } from 'react';
|
||||
import { usersApi, type User } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { SortableHead } from '@/components/ui/sortable-head';
|
||||
import { useSort } from '@/lib/use-sort';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
@@ -28,6 +30,7 @@ export default function UsersPage() {
|
||||
const [deleting, setDeleting] = useState<User | null>(null);
|
||||
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const sort = useSort<'name' | 'email' | 'role' | 'created'>();
|
||||
const { t, tn, te, relativeTime } = useLang();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -92,15 +95,20 @@ export default function UsersPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('users.name')}</TableHead>
|
||||
<TableHead>{t('users.email')}</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
<SortableHead sort={sort} column="name">{t('users.name')}</SortableHead>
|
||||
<SortableHead sort={sort} column="email">{t('users.email')}</SortableHead>
|
||||
<SortableHead sort={sort} column="role">{t('users.role')}</SortableHead>
|
||||
<SortableHead sort={sort} column="created">{t('users.created')}</SortableHead>
|
||||
{isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
{sort.apply(users, {
|
||||
name: (u) => u.name,
|
||||
email: (u) => u.email,
|
||||
role: (u) => t(`users.role.${u.role}`),
|
||||
created: (u) => new Date(u.createdAt),
|
||||
}).map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react';
|
||||
import { TableHead } from '@/components/ui/table';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Sort } from '@/lib/use-sort';
|
||||
|
||||
/** A column header that sorts the table on click: ascending, descending, then back to the table's own order. */
|
||||
export function SortableHead<K extends string>({ sort, column, className, children }: {
|
||||
sort: Sort<K>;
|
||||
column: K;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = sort.key === column;
|
||||
const Icon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown;
|
||||
const right = className?.includes('text-right');
|
||||
const center = className?.includes('text-center');
|
||||
|
||||
return (
|
||||
<TableHead className={className} aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sort.toggle(column)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 -mx-1 px-1 rounded select-none hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'text-foreground',
|
||||
right && 'flex-row-reverse',
|
||||
center && 'justify-center',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<Icon className={cn('h-3.5 w-3.5 shrink-0', !active && 'opacity-40')} />
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,21 @@ export interface Tab {
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired';
|
||||
/** A paid tab, settled on a terminal or on the dashboard. */
|
||||
export interface PaidTab {
|
||||
id: string;
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
settledAt: string;
|
||||
paymentMethod: string;
|
||||
amountInCents: number;
|
||||
itemCount: number;
|
||||
items: (TabLine & { removed: number })[];
|
||||
settledBy: string | null;
|
||||
onDashboard: boolean;
|
||||
}
|
||||
|
||||
export type CommandStatus ='pending' | 'completed' | 'failed' | 'conflict' | 'expired';
|
||||
|
||||
export interface Command {
|
||||
id: string;
|
||||
@@ -119,10 +133,15 @@ export interface Product {
|
||||
productId: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
/** The sell price, charged on the terminals */
|
||||
priceInCents: number;
|
||||
/** What the bar pays for one; null when not entered. Never sent to the terminals */
|
||||
buyPriceInCents: number | null;
|
||||
/** Null when stock is not tracked for this product; can be negative */
|
||||
stock: number | null;
|
||||
lowStockThreshold: number | null;
|
||||
/** How many the bar wants in stock; null when not set. Never sent to the terminals */
|
||||
desiredStock: 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 */
|
||||
@@ -138,7 +157,10 @@ export function imageUrl(imageId: string): string {
|
||||
return `${API_URL}/images/${imageId}`;
|
||||
}
|
||||
|
||||
export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null };
|
||||
export type ProductFields = {
|
||||
name: string; category: string | null; priceInCents: number; buyPriceInCents: number | null; lowStockThreshold: number | null;
|
||||
desiredStock: number | null;
|
||||
};
|
||||
|
||||
export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
|
||||
|
||||
@@ -280,6 +302,17 @@ export const authApi = {
|
||||
export const tabsApi = {
|
||||
list: (venueId: string) =>
|
||||
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
|
||||
history: (venueId: string, params: { search?: string; paymentMethod?: string; limit?: number; offset?: number }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.search) searchParams.set('search', params.search);
|
||||
if (params.paymentMethod) searchParams.set('paymentMethod', params.paymentMethod);
|
||||
if (params.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params.offset) searchParams.set('offset', String(params.offset));
|
||||
const qs = searchParams.toString();
|
||||
return api<{ settlements: PaidTab[]; total: number; totalAmountInCents: number }>(
|
||||
`/venues/${venueId}/tabs/history${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
/** Deletes the tab without settling it; refused when it changed since [expectedVersion] */
|
||||
remove: (venueId: string, tabId: string, expectedVersion: number) =>
|
||||
api(`/venues/${venueId}/tabs/${encodeURIComponent(tabId)}?expectedVersion=${expectedVersion}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -81,9 +81,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.amount': 'Amount',
|
||||
'tabs.items': 'Items',
|
||||
'tabs.search_placeholder': 'Search customers…',
|
||||
'tabs.sort': 'Sort by',
|
||||
'tabs.sort_name': 'Name',
|
||||
'tabs.sort_amount': 'Highest amount',
|
||||
'tabs.outstanding': '{amount} outstanding',
|
||||
'tabs.closing': 'Closing…',
|
||||
'tabs.no_matches': 'No tabs match "{query}"',
|
||||
@@ -110,6 +107,30 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.deleted': "{name}'s tab is deleted",
|
||||
'tabs.settled': 'Tab settled',
|
||||
'tabs.settled_desc': '{name}: {amount}',
|
||||
'nav.history': 'Tab History',
|
||||
'history.title': 'Tab History',
|
||||
'history.count': '{count} paid tab',
|
||||
'history.count_plural': '{count} paid tabs',
|
||||
'history.total': '{amount} paid',
|
||||
'history.search_placeholder': 'Search by name...',
|
||||
'history.all_methods': 'All',
|
||||
'history.customer': 'Customer',
|
||||
'history.paid_at': 'Paid',
|
||||
'history.amount': 'Amount',
|
||||
'history.items': 'Items',
|
||||
'history.payment': 'Payment',
|
||||
'history.settled_by': 'Settled by',
|
||||
'history.dashboard': 'Dashboard ({name})',
|
||||
'history.removed': '{count} removed',
|
||||
'history.show_items': "Show {name}'s items",
|
||||
'history.hide_items': "Hide {name}'s items",
|
||||
'history.empty': 'No paid tabs yet',
|
||||
'history.empty_desc': 'Tabs settled on a terminal or here appear once the terminal has synced.',
|
||||
'history.no_matches': 'No paid tabs match your filters.',
|
||||
'history.load_failed': 'Failed to load the tab history',
|
||||
'history.previous': 'Previous',
|
||||
'history.next': 'Next',
|
||||
'history.page': 'Page {page} of {total}',
|
||||
'planned.title': 'Planned Customers',
|
||||
'planned.name_placeholder': 'Add customer name...',
|
||||
'planned.email_placeholder': 'Email (optional)',
|
||||
@@ -328,7 +349,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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 (€)',
|
||||
'products.price': 'Sell price (€)',
|
||||
'products.buy_price': 'Buy price (€)',
|
||||
'products.buy_price_hint': 'What you pay for one. Only shown here, never on the terminals.',
|
||||
'products.track_stock': 'Track stock',
|
||||
'products.starting_stock': 'In stock now',
|
||||
'products.save': 'Save',
|
||||
@@ -346,7 +369,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.no_matches': 'No products match',
|
||||
'stock.no_category': 'Other',
|
||||
'stock.product': 'Product',
|
||||
'stock.price': 'Price',
|
||||
'stock.price': 'Sell price',
|
||||
'stock.buy_price': 'Buy price',
|
||||
'stock.stock': 'Stock',
|
||||
'stock.counted_at': 'Last counted',
|
||||
'stock.never_counted': 'Never',
|
||||
@@ -369,6 +393,10 @@ 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.desired': 'Desired stock',
|
||||
'stock.desired_none': 'Not set',
|
||||
'stock.to_order': 'To order',
|
||||
'stock.to_order_desc': 'Desired stock minus the current stock',
|
||||
'stock.send': 'Save',
|
||||
'stock.sending': 'Sending…',
|
||||
'stock.sent': 'Stock updated',
|
||||
@@ -635,9 +663,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.amount': 'Bedrag',
|
||||
'tabs.items': 'Items',
|
||||
'tabs.search_placeholder': 'Zoek klant…',
|
||||
'tabs.sort': 'Sorteer op',
|
||||
'tabs.sort_name': 'Naam',
|
||||
'tabs.sort_amount': 'Hoogste bedrag',
|
||||
'tabs.outstanding': '{amount} openstaand',
|
||||
'tabs.closing': 'Wordt gesloten…',
|
||||
'tabs.no_matches': 'Geen poefs gevonden voor "{query}"',
|
||||
@@ -664,6 +689,30 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.deleted': 'Poef van {name} is verwijderd',
|
||||
'tabs.settled': 'Poef afgerekend',
|
||||
'tabs.settled_desc': '{name}: {amount}',
|
||||
'nav.history': 'Poefgeschiedenis',
|
||||
'history.title': 'Poefgeschiedenis',
|
||||
'history.count': '{count} betaalde poef',
|
||||
'history.count_plural': '{count} betaalde poefs',
|
||||
'history.total': '{amount} betaald',
|
||||
'history.search_placeholder': 'Zoek op naam...',
|
||||
'history.all_methods': 'Alle',
|
||||
'history.customer': 'Klant',
|
||||
'history.paid_at': 'Betaald',
|
||||
'history.amount': 'Bedrag',
|
||||
'history.items': 'Items',
|
||||
'history.payment': 'Betaling',
|
||||
'history.settled_by': 'Afgerekend door',
|
||||
'history.dashboard': 'Dashboard ({name})',
|
||||
'history.removed': '{count} verwijderd',
|
||||
'history.show_items': 'Toon de items van {name}',
|
||||
'history.hide_items': 'Verberg de items van {name}',
|
||||
'history.empty': 'Nog geen betaalde poefs',
|
||||
'history.empty_desc': 'Poefs die op een kassa of hier afgerekend zijn, verschijnen zodra de kassa gesynchroniseerd is.',
|
||||
'history.no_matches': 'Geen betaalde poefs voor deze filters.',
|
||||
'history.load_failed': 'Kon de poefgeschiedenis niet laden',
|
||||
'history.previous': 'Vorige',
|
||||
'history.next': 'Volgende',
|
||||
'history.page': 'Pagina {page} van {total}',
|
||||
'planned.title': 'Geplande klanten',
|
||||
'planned.name_placeholder': 'Naam van de klant...',
|
||||
'planned.email_placeholder': 'E-mail (optioneel)',
|
||||
@@ -882,7 +931,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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 (€)',
|
||||
'products.price': 'Verkoopprijs (€)',
|
||||
'products.buy_price': 'Aankoopprijs (€)',
|
||||
'products.buy_price_hint': 'Wat je zelf betaalt per stuk. Enkel hier zichtbaar, nooit op de terminals.',
|
||||
'products.track_stock': 'Voorraad bijhouden',
|
||||
'products.starting_stock': 'Nu in voorraad',
|
||||
'products.save': 'Opslaan',
|
||||
@@ -900,7 +951,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.no_matches': 'Geen producten gevonden',
|
||||
'stock.no_category': 'Overige',
|
||||
'stock.product': 'Product',
|
||||
'stock.price': 'Prijs',
|
||||
'stock.price': 'Verkoopprijs',
|
||||
'stock.buy_price': 'Aankoopprijs',
|
||||
'stock.stock': 'Voorraad',
|
||||
'stock.counted_at': 'Laatst geteld',
|
||||
'stock.never_counted': 'Nooit',
|
||||
@@ -923,6 +975,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.note_placeholder': 'bv. levering brouwerij',
|
||||
'stock.threshold': 'Waarschuw vanaf',
|
||||
'stock.threshold_none': 'Geen waarschuwing',
|
||||
'stock.desired': 'Gewenste stock',
|
||||
'stock.desired_none': 'Niet ingesteld',
|
||||
'stock.to_order': 'Bij te bestellen',
|
||||
'stock.to_order_desc': 'Gewenste stock min de huidige stock',
|
||||
'stock.send': 'Opslaan',
|
||||
'stock.sending': 'Versturen…',
|
||||
'stock.sent': 'Voorraad bijgewerkt',
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useSession } from '@/lib/session';
|
||||
|
||||
// Server topics mapped to the dashboard queries that show that data
|
||||
const TOPIC_QUERIES: Record<string, string[][]> = {
|
||||
tabs: [['tabs'], ['reminder-candidates']],
|
||||
// A settlement changes the tabs, so it comes with this topic
|
||||
tabs: [['tabs'], ['reminder-candidates'], ['tab-history']],
|
||||
commands: [['commands']],
|
||||
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
|
||||
device: [['device']],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
export type SortValue = string | number | boolean | Date | null | undefined;
|
||||
|
||||
export interface Sort<K extends string> {
|
||||
key: K | null;
|
||||
direction: SortDirection;
|
||||
/** A click on a column header: ascending first, then descending, then back to the table's own order */
|
||||
toggle: (key: K) => void;
|
||||
/** [rows] in the chosen order; [columns] gives the value each column sorts on */
|
||||
apply: <T>(rows: readonly T[], columns: Record<K, (row: T) => SortValue>) => T[];
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
|
||||
const isEmpty = (v: SortValue) => v === null || v === undefined || v === '';
|
||||
|
||||
/** Text ignores case and puts "Beer 9" before "Beer 10"; dates and numbers by value. */
|
||||
function compare(a: SortValue, b: SortValue): number {
|
||||
if (typeof a === 'string' && typeof b === 'string') return collator.compare(a, b);
|
||||
const toNumber = (v: SortValue) => (v instanceof Date ? v.getTime() : Number(v));
|
||||
return toNumber(a) - toNumber(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-sort for a table whose rows are all loaded. Without a click (or [initial]) the rows keep the order they
|
||||
* came in. The hook holds only the choice, so it can be called before a page's loading returns.
|
||||
*/
|
||||
export function useSort<K extends string>(initial?: { key: K; direction: SortDirection }): Sort<K> {
|
||||
const [state, setState] = useState<{ key: K | null; direction: SortDirection }>(initial ?? { key: null, direction: 'asc' });
|
||||
|
||||
return {
|
||||
...state,
|
||||
toggle: (key) => setState((current) => {
|
||||
if (current.key !== key) return { key, direction: 'asc' };
|
||||
if (current.direction === 'asc') return { key, direction: 'desc' };
|
||||
return initial ?? { key: null, direction: 'asc' };
|
||||
}),
|
||||
apply: (rows, columns) => {
|
||||
const { key, direction } = state;
|
||||
if (key === null) return [...rows];
|
||||
const value = columns[key];
|
||||
// Empty values stay last in both directions, like in a spreadsheet; equal values keep their order
|
||||
return [...rows].sort((a, b) => {
|
||||
const va = value(a);
|
||||
const vb = value(b);
|
||||
if (isEmpty(va) || isEmpty(vb)) return Number(isEmpty(va)) - Number(isEmpty(vb));
|
||||
return direction === 'asc' ? compare(va, vb) : compare(vb, va);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user