feat: lunch recap actions card with preview + send + resend confirm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 02:45:12 +02:00
parent bbfe2d8097
commit 618def6174
2 changed files with 153 additions and 1 deletions

View File

@@ -0,0 +1,147 @@
'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 { Send, Eye } from 'lucide-react'
import { toast } from 'sonner'
export function LunchRecapActions({
programId,
recapSentAt,
extraRecipientCount,
}: {
programId: 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 { 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>
</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>
)
}

View File

@@ -7,6 +7,7 @@ import { LunchEventConfig } from './lunch-event-config'
import { LunchDishes } from './lunch-dishes' import { LunchDishes } from './lunch-dishes'
import { LunchManifest } from './lunch-manifest' import { LunchManifest } from './lunch-manifest'
import { LunchExternals, type LunchExternalsHandle } from './lunch-externals' import { LunchExternals, type LunchExternalsHandle } from './lunch-externals'
import { LunchRecapActions } from './lunch-recap-actions'
export function LunchTab({ programId }: { programId: string }) { export function LunchTab({ programId }: { programId: string }) {
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId }) const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
@@ -29,7 +30,11 @@ export function LunchTab({ programId }: { programId: string }) {
programId={programId} programId={programId}
lunchEventId={event.id} lunchEventId={event.id}
/> />
{/* Recap actions card mounts in Task 18. */} <LunchRecapActions
programId={programId}
recapSentAt={event.recapSentAt}
extraRecipientCount={event.extraRecipients.length}
/>
</> </>
)} )}
</div> </div>