feat: lunch event configuration card
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
246
src/components/admin/logistics/lunch-event-config.tsx
Normal file
246
src/components/admin/logistics/lunch-event-config.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { LunchEvent } from '@prisma/client'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function toLocalDateTimeInputValue(d: Date | null | undefined): string {
|
||||
if (!d) return ''
|
||||
// datetime-local expects "YYYY-MM-DDTHH:mm" in local time.
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function LunchEventConfig({
|
||||
programId,
|
||||
event,
|
||||
}: {
|
||||
programId: string
|
||||
event: LunchEvent
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const update = trpc.lunch.updateEvent.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.lunch.getEvent.invalidate({ programId })
|
||||
utils.lunch.getEventForMember.invalidate({ programId })
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
const [extraInput, setExtraInput] = useState('')
|
||||
|
||||
const eventAt = event.eventAt ? new Date(event.eventAt) : null
|
||||
const endAt = event.endAt ? new Date(event.endAt) : null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event configuration</CardTitle>
|
||||
<CardDescription>Per-edition lunch settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* enabled */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lunch-enabled">Enable lunch event</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When off, attendees see no banner or picker; admins still see this tab.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="lunch-enabled"
|
||||
checked={event.enabled}
|
||||
onCheckedChange={(v) => update.mutate({ programId, enabled: v })}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* eventAt */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-at">Event start</Label>
|
||||
<Input
|
||||
id="event-at"
|
||||
type="datetime-local"
|
||||
defaultValue={toLocalDateTimeInputValue(eventAt)}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value
|
||||
update.mutate({ programId, eventAt: v ? new Date(v) : null })
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* endAt */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="end-at">Event end (optional)</Label>
|
||||
<Input
|
||||
id="end-at"
|
||||
type="datetime-local"
|
||||
defaultValue={toLocalDateTimeInputValue(endAt)}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value
|
||||
update.mutate({ programId, endAt: v ? new Date(v) : null })
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* venue */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="venue">Venue (optional)</Label>
|
||||
<Input
|
||||
id="venue"
|
||||
defaultValue={event.venue ?? ''}
|
||||
placeholder="e.g. Hôtel Hermitage, Salle Belle Époque"
|
||||
onBlur={(e) =>
|
||||
update.mutate({ programId, venue: e.target.value || null })
|
||||
}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* notes */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="notes">Notes for attendees (optional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
defaultValue={event.notes ?? ''}
|
||||
placeholder="Wine pairings included. Vegetarian options at table 4."
|
||||
onBlur={(e) =>
|
||||
update.mutate({ programId, notes: e.target.value || null })
|
||||
}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* changeCutoffHours */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cutoff">Change cutoff (hours before event)</Label>
|
||||
<Input
|
||||
id="cutoff"
|
||||
type="number"
|
||||
min={0}
|
||||
max={720}
|
||||
defaultValue={event.changeCutoffHours}
|
||||
onBlur={(e) => {
|
||||
const n = Number(e.target.value)
|
||||
if (Number.isFinite(n) && n !== event.changeCutoffHours) {
|
||||
update.mutate({ programId, changeCutoffHours: n })
|
||||
}
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-[12rem]"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
After this many hours before the event, attendees and team leads can
|
||||
no longer change their picks. Admins always can.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* reminderHoursBeforeDeadline */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reminder">Reminder (hours before deadline)</Label>
|
||||
<Input
|
||||
id="reminder"
|
||||
type="number"
|
||||
min={0}
|
||||
max={720}
|
||||
defaultValue={event.reminderHoursBeforeDeadline ?? ''}
|
||||
placeholder="Leave blank for no reminder"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value
|
||||
update.mutate({
|
||||
programId,
|
||||
reminderHoursBeforeDeadline: v === '' ? null : Number(v),
|
||||
})
|
||||
}}
|
||||
disabled={update.isPending}
|
||||
className="max-w-[12rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* cronEnabled */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cron-enabled">Auto-send recap at deadline</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When on, the platform automatically emails the manifest when the
|
||||
change deadline passes.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="cron-enabled"
|
||||
checked={event.cronEnabled}
|
||||
onCheckedChange={(v) => update.mutate({ programId, cronEnabled: v })}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* extraRecipients */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Extra recap recipients (optional)</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
All edition admins receive the recap automatically. Add additional
|
||||
email addresses here (e.g. caterer, event manager).
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.extraRecipients.map((email) => (
|
||||
<Badge key={email} variant="secondary" className="gap-1">
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1"
|
||||
onClick={() =>
|
||||
update.mutate({
|
||||
programId,
|
||||
extraRecipients: event.extraRecipients.filter(
|
||||
(e) => e !== email,
|
||||
),
|
||||
})
|
||||
}
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="email@example.com — press Enter to add"
|
||||
value={extraInput}
|
||||
onChange={(e) => setExtraInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && extraInput.trim()) {
|
||||
e.preventDefault()
|
||||
const next = [
|
||||
...event.extraRecipients,
|
||||
extraInput.trim(),
|
||||
]
|
||||
update.mutate({ programId, extraRecipients: next })
|
||||
setExtraInput('')
|
||||
}
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LunchEventConfig } from './lunch-event-config'
|
||||
|
||||
export function LunchTab({ programId }: { programId: string }) {
|
||||
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
|
||||
if (isLoading || !event) {
|
||||
return <Skeleton className="h-48 w-full" />
|
||||
}
|
||||
if (!event.enabled) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lunch is disabled</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Toggle Lunch on from the Event configuration card to begin setup.
|
||||
</p>
|
||||
{/* Event config card mounts in Task 14, replacing this stub. */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Cards mount in Tasks 14-18. */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Lunch tab — cards land in upcoming tasks.
|
||||
</p>
|
||||
<LunchEventConfig programId={programId} event={event} />
|
||||
{/* Other cards mount in Tasks 15-18 once event.enabled. */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user