247 lines
8.1 KiB
TypeScript
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>
|
|
)
|
|
}
|