- Extract selectUnpickedAttendees helper with OR filter (is null OR pickedAt null) to fix cron missing attendees with no MemberLunchPick row at all - Update cron route to use the helper - sendRecap now throws TRPCError on email failure instead of silently stamping success - Add lunch.sendReminders adminProcedure for manual on-demand reminder sends - Add "Send reminders now" AlertDialog button to LunchRecapActions - Tests: lunch-reminder-filter.test.ts (2 new), all 5 lunch test files pass (40 tests) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { trpc } from '@/lib/trpc/client'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
AlertDialogTrigger,
|
||
} from '@/components/ui/alert-dialog'
|
||
import { Send, Eye, Bell } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
|
||
export function LunchRecapActions({
|
||
programId,
|
||
lunchEventId,
|
||
recapSentAt,
|
||
extraRecipientCount,
|
||
}: {
|
||
programId: string
|
||
lunchEventId: string
|
||
recapSentAt: Date | null
|
||
extraRecipientCount: number
|
||
}) {
|
||
const utils = trpc.useUtils()
|
||
const [previewOpen, setPreviewOpen] = useState(false)
|
||
|
||
const send = trpc.lunch.sendRecap.useMutation({
|
||
onSuccess: () => {
|
||
utils.lunch.getEvent.invalidate({ programId })
|
||
toast.success('Recap sent')
|
||
},
|
||
onError: (e) => {
|
||
if (e.data?.code === 'PRECONDITION_FAILED') {
|
||
if (
|
||
confirm(
|
||
"You've already sent a recap. Send updated version to all recipients?",
|
||
)
|
||
) {
|
||
send.mutate({ programId, forceUpdate: true })
|
||
}
|
||
} else {
|
||
toast.error(e.message)
|
||
}
|
||
},
|
||
})
|
||
|
||
const sendReminders = trpc.lunch.sendReminders.useMutation({
|
||
onSuccess: (data) => {
|
||
toast.success(`Reminders sent to ${data.sent} attendee${data.sent === 1 ? '' : 's'}`)
|
||
},
|
||
onError: (e) => {
|
||
toast.error(`Failed to send reminders: ${e.message}`)
|
||
},
|
||
})
|
||
|
||
const { data: preview, isLoading: loadingPreview } =
|
||
trpc.lunch.getRecapPreview.useQuery(
|
||
{ programId },
|
||
{ enabled: previewOpen },
|
||
)
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Recap</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={() => setPreviewOpen(true)}>
|
||
<Eye className="mr-2 h-4 w-4" /> Preview recap
|
||
</Button>
|
||
<Button
|
||
onClick={() => send.mutate({ programId })}
|
||
disabled={send.isPending}
|
||
>
|
||
<Send className="mr-2 h-4 w-4" /> Send recap now
|
||
</Button>
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<Button variant="outline" disabled={sendReminders.isPending}>
|
||
<Bell className="mr-2 h-4 w-4" /> Send reminders now
|
||
</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Send lunch pick reminders?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This will send a reminder email to all confirmed attendees who
|
||
haven't picked a lunch dish yet. You can do this multiple
|
||
times — it won't affect the automatic reminder window.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={() => sendReminders.mutate({ lunchEventId })}
|
||
>
|
||
Send reminders
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
<p className="text-muted-foreground text-xs">
|
||
{recapSentAt
|
||
? `Last sent: ${new Date(recapSentAt).toLocaleString()}. Recipients: edition admins${extraRecipientCount > 0 ? ` + ${extraRecipientCount} extra` : ''}.`
|
||
: 'Recap has not been sent yet.'}
|
||
</p>
|
||
</CardContent>
|
||
|
||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||
<DialogContent className="max-w-3xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Recap preview</DialogTitle>
|
||
</DialogHeader>
|
||
{loadingPreview && (
|
||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||
)}
|
||
{preview && (
|
||
<div className="space-y-4 text-sm">
|
||
<p>
|
||
<strong>
|
||
{preview.summary.picked}/{preview.summary.total}
|
||
</strong>{' '}
|
||
picked
|
||
{preview.summary.missing > 0
|
||
? ` · ${preview.summary.missing} missing`
|
||
: ''}
|
||
</p>
|
||
{Object.keys(preview.dishCounts).length > 0 && (
|
||
<div>
|
||
<h4 className="font-medium">Dishes</h4>
|
||
<ul className="ml-4 list-disc">
|
||
{Object.entries(preview.dishCounts).map(([n, c]) => (
|
||
<li key={n}>
|
||
{c}× {n}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{Object.keys(preview.dietaryCounts).length > 0 && (
|
||
<div>
|
||
<h4 className="font-medium">Dietary tags</h4>
|
||
<ul className="ml-4 list-disc">
|
||
{Object.entries(preview.dietaryCounts).map(([n, c]) => (
|
||
<li key={n}>
|
||
{c}× {n.replace('_', ' ').toLowerCase()}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<h4 className="font-medium">Allergens</h4>
|
||
{Object.keys(preview.allergenCounts).length === 0 ? (
|
||
<p className="text-muted-foreground">None reported.</p>
|
||
) : (
|
||
<ul className="ml-4 list-disc">
|
||
{Object.entries(preview.allergenCounts).map(([n, c]) => (
|
||
<li key={n}>
|
||
{c}× {n.replace('_', ' ').toLowerCase()}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
|
||
Close
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Card>
|
||
)
|
||
}
|