Files
MOPC-Portal/src/components/admin/logistics/lunch-recap-actions.tsx
Matt 3f25ba112b fix(lunch): reminder filter, recap failure surfacing, manual send-reminders
- 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>
2026-06-04 16:24:01 +02:00

195 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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&apos;t picked a lunch dish yet. You can do this multiple
times it won&apos;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>
)
}