@@ -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