diff --git a/apps/api/src/reminders/dto/send-reminders.dto.ts b/apps/api/src/reminders/dto/send-reminders.dto.ts new file mode 100644 index 0000000..6ab3e6a --- /dev/null +++ b/apps/api/src/reminders/dto/send-reminders.dto.ts @@ -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[]; +} diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts index 9e24c81..6e3a426 100644 --- a/apps/api/src/reminders/reminders.controller.ts +++ b/apps/api/src/reminders/reminders.controller.ts @@ -1,10 +1,11 @@ -import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { Controller, Get, Post, Param, Body, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { VenueAccessGuard } from '../common/guards/venue-access.guard'; import { RemindersService } from './reminders.service'; +import { SendRemindersDto } from './dto/send-reminders.dto'; @ApiTags('Reminders') @ApiBearerAuth() @@ -14,8 +15,8 @@ export class RemindersController { constructor(private service: RemindersService) {} @Get('candidates') - @ApiOperation({ summary: 'List tabs eligible for payment reminders' }) - @ApiResponse({ status: 200, description: 'List of eligible tabs with customer email and amount' }) + @ApiOperation({ summary: 'List customers with an unpaid tab and their reminder history' }) + @ApiResponse({ status: 200, description: 'Unpaid tabs with email, amount, reminder count and eligibility' }) async listCandidates(@Param('venueId') venueId: string) { return this.service.listCandidates(venueId); } @@ -27,15 +28,16 @@ export class RemindersController { @ApiResponse({ status: 201, description: 'Reminders sent' }) @ApiResponse({ status: 400, description: 'Resend not configured' }) @ApiResponse({ status: 409, description: 'Reminders are already being sent' }) - async send(@Param('venueId') venueId: string) { - return this.service.sendReminders(venueId); + async send(@Param('venueId') venueId: string, @Body() dto: SendRemindersDto) { + return this.service.sendReminders(venueId, dto.tabIds); } @Get() @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' }) - async history(@Param('venueId') venueId: string) { - return this.service.history(venueId); + async history(@Param('venueId') venueId: string, @Query('customerName') customerName?: string) { + return this.service.history(venueId, customerName); } @Post('test/:plannedTabId') diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts index af514f4..72cae55 100644 --- a/apps/api/src/reminders/reminders.service.spec.ts +++ b/apps/api/src/reminders/reminders.service.spec.ts @@ -2,7 +2,9 @@ import { ConflictException } from '@nestjs/common'; import { EmailTemplatesService } from '../email/email-templates.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 = { tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) }, openTabSnapshot: { @@ -11,7 +13,7 @@ function createService(items: unknown, { plannedName = 'Bram' } = {}) { ]), }, 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' }) }, }; 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 () => { 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'); 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']); + }); }); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index 7f2a0c0..8ac4c35 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -5,9 +5,10 @@ import { RealtimeService } from '../realtime/realtime.service'; import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service'; import { customerNameKey } from '../planned-tabs/customer-name'; -// Tabs stay open per customer (closing only settles them), so a customer can be reminded again -// once this many days have passed since their last reminder. -const REMINDER_COOLDOWN_DAYS = 7; +// 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. Admins can still pick them by hand. +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 const BATCH_SIZE = 100; @@ -15,12 +16,21 @@ const BATCH_SIZE = 100; type Candidate = { tabId: 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; itemCount: number; 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() export class RemindersService { private resendApiKey: string; @@ -36,7 +46,8 @@ export class RemindersService { this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap '; } - 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({ where: { venueId }, }); @@ -54,18 +65,32 @@ export class RemindersService { }); const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!])); - const alreadySent = await this.prisma.tabReminder.findMany({ - where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } }, - select: { tabId: true }, + // Reminders are tracked per customer: names differing only in case or spacing are one person + const sentPerName = await this.prisma.tabReminder.groupBy({ + by: ['customerName'], + where: { venueId }, + _count: { _all: true }, + _max: { sentAt: true }, }); - const sentTabIds = new Set(alreadySent.map((r) => r.tabId)); + const sentByKey = new Map(); + 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[] = []; for (const snap of snapshots) { if (snap.totalInCents <= 0) continue; - if (sentTabIds.has(snap.tabId)) continue; - const email = emailByKey.get(customerNameKey(snap.customerName)); - if (!email) continue; + const key = customerNameKey(snap.customerName); + const email = emailByKey.get(key) ?? null; + const sent = sentByKey.get(key); + const nextEligibleAt = sent?.last ? new Date(sent.last.getTime() + COOLDOWN_MS) : null; candidates.push({ tabId: snap.tabId, customerName: snap.customerName, @@ -74,13 +99,21 @@ export class RemindersService { itemCount: snap.itemCount, // Null when the terminal's app version doesn't report lines 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) { throw new BadRequestException({ code: 'resend_not_configured', @@ -95,14 +128,18 @@ export class RemindersService { this.sendingVenues.add(venueId); try { - return await this.sendToCandidates(venueId); + return await this.sendToCandidates(venueId, tabIds); } finally { this.sendingVenues.delete(venueId); } } - private async sendToCandidates(venueId: string) { - const { candidates } = await this.listCandidates(venueId); + private async sendToCandidates(venueId: string, tabIds?: string[]) { + 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) { return { sent: 0, total: 0 }; @@ -112,7 +149,7 @@ export class RemindersService { let sent = 0; const errors: { customerName: string; error: string }[] = []; - const rendered: { candidate: Candidate; email: RenderedEmail }[] = []; + const rendered: { candidate: Recipient; email: RenderedEmail }[] = []; for (const candidate of candidates) { try { 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) { 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) { for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error }); continue; @@ -146,9 +187,12 @@ export class RemindersService { 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({ - where: { venueId }, + where: { + venueId, + ...(customerName ? { customerName: { equals: customerName.trim(), mode: 'insensitive' as const } } : {}), + }, orderBy: { sentAt: 'desc' }, take: 100, }); @@ -200,7 +244,7 @@ export class RemindersService { return { ok: true, email: tab.email, customerName: tab.customerName }; } - private renderReminder(candidate: Candidate, venueName: string): Promise { + private renderReminder(candidate: Recipient, venueName: string): Promise { return this.emailTemplates.render('payment-reminder', { customerName: candidate.customerName, venueName, @@ -217,10 +261,12 @@ export class RemindersService { } /** One Resend batch request; returns Resend's error text, or null when sent. */ - private sendBatch(messages: { to: string; email: RenderedEmail }[]): Promise { + private sendBatch(messages: { to: string; email: RenderedEmail; reminderCount: number }[]): Promise { 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 - const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'); + // Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice. + // 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); } diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx index efc5662..0327062 100644 --- a/apps/dashboard/src/app/dashboard/reminders/page.tsx +++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx @@ -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 | null>(null); + const [historyFor, setHistoryFor] = useState(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) => 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 {t('reminders.state_no_email')}; + if (c.eligible) return {t('reminders.state_eligible')}; + const days = Math.max(1, Math.ceil((new Date(c.nextEligibleAt!).getTime() - Date.now()) / DAY_MS)); + return {tn('reminders.state_cooldown', days)}; + }; + const reminders = historyQuery.data?.reminders ?? []; + const personReminders = personHistoryQuery.data?.reminders ?? []; return (
@@ -68,7 +133,7 @@ export default function RemindersPage() {
} @@ -97,10 +162,14 @@ export default function RemindersPage() { {reminders.map((r) => ( - {r.customerName} + + + {r.email} {formatCents(r.amountInCents)} - {relativeTime(r.sentAt)} + {relativeTime(r.sentAt)} {r.status === 'sent' ? t('reminders.status_sent') : r.status} ))} @@ -112,11 +181,12 @@ export default function RemindersPage() { ) : ( - + - {t('reminders.candidates')} + {t('reminders.customers')} +

{t('reminders.customers_desc', { days: cooldownDays })}

{candidatesQuery.isLoading ? ( @@ -127,34 +197,120 @@ export default function RemindersPage() { ) : ( <> -
+
+
+ + setSearch(e.target.value)} + placeholder={t('reminders.search')} + className="pl-8" + /> +
+ {isAdmin && ( +
+ + + +
+ )} +
+ +
+ {isAdmin && ( + + + + )} {t('reminders.customer')} {t('reminders.email')} - {t('reminders.amount')} - {t('tabs.items')} + {t('reminders.amount')} + {t('reminders.count')} + {t('reminders.last_sent')} + {t('reminders.status')} - {candidates.map((c) => ( - - {c.customerName} - {c.email} - {formatCents(c.amountInCents)} - {c.itemCount} + {visible.map((c) => { + const isSelected = selected.has(c.tabId); + return ( + toggle(c.tabId) : undefined} + > + {isAdmin && ( + e.stopPropagation()}> + toggle(c.tabId)} + /> + + )} + {c.customerName} + {c.email ?? '—'} + {formatCents(c.amountInCents)} + e.stopPropagation()}> + {c.reminderCount > 0 ? ( + + ) : ( + 0 + )} + + + {c.lastReminderAt ? relativeTime(c.lastReminderAt) : t('reminders.never')} + + {statusBadge(c)} + + ); + })} + {visible.length === 0 && ( + + + {t('reminders.no_matches')} + - ))} + )}
- {role === 'admin' ? ( - + {isAdmin ? ( +
+

+ {tn('reminders.selected', selected.size, { amount: formatCents(selectedTotal) })} +

+ +
) : (

{t('reminders.admin_only')}

)} @@ -168,20 +324,24 @@ export default function RemindersPage() { {t('reminders.send')} - - {t('reminders.candidates_desc')} - + {t('reminders.candidates_desc')}

- {t('reminders.confirm', { count: candidates.length })} + {t('reminders.confirm', { count: selectedCandidates.length })}

-
    - {candidates.map((c) => ( + {selectedInCooldown > 0 && ( +

    + {tn('reminders.confirm_cooldown', selectedInCooldown, { days: cooldownDays })} +

    + )} +
      + {selectedCandidates.map((c) => (
    • {c.customerName} - ({c.email}) - {formatCents(c.amountInCents)} + ({c.email}) + {!c.eligible && {t('reminders.recent')}} + {formatCents(c.amountInCents)}
    • ))}
    @@ -190,7 +350,10 @@ export default function RemindersPage() { - + + +
); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index 450cdba..cbd2532 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -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' }), }; diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 8f3ba99..f163d34 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -220,8 +220,8 @@ const translations: Record> = { '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> = { '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> = { '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> = { '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', }, }; diff --git a/docs/api.md b/docs/api.md index 37900c6..459644a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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) ### Reminders -`GET /venues/:venueId/reminders/candidates` - Customers with an unpaid balance and email, not reminded in the last 7 days -`POST /venues/:venueId/reminders/send` - Send payment reminders -`GET /venues/:venueId/reminders` - Reminder history +`GET /venues/:venueId/reminders/candidates` - Customers with an unpaid balance, with their reminder count, last reminder + and whether they are eligible (have an email and weren't reminded in the last 7 days) +`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) ### Commands