Files
MOPC-Portal/src/components/admin/logistics/lunch-event-config.tsx
Matt ec00942620 feat: lunch event configuration card
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:41:34 +02:00

247 lines
8.1 KiB
TypeScript

'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>
)
}