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 { 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user