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
@@ -0,0 +1,14 @@
import { IsArray, IsOptional, IsString, ArrayMaxSize } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SendRemindersDto {
@ApiPropertyOptional({
type: [String],
description: 'Tabs to remind, including ones still in their cooldown. Omit to remind every eligible customer.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
@IsString({ each: true })
tabIds?: string[];
}
+10 -8
View File
@@ -1,10 +1,11 @@
import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Param, Body, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard'; import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RemindersService } from './reminders.service'; import { RemindersService } from './reminders.service';
import { SendRemindersDto } from './dto/send-reminders.dto';
@ApiTags('Reminders') @ApiTags('Reminders')
@ApiBearerAuth() @ApiBearerAuth()
@@ -14,8 +15,8 @@ export class RemindersController {
constructor(private service: RemindersService) {} constructor(private service: RemindersService) {}
@Get('candidates') @Get('candidates')
@ApiOperation({ summary: 'List tabs eligible for payment reminders' }) @ApiOperation({ summary: 'List customers with an unpaid tab and their reminder history' })
@ApiResponse({ status: 200, description: 'List of eligible tabs with customer email and amount' }) @ApiResponse({ status: 200, description: 'Unpaid tabs with email, amount, reminder count and eligibility' })
async listCandidates(@Param('venueId') venueId: string) { async listCandidates(@Param('venueId') venueId: string) {
return this.service.listCandidates(venueId); return this.service.listCandidates(venueId);
} }
@@ -27,15 +28,16 @@ export class RemindersController {
@ApiResponse({ status: 201, description: 'Reminders sent' }) @ApiResponse({ status: 201, description: 'Reminders sent' })
@ApiResponse({ status: 400, description: 'Resend not configured' }) @ApiResponse({ status: 400, description: 'Resend not configured' })
@ApiResponse({ status: 409, description: 'Reminders are already being sent' }) @ApiResponse({ status: 409, description: 'Reminders are already being sent' })
async send(@Param('venueId') venueId: string) { async send(@Param('venueId') venueId: string, @Body() dto: SendRemindersDto) {
return this.service.sendReminders(venueId); return this.service.sendReminders(venueId, dto.tabIds);
} }
@Get() @Get()
@ApiOperation({ summary: 'Get reminder history' }) @ApiOperation({ summary: 'Get reminder history' })
@ApiQuery({ name: 'customerName', required: false, description: 'Only reminders sent to this customer' })
@ApiResponse({ status: 200, description: 'List of previously sent reminders' }) @ApiResponse({ status: 200, description: 'List of previously sent reminders' })
async history(@Param('venueId') venueId: string) { async history(@Param('venueId') venueId: string, @Query('customerName') customerName?: string) {
return this.service.history(venueId); return this.service.history(venueId, customerName);
} }
@Post('test/:plannedTabId') @Post('test/:plannedTabId')
@@ -2,7 +2,9 @@ import { ConflictException } from '@nestjs/common';
import { EmailTemplatesService } from '../email/email-templates.service'; import { EmailTemplatesService } from '../email/email-templates.service';
import { RemindersService } from './reminders.service'; import { RemindersService } from './reminders.service';
function createService(items: unknown, { plannedName = 'Bram' } = {}) { type SentRow = { customerName: string; _count: { _all: number }; _max: { sentAt: Date | null } };
function createService(items: unknown, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) {
const prisma = { const prisma = {
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) }, tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
openTabSnapshot: { openTabSnapshot: {
@@ -11,7 +13,7 @@ function createService(items: unknown, { plannedName = 'Bram' } = {}) {
]), ]),
}, },
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) }, plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) },
tabReminder: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn() }, tabReminder: { groupBy: jest.fn().mockResolvedValue(sent), createMany: jest.fn() },
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) }, venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
}; };
const service = new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService()); const service = new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
@@ -105,9 +107,51 @@ describe('RemindersService.sendReminders', () => {
it('lets database errors surface instead of re-sending to everyone', async () => { it('lets database errors surface instead of re-sending to everyone', async () => {
const { service, prisma } = createService(null); const { service, prisma } = createService(null);
prisma.tabReminder.findMany.mockRejectedValue(new Error('db down')); prisma.tabReminder.groupBy.mockRejectedValue(new Error('db down'));
await expect(service.sendReminders('v1')).rejects.toThrow('db down'); await expect(service.sendReminders('v1')).rejects.toThrow('db down');
expect(requests).toHaveLength(0); expect(requests).toHaveLength(0);
}); });
it('skips customers in their cooldown unless they are picked by hand', async () => {
const daysAgo = (d: number) => new Date(Date.now() - d * 24 * 60 * 60 * 1000);
const { service } = createService(null, {
sent: [{ customerName: 'bram', _count: { _all: 2 }, _max: { sentAt: daysAgo(1) } }],
});
const [candidate] = (await service.listCandidates('v1')).candidates;
expect(candidate).toMatchObject({ eligible: false, reminderCount: 2 });
await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 0 });
await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 1 });
});
it('only sends to the picked tabs', async () => {
const { service } = createService(null);
await expect(service.sendReminders('v1', ['other'])).resolves.toMatchObject({ sent: 0, total: 0 });
expect(requests).toHaveLength(0);
});
it('lists customers without an email as not eligible', async () => {
const { service, prisma } = createService(null);
prisma.plannedTab.findMany.mockResolvedValue([]);
const { candidates } = await service.listCandidates('v1');
expect(candidates).toEqual([expect.objectContaining({ email: null, eligible: false, reminderCount: 0 })]);
await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 0 });
});
it('uses a new idempotency key for a deliberate repeat', async () => {
const { service, prisma } = createService(null);
await service.sendReminders('v1', ['t1']);
prisma.tabReminder.groupBy.mockResolvedValue([
{ customerName: 'Bram', _count: { _all: 1 }, _max: { sentAt: new Date() } },
]);
await service.sendReminders('v1', ['t1']);
expect(requests[0].headers['Idempotency-Key']).not.toBe(requests[1].headers['Idempotency-Key']);
});
}); });
+71 -25
View File
@@ -5,9 +5,10 @@ import { RealtimeService } from '../realtime/realtime.service';
import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service'; import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service';
import { customerNameKey } from '../planned-tabs/customer-name'; import { customerNameKey } from '../planned-tabs/customer-name';
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again // Tabs stay open per customer (closing only settles them), so a customer is eligible again
// once this many days have passed since their last reminder. // once this many days have passed since their last reminder. Admins can still pick them by hand.
const REMINDER_COOLDOWN_DAYS = 7; export const REMINDER_COOLDOWN_DAYS = 7;
const COOLDOWN_MS = REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
// Resend accepts at most 100 emails per batch request // Resend accepts at most 100 emails per batch request
const BATCH_SIZE = 100; const BATCH_SIZE = 100;
@@ -15,12 +16,21 @@ const BATCH_SIZE = 100;
type Candidate = { type Candidate = {
tabId: string; tabId: string;
customerName: string; customerName: string;
email: string; // Null when the planned customer has no email address: shown, but can't be reminded
email: string | null;
amountInCents: number; amountInCents: number;
itemCount: number; itemCount: number;
items: TabLine[] | null; items: TabLine[] | null;
reminderCount: number;
lastReminderAt: Date | null;
// When the cooldown ends; null when they have never been reminded
nextEligibleAt: Date | null;
// Has an email and is not within the cooldown: selected by default
eligible: boolean;
}; };
type Recipient = Candidate & { email: string };
@Injectable() @Injectable()
export class RemindersService { export class RemindersService {
private resendApiKey: string; private resendApiKey: string;
@@ -36,7 +46,8 @@ export class RemindersService {
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>'; this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
} }
async listCandidates(venueId: string): Promise<{ candidates: Candidate[] }> { /** Every customer with an unpaid open tab, with their reminder track record. */
async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> {
const ownerships = await this.prisma.tabOwnership.findMany({ const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId }, where: { venueId },
}); });
@@ -54,18 +65,32 @@ export class RemindersService {
}); });
const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!])); const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!]));
const alreadySent = await this.prisma.tabReminder.findMany({ // Reminders are tracked per customer: names differing only in case or spacing are one person
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } }, const sentPerName = await this.prisma.tabReminder.groupBy({
select: { tabId: true }, by: ['customerName'],
where: { venueId },
_count: { _all: true },
_max: { sentAt: true },
}); });
const sentTabIds = new Set(alreadySent.map((r) => r.tabId)); const sentByKey = new Map<string, { count: number; last: Date | null }>();
for (const row of sentPerName) {
const key = customerNameKey(row.customerName);
const prev = sentByKey.get(key) ?? { count: 0, last: null };
const last = row._max.sentAt;
sentByKey.set(key, {
count: prev.count + row._count._all,
last: !prev.last || (last && last > prev.last) ? last : prev.last,
});
}
const now = Date.now();
const candidates: Candidate[] = []; const candidates: Candidate[] = [];
for (const snap of snapshots) { for (const snap of snapshots) {
if (snap.totalInCents <= 0) continue; if (snap.totalInCents <= 0) continue;
if (sentTabIds.has(snap.tabId)) continue; const key = customerNameKey(snap.customerName);
const email = emailByKey.get(customerNameKey(snap.customerName)); const email = emailByKey.get(key) ?? null;
if (!email) continue; const sent = sentByKey.get(key);
const nextEligibleAt = sent?.last ? new Date(sent.last.getTime() + COOLDOWN_MS) : null;
candidates.push({ candidates.push({
tabId: snap.tabId, tabId: snap.tabId,
customerName: snap.customerName, customerName: snap.customerName,
@@ -74,13 +99,21 @@ export class RemindersService {
itemCount: snap.itemCount, itemCount: snap.itemCount,
// Null when the terminal's app version doesn't report lines // Null when the terminal's app version doesn't report lines
items: (snap.items as TabLine[] | null) ?? null, items: (snap.items as TabLine[] | null) ?? null,
reminderCount: sent?.count ?? 0,
lastReminderAt: sent?.last ?? null,
nextEligibleAt,
eligible: email !== null && (!nextEligibleAt || nextEligibleAt.getTime() <= now),
}); });
} }
return { candidates }; return { candidates, cooldownDays: REMINDER_COOLDOWN_DAYS };
} }
async sendReminders(venueId: string) { /**
* Sends to the given tabs, eligible or not (an admin's explicit choice overrides the cooldown),
* or to every eligible customer when no tabs are given.
*/
async sendReminders(venueId: string, tabIds?: string[]) {
if (!this.resendApiKey) { if (!this.resendApiKey) {
throw new BadRequestException({ throw new BadRequestException({
code: 'resend_not_configured', code: 'resend_not_configured',
@@ -95,14 +128,18 @@ export class RemindersService {
this.sendingVenues.add(venueId); this.sendingVenues.add(venueId);
try { try {
return await this.sendToCandidates(venueId); return await this.sendToCandidates(venueId, tabIds);
} finally { } finally {
this.sendingVenues.delete(venueId); this.sendingVenues.delete(venueId);
} }
} }
private async sendToCandidates(venueId: string) { private async sendToCandidates(venueId: string, tabIds?: string[]) {
const { candidates } = await this.listCandidates(venueId); const { candidates: all } = await this.listCandidates(venueId);
const selected = tabIds ? new Set(tabIds) : null;
const candidates = all.filter((c): c is Recipient =>
c.email !== null && (selected ? selected.has(c.tabId) : c.eligible),
);
if (candidates.length === 0) { if (candidates.length === 0) {
return { sent: 0, total: 0 }; return { sent: 0, total: 0 };
@@ -112,7 +149,7 @@ export class RemindersService {
let sent = 0; let sent = 0;
const errors: { customerName: string; error: string }[] = []; const errors: { customerName: string; error: string }[] = [];
const rendered: { candidate: Candidate; email: RenderedEmail }[] = []; const rendered: { candidate: Recipient; email: RenderedEmail }[] = [];
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) }); rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) });
@@ -123,7 +160,11 @@ export class RemindersService {
for (let i = 0; i < rendered.length; i += BATCH_SIZE) { for (let i = 0; i < rendered.length; i += BATCH_SIZE) {
const batch = rendered.slice(i, i + BATCH_SIZE); const batch = rendered.slice(i, i + BATCH_SIZE);
const error = await this.sendBatch(batch.map(({ candidate, email }) => ({ to: candidate.email, email }))); const error = await this.sendBatch(batch.map(({ candidate, email }) => ({
to: candidate.email,
email,
reminderCount: candidate.reminderCount,
})));
if (error) { if (error) {
for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error }); for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error });
continue; continue;
@@ -146,9 +187,12 @@ export class RemindersService {
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined }; return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
} }
async history(venueId: string) { async history(venueId: string, customerName?: string) {
const reminders = await this.prisma.tabReminder.findMany({ const reminders = await this.prisma.tabReminder.findMany({
where: { venueId }, where: {
venueId,
...(customerName ? { customerName: { equals: customerName.trim(), mode: 'insensitive' as const } } : {}),
},
orderBy: { sentAt: 'desc' }, orderBy: { sentAt: 'desc' },
take: 100, take: 100,
}); });
@@ -200,7 +244,7 @@ export class RemindersService {
return { ok: true, email: tab.email, customerName: tab.customerName }; return { ok: true, email: tab.email, customerName: tab.customerName };
} }
private renderReminder(candidate: Candidate, venueName: string): Promise<RenderedEmail> { private renderReminder(candidate: Recipient, venueName: string): Promise<RenderedEmail> {
return this.emailTemplates.render('payment-reminder', { return this.emailTemplates.render('payment-reminder', {
customerName: candidate.customerName, customerName: candidate.customerName,
venueName, venueName,
@@ -217,10 +261,12 @@ export class RemindersService {
} }
/** One Resend batch request; returns Resend's error text, or null when sent. */ /** One Resend batch request; returns Resend's error text, or null when sent. */
private sendBatch(messages: { to: string; email: RenderedEmail }[]): Promise<string | null> { private sendBatch(messages: { to: string; email: RenderedEmail; reminderCount: number }[]): Promise<string | null> {
const body = messages.map(({ to, email }) => ({ from: this.fromEmail, to, ...email })); const body = messages.map(({ to, email }) => ({ from: this.fromEmail, to, ...email }));
// Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice // Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice.
const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'); // The reminder counts are part of the key: a deliberate repeat after a successful send is a new request.
const counts = messages.map((m) => m.reminderCount);
const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify([body, counts])).digest('hex');
return this.resend('/emails/batch', body, idempotencyKey); return this.resend('/emails/batch', body, idempotencyKey);
} }
@@ -1,28 +1,40 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { remindersApi } from '@/lib/api/client'; import { remindersApi, type ReminderCandidate } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { EmptyState } from '@/components/empty-state'; import { EmptyState } from '@/components/empty-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils'; import { formatCents } from '@/lib/utils';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session'; 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() { export default function RemindersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t, te, relativeTime } = useLang(); const { t, tn, te, relativeTime, dateTime } = useLang();
const { venueId, role } = useSession(); const { venueId, role } = useSession();
const isAdmin = role === 'admin';
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = 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({ const candidatesQuery = useQuery({
queryKey: ['reminder-candidates', venueId], queryKey: ['reminder-candidates', venueId],
@@ -35,10 +47,56 @@ export default function RemindersPage() {
enabled: showHistory, 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({ const sendMutation = useMutation({
mutationFn: () => remindersApi.send(venueId), mutationFn: (tabIds: string[]) => remindersApi.send(venueId, tabIds),
onSuccess: (result) => { onSuccess: (result) => {
setSendDialogOpen(false); setSendDialogOpen(false);
setPicked(null);
if (result.errors && result.errors.length > 0) { if (result.errors && result.errors.length > 0) {
const firstError = result.errors[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' }); 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 reminders = historyQuery.data?.reminders ?? [];
const personReminders = personHistoryQuery.data?.reminders ?? [];
return ( return (
<div> <div>
@@ -68,7 +133,7 @@ export default function RemindersPage() {
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setShowHistory(!showHistory); if (!showHistory) queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); }}> <Button variant="outline" size="sm" onClick={() => { setShowHistory(!showHistory); if (!showHistory) queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); }}>
<History className="h-4 w-4 mr-2" /> <History className="h-4 w-4 mr-2" />
{showHistory ? t('reminders.candidates') : t('reminders.history')} {showHistory ? t('reminders.customers') : t('reminders.history')}
</Button> </Button>
</div> </div>
} }
@@ -97,10 +162,14 @@ export default function RemindersPage() {
<TableBody> <TableBody>
{reminders.map((r) => ( {reminders.map((r) => (
<TableRow key={r.id}> <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>{r.email}</TableCell>
<TableCell>{formatCents(r.amountInCents)}</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> <TableCell><Badge variant={r.status === 'sent' ? 'success' : 'secondary'}>{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
</TableRow> </TableRow>
))} ))}
@@ -112,11 +181,12 @@ export default function RemindersPage() {
</Card> </Card>
) : ( ) : (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3 space-y-1">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
{t('reminders.candidates')} {t('reminders.customers')}
</CardTitle> </CardTitle>
<p className="text-xs text-muted-foreground">{t('reminders.customers_desc', { days: cooldownDays })}</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{candidatesQuery.isLoading ? ( {candidatesQuery.isLoading ? (
@@ -127,34 +197,120 @@ export default function RemindersPage() {
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} /> <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> <Table>
<TableHeader> <TableHeader>
<TableRow> <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.customer')}</TableHead>
<TableHead>{t('reminders.email')}</TableHead> <TableHead>{t('reminders.email')}</TableHead>
<TableHead>{t('reminders.amount')}</TableHead> <TableHead className="text-right">{t('reminders.amount')}</TableHead>
<TableHead>{t('tabs.items')}</TableHead> <TableHead className="text-center">{t('reminders.count')}</TableHead>
<TableHead>{t('reminders.last_sent')}</TableHead>
<TableHead>{t('reminders.status')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{candidates.map((c) => ( {visible.map((c) => {
<TableRow key={c.tabId}> 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="font-medium">{c.customerName}</TableCell>
<TableCell>{c.email}</TableCell> <TableCell className={c.email ? undefined : 'text-muted-foreground'}>{c.email ?? '—'}</TableCell>
<TableCell>{formatCents(c.amountInCents)}</TableCell> <TableCell className="text-right tabular-nums">{formatCents(c.amountInCents)}</TableCell>
<TableCell>{c.itemCount}</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> </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> </TableBody>
</Table> </Table>
</div> </div>
{role === 'admin' ? ( {isAdmin ? (
<Button onClick={() => setSendDialogOpen(true)}> <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" /> <Send className="h-4 w-4 mr-2" />
{t('reminders.send')} ({candidates.length}) {t('reminders.send')} ({selected.size})
</Button> </Button>
</div>
) : ( ) : (
<p className="text-sm text-muted-foreground">{t('reminders.admin_only')}</p> <p className="text-sm text-muted-foreground">{t('reminders.admin_only')}</p>
)} )}
@@ -168,20 +324,24 @@ export default function RemindersPage() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('reminders.send')}</DialogTitle> <DialogTitle>{t('reminders.send')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('reminders.candidates_desc')}</DialogDescription>
{t('reminders.candidates_desc')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4"> <div className="py-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('reminders.confirm', { count: candidates.length })} {t('reminders.confirm', { count: selectedCandidates.length })}
</p> </p>
<ul className="mt-3 space-y-1"> {selectedInCooldown > 0 && (
{candidates.map((c) => ( <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"> <li key={c.tabId} className="text-sm flex items-center gap-2">
<span className="font-medium">{c.customerName}</span> <span className="font-medium">{c.customerName}</span>
<span className="text-muted-foreground">({c.email})</span> <span className="text-muted-foreground truncate">({c.email})</span>
<span className="ml-auto">{formatCents(c.amountInCents)}</span> {!c.eligible && <Badge variant="warning">{t('reminders.recent')}</Badge>}
<span className="ml-auto tabular-nums">{formatCents(c.amountInCents)}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -190,7 +350,10 @@ export default function RemindersPage() {
<Button variant="outline" onClick={() => setSendDialogOpen(false)} disabled={sendMutation.isPending}> <Button variant="outline" onClick={() => setSendDialogOpen(false)} disabled={sendMutation.isPending}>
{t('common.cancel')} {t('common.cancel')}
</Button> </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 ? ( {sendMutation.isPending ? (
<>{t('reminders.sending')}</> <>{t('reminders.sending')}</>
) : ( ) : (
@@ -200,6 +363,33 @@ export default function RemindersPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
} }
+17 -6
View File
@@ -70,10 +70,16 @@ export interface PlannedTab {
export interface ReminderCandidate { export interface ReminderCandidate {
tabId: string; tabId: string;
customerName: string; customerName: string;
email: string; /** Null when the customer has no email address on file */
email: string | null;
amountInCents: number; amountInCents: number;
itemCount: number; itemCount: number;
items: TabLine[] | null; items: TabLine[] | null;
reminderCount: number;
lastReminderAt: string | null;
nextEligibleAt: string | null;
/** Has an email and is past the cooldown */
eligible: boolean;
} }
export interface Reminder { export interface Reminder {
@@ -274,11 +280,16 @@ export const plannedTabsApi = {
// Reminders API // Reminders API
export const remindersApi = { export const remindersApi = {
candidates: (venueId: string) => candidates: (venueId: string) =>
api<{ candidates: ReminderCandidate[] }>(`/venues/${venueId}/reminders/candidates`), api<{ candidates: ReminderCandidate[]; cooldownDays: number }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string) => send: (venueId: string, tabIds: string[]) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }), api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, {
history: (venueId: string) => method: 'POST',
api<{ reminders: Reminder[] }>(`/venues/${venueId}/reminders`), body: JSON.stringify({ tabIds }),
}),
history: (venueId: string, customerName?: string) =>
api<{ reminders: Reminder[] }>(
`/venues/${venueId}/reminders${customerName ? `?customerName=${encodeURIComponent(customerName)}` : ''}`,
),
sendTest: (venueId: string, plannedTabId: string) => sendTest: (venueId: string, plannedTabId: string) =>
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }), 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.desc': 'Send payment reminders to customers with an unpaid tab',
'reminders.candidates': 'Eligible for a reminder', '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.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': 'No unpaid tabs',
'reminders.no_candidates_desc': 'Make sure planned customers have an email address.', 'reminders.no_candidates_desc': 'Customers with an open balance show up here.',
'reminders.send': 'Send Reminders', 'reminders.send': 'Send Reminders',
'reminders.sending': 'Sending...', 'reminders.sending': 'Sending...',
'reminders.admin_only': 'Only admins can send reminders.', '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.sent_at': 'Sent At',
'reminders.status': 'Status', 'reminders.status': 'Status',
'reminders.status_sent': 'Sent', '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: { nl: {
'nav.overview': 'Overzicht', '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.desc': 'Stuur betalingsherinneringen naar klanten met een openstaande poef',
'reminders.candidates': 'Komen in aanmerking', '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.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': 'Niemand heeft een openstaande poef',
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.', 'reminders.no_candidates_desc': 'Klanten met een openstaand bedrag verschijnen hier.',
'reminders.send': 'Herinneringen versturen', 'reminders.send': 'Herinneringen versturen',
'reminders.sending': 'Bezig met versturen...', 'reminders.sending': 'Bezig met versturen...',
'reminders.admin_only': 'Alleen beheerders kunnen herinneringen 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.sent_at': 'Verstuurd op',
'reminders.status': 'Status', 'reminders.status': 'Status',
'reminders.status_sent': 'Verstuurd', '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',
}, },
}; };
+5 -3
View File
@@ -68,9 +68,11 @@ Names match case-insensitively and keep their existing spelling; `replace` also
`DELETE /venues/:venueId/planned-tabs/:id` - Remove a customer (admin) `DELETE /venues/:venueId/planned-tabs/:id` - Remove a customer (admin)
### Reminders ### Reminders
`GET /venues/:venueId/reminders/candidates` - Customers with an unpaid balance and email, not reminded in the last 7 days `GET /venues/:venueId/reminders/candidates` - Customers with an unpaid balance, with their reminder count, last reminder
`POST /venues/:venueId/reminders/send` - Send payment reminders and whether they are eligible (have an email and weren't reminded in the last 7 days)
`GET /venues/:venueId/reminders` - Reminder history `POST /venues/:venueId/reminders/send` - Send payment reminders (admin). Body `{ "tabIds": [...] }` picks the
recipients, including customers still in their cooldown; without it every eligible customer is reminded
`GET /venues/:venueId/reminders?customerName=` - Reminder history, optionally for one customer
`POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin) `POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin)
### Commands ### Commands