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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
204
src/components/applicant/lunch-pick-form.tsx
Normal file
204
src/components/applicant/lunch-pick-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user