feat: lunch picker on attending-members card + admin slide-over

LunchPickForm shared between applicant dashboard rows (member-self /
team-lead context) and the admin manifest's edit-pencil slide-over.
Adds lunch.getMemberPick read for the per-row hydration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 02:49:08 +02:00
parent ec24d404c5
commit df95867465
5 changed files with 350 additions and 30 deletions

View File

@@ -8,6 +8,14 @@ import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { LunchPickForm } from '@/components/applicant/lunch-pick-form'
import { Pencil, Download } from 'lucide-react'
import { toast } from 'sonner'
@@ -53,15 +61,17 @@ function DownloadCsvButton({ programId }: { programId: string }) {
export function LunchManifest({
programId,
onEditExternal,
onEditMember,
}: {
programId: string
onEditExternal?: (externalId: string) => void
onEditMember?: (attendingMemberId: string) => void
}) {
const { data } = trpc.lunch.getManifest.useQuery({ programId })
const [search, setSearch] = useState('')
const [missingOnly, setMissingOnly] = useState(false)
const [editingMemberId, setEditingMemberId] = useState<string | null>(null)
const editingMember = data?.members.find(
(m) => m.attendingMemberId === editingMemberId,
)
type Row =
| (NonNullable<typeof data>['members'][number] & { sortKey: string })
@@ -192,7 +202,7 @@ export function LunchManifest({
if (r.kind === 'EXTERNAL') {
onEditExternal?.(r.externalId)
} else {
onEditMember?.(r.attendingMemberId)
setEditingMemberId(r.attendingMemberId)
}
}}
>
@@ -213,6 +223,32 @@ export function LunchManifest({
</table>
</div>
</CardContent>
<Sheet
open={!!editingMemberId}
onOpenChange={(o) => { if (!o) setEditingMemberId(null) }}
>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Edit lunch pick</SheetTitle>
{editingMember && (
<SheetDescription>
{editingMember.name} · {editingMember.project?.name}
</SheetDescription>
)}
</SheetHeader>
{editingMemberId && data?.event && (
<div className="mt-6">
<LunchPickForm
attendingMemberId={editingMemberId}
programId={programId}
lunchEventId={data.event.id}
canEdit
/>
</div>
)}
</SheetContent>
</Sheet>
</Card>
)
}

View File

@@ -12,7 +12,9 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react'
import { EditAttendeesDialog } from './edit-attendees-dialog'
import { LunchPickForm } from './lunch-pick-form'
import type { VisaStatus } from '@prisma/client'
import { useSession } from 'next-auth/react'
const VISA_BADGE: Record<
VisaStatus,
@@ -46,8 +48,14 @@ function nextVisaDate(v: {
}
export function AttendingMembersCard() {
const { data: session } = useSession()
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
const { data: myVisas } = trpc.applicant.getMyVisaApplications.useQuery()
const programId = data?.project.programId
const { data: lunchEvent } = trpc.lunch.getEventForMember.useQuery(
{ programId: programId ?? '' },
{ enabled: !!programId },
)
if (isLoading) {
return (
@@ -131,37 +139,66 @@ export function AttendingMembersCard() {
const visa = visaByUser.get(a.userId)
const visaBadge = visa ? VISA_BADGE[visa.status] : null
const next = visa ? nextVisaDate(visa) : null
const sessionUserId = session?.user?.id
const sessionRole = session?.user?.role
const isAdmin =
sessionRole === 'SUPER_ADMIN' || sessionRole === 'PROGRAM_ADMIN'
const isSelf = sessionUserId === a.userId
const isLeadActing = data.isLead && !isSelf
const lunchDeadline = lunchEvent?.changeDeadline
? new Date(lunchEvent.changeDeadline)
: null
const lunchPastDeadline =
!!lunchDeadline && new Date() > lunchDeadline
const canEditLunch =
!!lunchEvent &&
((isSelf && !lunchPastDeadline) ||
(data.isLead && !lunchPastDeadline) ||
isAdmin)
return (
<li
key={a.userId}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
className="space-y-3 rounded-md border px-3 py-2"
>
<div>
<div className="text-sm font-medium">{user.name ?? user.email}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</div>
<div className="flex flex-col items-end gap-1">
{visa && visaBadge ? (
<>
<Badge variant={visaBadge.variant} className="gap-1">
<ShieldCheck className="h-3 w-3" />
{visaBadge.label}
</Badge>
{next && (
<span className="text-muted-foreground text-xs">
{next.label}: {formatDateOnly(next.date)}
</span>
)}
</>
) : (
a.needsVisa && (
<Badge variant="outline" className="gap-1">
<ShieldCheck className="h-3 w-3" />
Visa support
</Badge>
)
)}
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{user.name ?? user.email}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</div>
<div className="flex flex-col items-end gap-1">
{visa && visaBadge ? (
<>
<Badge variant={visaBadge.variant} className="gap-1">
<ShieldCheck className="h-3 w-3" />
{visaBadge.label}
</Badge>
{next && (
<span className="text-muted-foreground text-xs">
{next.label}: {formatDateOnly(next.date)}
</span>
)}
</>
) : (
a.needsVisa && (
<Badge variant="outline" className="gap-1">
<ShieldCheck className="h-3 w-3" />
Visa support
</Badge>
)
)}
</div>
</div>
{lunchEvent && programId && (
<LunchPickForm
attendingMemberId={a.id}
programId={programId}
lunchEventId={lunchEvent.id}
canEdit={canEditLunch}
editingOnBehalfOf={
isLeadActing ? (user.name ?? user.email) : null
}
/>
)}
</li>
)
})}

View File

@@ -0,0 +1,204 @@
'use client'
import { useState, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Salad, Lock, CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
const ALLERGENS = [
'GLUTEN',
'CRUSTACEANS',
'EGGS',
'FISH',
'PEANUTS',
'SOYBEANS',
'MILK',
'TREE_NUTS',
'CELERY',
'MUSTARD',
'SESAME',
'SULPHITES',
'LUPIN',
'MOLLUSCS',
] as const
type Allergen = (typeof ALLERGENS)[number]
const NO_DISH = '__no_dish__'
function formatTag(t: string): string {
return t.replace('_', ' ').toLowerCase()
}
export function LunchPickForm({
attendingMemberId,
programId,
lunchEventId,
canEdit,
editingOnBehalfOf,
}: {
attendingMemberId: string
programId: string
lunchEventId: string
canEdit: boolean
editingOnBehalfOf?: string | null
}) {
const utils = trpc.useUtils()
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
const { data: row, refetch } = trpc.lunch.getMemberPick.useQuery({
attendingMemberId,
})
const pick = row?.pick ?? null
const [dishId, setDishId] = useState<string>('')
const [allergens, setAllergens] = useState<Allergen[]>([])
const [allergenOther, setAllergenOther] = useState<string>('')
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
if (!hydrated && pick) {
setDishId(pick.dishId ?? '')
setAllergens((pick.allergens as Allergen[]) ?? [])
setAllergenOther(pick.allergenOther ?? '')
setHydrated(true)
}
}, [pick, hydrated])
const upsert = trpc.lunch.upsertPick.useMutation({
onSuccess: () => {
refetch()
utils.lunch.getManifest.invalidate({ programId })
toast.success('Lunch pick saved')
},
onError: (e) => toast.error(e.message),
})
function commit(next: {
dishId?: string
allergens?: Allergen[]
allergenOther?: string
}) {
if (!canEdit) return
upsert.mutate({
attendingMemberId,
dishId: (next.dishId ?? dishId) || null,
allergens: next.allergens ?? allergens,
allergenOther: (next.allergenOther ?? allergenOther) || null,
})
}
if (!dishes) return null
const grouped: Record<string, typeof dishes> = {}
for (const d of dishes) {
const key = d.dietaryTags.length > 0 ? d.dietaryTags[0] : 'OTHER'
if (!grouped[key]) grouped[key] = []
grouped[key].push(d)
}
return (
<div className="space-y-3 rounded-md border-l-2 border-emerald-500/30 bg-emerald-500/5 p-3 text-sm">
<div className="flex items-center gap-2">
<Salad className="h-4 w-4 text-emerald-500" />
<span className="font-medium">Lunch</span>
{pick?.pickedAt && (
<Badge variant="outline" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> picked
</Badge>
)}
{!canEdit && (
<Badge variant="secondary" className="gap-1">
<Lock className="h-3 w-3" /> read-only
</Badge>
)}
{editingOnBehalfOf && (
<span className="text-muted-foreground text-xs">
Editing on behalf of {editingOnBehalfOf}
</span>
)}
</div>
<div>
<Label className="text-xs">Dish</Label>
<Select
value={dishId === '' ? NO_DISH : dishId}
onValueChange={(v) => {
const next = v === NO_DISH ? '' : v
setDishId(next)
commit({ dishId: next })
}}
disabled={!canEdit}
>
<SelectTrigger className="max-w-sm">
<SelectValue placeholder="Pick a dish" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_DISH}>Not picked</SelectItem>
{Object.entries(grouped).map(([group, items]) => (
<SelectGroup key={group}>
<SelectLabel>
{group === 'OTHER' ? 'All options' : formatTag(group)}
</SelectLabel>
{items.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
{d.dietaryTags.length > 0 && (
<span className="text-muted-foreground ml-2 text-xs">
{d.dietaryTags.map(formatTag).join(', ')}
</span>
)}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Allergens</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{ALLERGENS.map((a) => (
<label key={a} className="flex items-center gap-2">
<Checkbox
checked={allergens.includes(a)}
disabled={!canEdit}
onCheckedChange={(v) => {
const next = v
? [...allergens, a]
: allergens.filter((x) => x !== a)
setAllergens(next)
commit({ allergens: next })
}}
/>
<span className="text-xs">{formatTag(a)}</span>
</label>
))}
</div>
</div>
<div>
<Label className="text-xs">Other allergens / notes</Label>
<Textarea
value={allergenOther}
disabled={!canEdit}
onChange={(e) => setAllergenOther(e.target.value)}
onBlur={() => commit({ allergenOther })}
rows={2}
/>
</div>
</div>
)
}

View File

@@ -2727,7 +2727,9 @@ export const applicantRouter = router({
},
finalistConfirmation: {
include: {
attendingMembers: { select: { userId: true, needsVisa: true } },
attendingMembers: {
select: { id: true, userId: true, needsVisa: true },
},
},
},
},
@@ -2757,6 +2759,7 @@ export const applicantRouter = router({
project: {
id: project.id,
title: project.title,
programId: project.program.id,
teamMembers: project.teamMembers.map((tm) => ({
userId: tm.userId,
role: tm.role,

View File

@@ -227,6 +227,46 @@ export const lunchRouter = router({
return { ok: true as const }
}),
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
/**
* Read the current MemberLunchPick for an AttendingMember plus the dishes
* for the parent event. Permission: any user with a TeamMember row on the
* project, OR the AttendingMember.userId itself, OR admin.
*/
getMemberPick: protectedProcedure
.input(z.object({ attendingMemberId: z.string() }))
.query(async ({ ctx, input }) => {
const am = await ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
include: {
confirmation: {
select: {
project: {
select: {
id: true,
programId: true,
teamMembers: { select: { userId: true } },
},
},
},
},
lunchPick: true,
},
})
if (!am) throw new TRPCError({ code: 'NOT_FOUND' })
const userId = ctx.user.id
const role = ctx.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
const isOnTeam = am.confirmation.project.teamMembers.some(
(tm) => tm.userId === userId,
)
if (!isAdmin && !isOnTeam && am.userId !== userId) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return { pick: am.lunchPick }
}),
// ─── Manifest + CSV export ───────────────────────────────────────────────
getManifest: adminProcedure