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:
147
src/components/admin/logistics/lunch-recap-actions.tsx
Normal file
147
src/components/admin/logistics/lunch-recap-actions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { LunchEventConfig } from './lunch-event-config'
|
||||
import { LunchDishes } from './lunch-dishes'
|
||||
import { LunchManifest } from './lunch-manifest'
|
||||
import { LunchExternals, type LunchExternalsHandle } from './lunch-externals'
|
||||
import { LunchRecapActions } from './lunch-recap-actions'
|
||||
|
||||
export function LunchTab({ programId }: { programId: string }) {
|
||||
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
|
||||
@@ -29,7 +30,11 @@ export function LunchTab({ programId }: { programId: string }) {
|
||||
programId={programId}
|
||||
lunchEventId={event.id}
|
||||
/>
|
||||
{/* Recap actions card mounts in Task 18. */}
|
||||
<LunchRecapActions
|
||||
programId={programId}
|
||||
recapSentAt={event.recapSentAt}
|
||||
extraRecipientCount={event.extraRecipients.length}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user