feat: better reminder tab
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 10:39:06 +02:00
parent 874f68b4b1
commit fb6d26a468
8 changed files with 441 additions and 86 deletions
@@ -1,28 +1,40 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { remindersApi } from '@/lib/api/client';
import { useMemo, useState } from 'react';
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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { EmptyState } from '@/components/empty-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { Send, History } from 'lucide-react';
import { Send, History, Search } from 'lucide-react';
const DAY_MS = 24 * 60 * 60 * 1000;
// Eligible first, then those in their cooldown, then those without an email; biggest balance first
function sortRank(c: ReminderCandidate) {
return c.eligible ? 0 : c.email ? 1 : 2;
}
export default function RemindersPage() {
const queryClient = useQueryClient();
const { t, te, relativeTime } = useLang();
const { t, tn, te, relativeTime, dateTime } = useLang();
const { venueId, role } = useSession();
const isAdmin = role === 'admin';
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [search, setSearch] = useState('');
// 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 candidatesQuery = useQuery({
queryKey: ['reminder-candidates', venueId],
@@ -35,10 +47,56 @@ export default function RemindersPage() {
enabled: showHistory,
});
const personHistoryQuery = useQuery({
queryKey: ['reminder-history', venueId, historyFor],
queryFn: () => remindersApi.history(venueId, historyFor!),
enabled: historyFor !== null,
});
const candidates = useMemo(
() =>
[...(candidatesQuery.data?.candidates ?? [])].sort(
(a, b) => sortRank(a) - sortRank(b) || b.amountInCents - a.amountInCents,
),
[candidatesQuery.data],
);
const cooldownDays = candidatesQuery.data?.cooldownDays ?? 7;
const sendable = candidates.filter((c) => c.email);
const selected = new Set(
sendable.filter((c) => (picked ? picked.has(c.tabId) : c.eligible)).map((c) => c.tabId),
);
const selectedCandidates = sendable.filter((c) => selected.has(c.tabId));
const selectedTotal = selectedCandidates.reduce((sum, c) => sum + c.amountInCents, 0);
const selectedInCooldown = selectedCandidates.filter((c) => !c.eligible).length;
const query = search.trim().toLowerCase();
const visible = query
? candidates.filter((c) => c.customerName.toLowerCase().includes(query) || c.email?.toLowerCase().includes(query))
: candidates;
const visibleSendable = visible.filter((c) => c.email);
const allVisibleSelected = visibleSendable.length > 0 && visibleSendable.every((c) => selected.has(c.tabId));
const setSelection = (ids: Iterable<string>) => setPicked(new Set(ids));
const toggle = (tabId: string) => {
const next = new Set(selected);
if (next.has(tabId)) next.delete(tabId);
else next.add(tabId);
setSelection(next);
};
const toggleAllVisible = () => {
const next = new Set(selected);
for (const c of visibleSendable) {
if (allVisibleSelected) next.delete(c.tabId);
else next.add(c.tabId);
}
setSelection(next);
};
const sendMutation = useMutation({
mutationFn: () => remindersApi.send(venueId),
mutationFn: (tabIds: string[]) => remindersApi.send(venueId, tabIds),
onSuccess: (result) => {
setSendDialogOpen(false);
setPicked(null);
if (result.errors && result.errors.length > 0) {
const firstError = result.errors[0];
toast({ title: t('reminders.partial', { sent: result.sent, total: result.total }), description: t('reminders.error_detail', { error: firstError.error }), variant: 'destructive' });
@@ -56,8 +114,15 @@ export default function RemindersPage() {
},
});
const candidates = candidatesQuery.data?.candidates ?? [];
const statusBadge = (c: ReminderCandidate) => {
if (!c.email) return <Badge variant="secondary">{t('reminders.state_no_email')}</Badge>;
if (c.eligible) return <Badge variant="success">{t('reminders.state_eligible')}</Badge>;
const days = Math.max(1, Math.ceil((new Date(c.nextEligibleAt!).getTime() - Date.now()) / DAY_MS));
return <Badge variant="warning">{tn('reminders.state_cooldown', days)}</Badge>;
};
const reminders = historyQuery.data?.reminders ?? [];
const personReminders = personHistoryQuery.data?.reminders ?? [];
return (
<div>
@@ -68,7 +133,7 @@ export default function RemindersPage() {
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setShowHistory(!showHistory); if (!showHistory) queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); }}>
<History className="h-4 w-4 mr-2" />
{showHistory ? t('reminders.candidates') : t('reminders.history')}
{showHistory ? t('reminders.customers') : t('reminders.history')}
</Button>
</div>
}
@@ -97,10 +162,14 @@ export default function RemindersPage() {
<TableBody>
{reminders.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.customerName}</TableCell>
<TableCell className="font-medium">
<button type="button" className="hover:underline" onClick={() => setHistoryFor(r.customerName)}>
{r.customerName}
</button>
</TableCell>
<TableCell>{r.email}</TableCell>
<TableCell>{formatCents(r.amountInCents)}</TableCell>
<TableCell>{relativeTime(r.sentAt)}</TableCell>
<TableCell title={dateTime(r.sentAt)}>{relativeTime(r.sentAt)}</TableCell>
<TableCell><Badge variant={r.status === 'sent' ? 'success' : 'secondary'}>{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
</TableRow>
))}
@@ -112,11 +181,12 @@ export default function RemindersPage() {
</Card>
) : (
<Card>
<CardHeader className="pb-3">
<CardHeader className="pb-3 space-y-1">
<CardTitle className="text-sm flex items-center gap-2">
<Send className="h-4 w-4" />
{t('reminders.candidates')}
{t('reminders.customers')}
</CardTitle>
<p className="text-xs text-muted-foreground">{t('reminders.customers_desc', { days: cooldownDays })}</p>
</CardHeader>
<CardContent>
{candidatesQuery.isLoading ? (
@@ -127,34 +197,120 @@ export default function RemindersPage() {
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
) : (
<>
<div className="rounded-md border mb-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
<div className="relative sm:max-w-xs w-full">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('reminders.search')}
className="pl-8"
/>
</div>
{isAdmin && (
<div className="flex gap-2 sm:ml-auto">
<Button variant="outline" size="sm" onClick={() => setPicked(null)}>
{t('reminders.select_eligible')}
</Button>
<Button variant="outline" size="sm" onClick={() => setSelection(sendable.map((c) => c.tabId))}>
{t('reminders.select_all')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelection([])} disabled={selected.size === 0}>
{t('reminders.select_none')}
</Button>
</div>
)}
</div>
<div className="rounded-md border mb-4 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{isAdmin && (
<TableHead className="w-10">
<input
type="checkbox"
aria-label={t('reminders.select_all')}
checked={allVisibleSelected}
disabled={visibleSendable.length === 0}
onChange={toggleAllVisible}
/>
</TableHead>
)}
<TableHead>{t('reminders.customer')}</TableHead>
<TableHead>{t('reminders.email')}</TableHead>
<TableHead>{t('reminders.amount')}</TableHead>
<TableHead>{t('tabs.items')}</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>
</TableRow>
</TableHeader>
<TableBody>
{candidates.map((c) => (
<TableRow key={c.tabId}>
<TableCell className="font-medium">{c.customerName}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell>{formatCents(c.amountInCents)}</TableCell>
<TableCell>{c.itemCount}</TableCell>
{visible.map((c) => {
const isSelected = selected.has(c.tabId);
return (
<TableRow
key={c.tabId}
data-state={isSelected ? 'selected' : undefined}
className={isAdmin && c.email ? 'cursor-pointer' : undefined}
onClick={isAdmin && c.email ? () => toggle(c.tabId) : undefined}
>
{isAdmin && (
<TableCell onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
aria-label={c.customerName}
checked={isSelected}
disabled={!c.email}
onChange={() => toggle(c.tabId)}
/>
</TableCell>
)}
<TableCell className="font-medium">{c.customerName}</TableCell>
<TableCell className={c.email ? undefined : 'text-muted-foreground'}>{c.email ?? '—'}</TableCell>
<TableCell className="text-right tabular-nums">{formatCents(c.amountInCents)}</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{c.reminderCount > 0 ? (
<button
type="button"
className="tabular-nums underline-offset-2 hover:underline"
title={t('reminders.view_history')}
onClick={() => setHistoryFor(c.customerName)}
>
{c.reminderCount}×
</button>
) : (
<span className="text-muted-foreground">0</span>
)}
</TableCell>
<TableCell className="text-muted-foreground" title={c.lastReminderAt ? dateTime(c.lastReminderAt) : undefined}>
{c.lastReminderAt ? relativeTime(c.lastReminderAt) : t('reminders.never')}
</TableCell>
<TableCell>{statusBadge(c)}</TableCell>
</TableRow>
);
})}
{visible.length === 0 && (
<TableRow>
<TableCell colSpan={isAdmin ? 7 : 6} className="text-center text-sm text-muted-foreground py-6">
{t('reminders.no_matches')}
</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
</div>
{role === 'admin' ? (
<Button onClick={() => setSendDialogOpen(true)}>
<Send className="h-4 w-4 mr-2" />
{t('reminders.send')} ({candidates.length})
</Button>
{isAdmin ? (
<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) })}
</p>
<Button className="sm:ml-auto" onClick={() => setSendDialogOpen(true)} disabled={selected.size === 0}>
<Send className="h-4 w-4 mr-2" />
{t('reminders.send')} ({selected.size})
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">{t('reminders.admin_only')}</p>
)}
@@ -168,20 +324,24 @@ export default function RemindersPage() {
<DialogContent>
<DialogHeader>
<DialogTitle>{t('reminders.send')}</DialogTitle>
<DialogDescription>
{t('reminders.candidates_desc')}
</DialogDescription>
<DialogDescription>{t('reminders.candidates_desc')}</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
{t('reminders.confirm', { count: candidates.length })}
{t('reminders.confirm', { count: selectedCandidates.length })}
</p>
<ul className="mt-3 space-y-1">
{candidates.map((c) => (
{selectedInCooldown > 0 && (
<p className="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
{tn('reminders.confirm_cooldown', selectedInCooldown, { days: cooldownDays })}
</p>
)}
<ul className="mt-3 space-y-1 max-h-64 overflow-y-auto">
{selectedCandidates.map((c) => (
<li key={c.tabId} className="text-sm flex items-center gap-2">
<span className="font-medium">{c.customerName}</span>
<span className="text-muted-foreground">({c.email})</span>
<span className="ml-auto">{formatCents(c.amountInCents)}</span>
<span className="text-muted-foreground truncate">({c.email})</span>
{!c.eligible && <Badge variant="warning">{t('reminders.recent')}</Badge>}
<span className="ml-auto tabular-nums">{formatCents(c.amountInCents)}</span>
</li>
))}
</ul>
@@ -190,7 +350,10 @@ export default function RemindersPage() {
<Button variant="outline" onClick={() => setSendDialogOpen(false)} disabled={sendMutation.isPending}>
{t('common.cancel')}
</Button>
<Button onClick={() => sendMutation.mutate()} disabled={sendMutation.isPending}>
<Button
onClick={() => sendMutation.mutate(selectedCandidates.map((c) => c.tabId))}
disabled={sendMutation.isPending || selectedCandidates.length === 0}
>
{sendMutation.isPending ? (
<>{t('reminders.sending')}</>
) : (
@@ -200,6 +363,33 @@ export default function RemindersPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={historyFor !== null} onOpenChange={(open) => !open && setHistoryFor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('reminders.person_history', { name: historyFor ?? '' })}</DialogTitle>
<DialogDescription>{tn('reminders.person_history_desc', personReminders.length)}</DialogDescription>
</DialogHeader>
{personHistoryQuery.isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : personReminders.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('reminders.history_empty')}</p>
) : (
<ul className="divide-y rounded-md border max-h-80 overflow-y-auto">
{personReminders.map((r) => (
<li key={r.id} className="flex items-center gap-3 px-3 py-2 text-sm">
<span>{dateTime(r.sentAt)}</span>
<span className="text-muted-foreground truncate">{r.email}</span>
<span className="ml-auto tabular-nums">{formatCents(r.amountInCents)}</span>
</li>
))}
</ul>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setHistoryFor(null)}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
}
+17 -6
View File
@@ -70,10 +70,16 @@ export interface PlannedTab {
export interface ReminderCandidate {
tabId: string;
customerName: string;
email: string;
/** Null when the customer has no email address on file */
email: string | null;
amountInCents: number;
itemCount: number;
items: TabLine[] | null;
reminderCount: number;
lastReminderAt: string | null;
nextEligibleAt: string | null;
/** Has an email and is past the cooldown */
eligible: boolean;
}
export interface Reminder {
@@ -274,11 +280,16 @@ export const plannedTabsApi = {
// Reminders API
export const remindersApi = {
candidates: (venueId: string) =>
api<{ candidates: ReminderCandidate[] }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }),
history: (venueId: string) =>
api<{ reminders: Reminder[] }>(`/venues/${venueId}/reminders`),
api<{ candidates: ReminderCandidate[]; cooldownDays: number }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string, tabIds: string[]) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, {
method: 'POST',
body: JSON.stringify({ tabIds }),
}),
history: (venueId: string, customerName?: string) =>
api<{ reminders: Reminder[] }>(
`/venues/${venueId}/reminders${customerName ? `?customerName=${encodeURIComponent(customerName)}` : ''}`,
),
sendTest: (venueId: string, plannedTabId: string) =>
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
+50 -4
View File
@@ -220,8 +220,8 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.desc': 'Send payment reminders to customers with an unpaid tab',
'reminders.candidates': 'Eligible for a reminder',
'reminders.candidates_desc': 'These customers have an unpaid tab and an email address on file. Reminders are sent via Resend.',
'reminders.no_candidates': 'No eligible customers',
'reminders.no_candidates_desc': 'Make sure planned customers have an email address.',
'reminders.no_candidates': 'No unpaid tabs',
'reminders.no_candidates_desc': 'Customers with an open balance show up here.',
'reminders.send': 'Send Reminders',
'reminders.sending': 'Sending...',
'reminders.admin_only': 'Only admins can send reminders.',
@@ -239,6 +239,29 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.sent_at': 'Sent At',
'reminders.status': 'Status',
'reminders.status_sent': 'Sent',
'reminders.customers': 'Customers with an unpaid tab',
'reminders.customers_desc': 'Eligible customers are selected by default. Anyone reminded in the last {days} days can still be picked by hand.',
'reminders.search': 'Search name or email',
'reminders.select_eligible': 'Select eligible',
'reminders.select_all': 'Select all',
'reminders.select_none': 'Clear',
'reminders.count': 'Reminders',
'reminders.last_sent': 'Last reminder',
'reminders.never': 'Never',
'reminders.state_eligible': 'Eligible',
'reminders.state_no_email': 'No email',
'reminders.state_cooldown': 'Eligible in {count} day',
'reminders.state_cooldown_plural': 'Eligible in {count} days',
'reminders.view_history': 'View reminders sent to this customer',
'reminders.no_matches': 'No customers match your search',
'reminders.selected': '{count} customer selected · {amount} outstanding',
'reminders.selected_plural': '{count} customers selected · {amount} outstanding',
'reminders.confirm_cooldown': '{count} of them was already reminded in the last {days} days.',
'reminders.confirm_cooldown_plural': '{count} of them were already reminded in the last {days} days.',
'reminders.recent': 'Recently reminded',
'reminders.person_history': 'Reminders for {name}',
'reminders.person_history_desc': '{count} reminder sent',
'reminders.person_history_desc_plural': '{count} reminders sent',
},
nl: {
'nav.overview': 'Overzicht',
@@ -456,8 +479,8 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.desc': 'Stuur betalingsherinneringen naar klanten met een openstaande poef',
'reminders.candidates': 'Komen in aanmerking',
'reminders.candidates_desc': 'Deze klanten hebben een openstaande poef en een gekend e-mailadres. Herinneringen worden verstuurd via Resend.',
'reminders.no_candidates': 'Geen klanten die in aanmerking komen',
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
'reminders.no_candidates': 'Niemand heeft een openstaande poef',
'reminders.no_candidates_desc': 'Klanten met een openstaand bedrag verschijnen hier.',
'reminders.send': 'Herinneringen versturen',
'reminders.sending': 'Bezig met versturen...',
'reminders.admin_only': 'Alleen beheerders kunnen herinneringen versturen.',
@@ -475,6 +498,29 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.sent_at': 'Verstuurd op',
'reminders.status': 'Status',
'reminders.status_sent': 'Verstuurd',
'reminders.customers': 'Klanten met een openstaande poef',
'reminders.customers_desc': 'Klanten die in aanmerking komen zijn standaard geselecteerd. Wie de laatste {days} dagen al een herinnering kreeg, kun je nog steeds zelf aanvinken.',
'reminders.search': 'Zoek op naam of e-mail',
'reminders.select_eligible': 'Selecteer in aanmerking',
'reminders.select_all': 'Alles selecteren',
'reminders.select_none': 'Wissen',
'reminders.count': 'Herinneringen',
'reminders.last_sent': 'Laatste herinnering',
'reminders.never': 'Nooit',
'reminders.state_eligible': 'In aanmerking',
'reminders.state_no_email': 'Geen e-mail',
'reminders.state_cooldown': 'Over {count} dag',
'reminders.state_cooldown_plural': 'Over {count} dagen',
'reminders.view_history': 'Bekijk de herinneringen aan deze klant',
'reminders.no_matches': 'Geen klanten gevonden',
'reminders.selected': '{count} klant geselecteerd · {amount} openstaand',
'reminders.selected_plural': '{count} klanten geselecteerd · {amount} openstaand',
'reminders.confirm_cooldown': '{count} ervan kreeg de laatste {days} dagen al een herinnering.',
'reminders.confirm_cooldown_plural': '{count} ervan kregen de laatste {days} dagen al een herinnering.',
'reminders.recent': 'Recent herinnerd',
'reminders.person_history': 'Herinneringen voor {name}',
'reminders.person_history_desc': '{count} herinnering verstuurd',
'reminders.person_history_desc_plural': '{count} herinneringen verstuurd',
},
};