@@ -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[];
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <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({
|
||||
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<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[] = [];
|
||||
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<RenderedEmail> {
|
||||
private renderReminder(candidate: Recipient, venueName: string): Promise<RenderedEmail> {
|
||||
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<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 }));
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user