diff --git a/src/components/admin/logistics/lunch-manifest.tsx b/src/components/admin/logistics/lunch-manifest.tsx index 7f1d339..5d47b8f 100644 --- a/src/components/admin/logistics/lunch-manifest.tsx +++ b/src/components/admin/logistics/lunch-manifest.tsx @@ -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(null) + const editingMember = data?.members.find( + (m) => m.attendingMemberId === editingMemberId, + ) type Row = | (NonNullable['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({ + + { if (!o) setEditingMemberId(null) }} + > + + + Edit lunch pick + {editingMember && ( + + {editingMember.name} · {editingMember.project?.name} + + )} + + {editingMemberId && data?.event && ( +
+ +
+ )} +
+
) } diff --git a/src/components/applicant/attending-members-card.tsx b/src/components/applicant/attending-members-card.tsx index 620c5ff..e0ea880 100644 --- a/src/components/applicant/attending-members-card.tsx +++ b/src/components/applicant/attending-members-card.tsx @@ -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 (
  • -
    -
    {user.name ?? user.email}
    -
    {user.email}
    -
    -
    - {visa && visaBadge ? ( - <> - - - {visaBadge.label} - - {next && ( - - {next.label}: {formatDateOnly(next.date)} - - )} - - ) : ( - a.needsVisa && ( - - - Visa support - - ) - )} +
    +
    +
    {user.name ?? user.email}
    +
    {user.email}
    +
    +
    + {visa && visaBadge ? ( + <> + + + {visaBadge.label} + + {next && ( + + {next.label}: {formatDateOnly(next.date)} + + )} + + ) : ( + a.needsVisa && ( + + + Visa support + + ) + )} +
    + {lunchEvent && programId && ( + + )}
  • ) })} diff --git a/src/components/applicant/lunch-pick-form.tsx b/src/components/applicant/lunch-pick-form.tsx new file mode 100644 index 0000000..bc440c1 --- /dev/null +++ b/src/components/applicant/lunch-pick-form.tsx @@ -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('') + const [allergens, setAllergens] = useState([]) + const [allergenOther, setAllergenOther] = useState('') + 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 = {} + 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 ( +
    +
    + + Lunch + {pick?.pickedAt && ( + + picked + + )} + {!canEdit && ( + + read-only + + )} + {editingOnBehalfOf && ( + + Editing on behalf of {editingOnBehalfOf} + + )} +
    + +
    + + +
    + +
    + +
    + {ALLERGENS.map((a) => ( + + ))} +
    +
    + +
    + +