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 { Label } from '@/components/ui/label'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { Pencil, Download } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -53,15 +61,17 @@ function DownloadCsvButton({ programId }: { programId: string }) {
|
|||||||
export function LunchManifest({
|
export function LunchManifest({
|
||||||
programId,
|
programId,
|
||||||
onEditExternal,
|
onEditExternal,
|
||||||
onEditMember,
|
|
||||||
}: {
|
}: {
|
||||||
programId: string
|
programId: string
|
||||||
onEditExternal?: (externalId: string) => void
|
onEditExternal?: (externalId: string) => void
|
||||||
onEditMember?: (attendingMemberId: string) => void
|
|
||||||
}) {
|
}) {
|
||||||
const { data } = trpc.lunch.getManifest.useQuery({ programId })
|
const { data } = trpc.lunch.getManifest.useQuery({ programId })
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [missingOnly, setMissingOnly] = useState(false)
|
const [missingOnly, setMissingOnly] = useState(false)
|
||||||
|
const [editingMemberId, setEditingMemberId] = useState<string | null>(null)
|
||||||
|
const editingMember = data?.members.find(
|
||||||
|
(m) => m.attendingMemberId === editingMemberId,
|
||||||
|
)
|
||||||
|
|
||||||
type Row =
|
type Row =
|
||||||
| (NonNullable<typeof data>['members'][number] & { sortKey: string })
|
| (NonNullable<typeof data>['members'][number] & { sortKey: string })
|
||||||
@@ -192,7 +202,7 @@ export function LunchManifest({
|
|||||||
if (r.kind === 'EXTERNAL') {
|
if (r.kind === 'EXTERNAL') {
|
||||||
onEditExternal?.(r.externalId)
|
onEditExternal?.(r.externalId)
|
||||||
} else {
|
} else {
|
||||||
onEditMember?.(r.attendingMemberId)
|
setEditingMemberId(r.attendingMemberId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -213,6 +223,32 @@ export function LunchManifest({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react'
|
import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react'
|
||||||
import { EditAttendeesDialog } from './edit-attendees-dialog'
|
import { EditAttendeesDialog } from './edit-attendees-dialog'
|
||||||
|
import { LunchPickForm } from './lunch-pick-form'
|
||||||
import type { VisaStatus } from '@prisma/client'
|
import type { VisaStatus } from '@prisma/client'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
const VISA_BADGE: Record<
|
const VISA_BADGE: Record<
|
||||||
VisaStatus,
|
VisaStatus,
|
||||||
@@ -46,8 +48,14 @@ function nextVisaDate(v: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AttendingMembersCard() {
|
export function AttendingMembersCard() {
|
||||||
|
const { data: session } = useSession()
|
||||||
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
|
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
|
||||||
const { data: myVisas } = trpc.applicant.getMyVisaApplications.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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -131,37 +139,66 @@ export function AttendingMembersCard() {
|
|||||||
const visa = visaByUser.get(a.userId)
|
const visa = visaByUser.get(a.userId)
|
||||||
const visaBadge = visa ? VISA_BADGE[visa.status] : null
|
const visaBadge = visa ? VISA_BADGE[visa.status] : null
|
||||||
const next = visa ? nextVisaDate(visa) : 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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={a.userId}
|
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="flex items-center justify-between gap-3">
|
||||||
<div className="text-sm font-medium">{user.name ?? user.email}</div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">{user.email}</div>
|
<div className="text-sm font-medium">{user.name ?? user.email}</div>
|
||||||
</div>
|
<div className="text-muted-foreground text-xs">{user.email}</div>
|
||||||
<div className="flex flex-col items-end gap-1">
|
</div>
|
||||||
{visa && visaBadge ? (
|
<div className="flex flex-col items-end gap-1">
|
||||||
<>
|
{visa && visaBadge ? (
|
||||||
<Badge variant={visaBadge.variant} className="gap-1">
|
<>
|
||||||
<ShieldCheck className="h-3 w-3" />
|
<Badge variant={visaBadge.variant} className="gap-1">
|
||||||
{visaBadge.label}
|
<ShieldCheck className="h-3 w-3" />
|
||||||
</Badge>
|
{visaBadge.label}
|
||||||
{next && (
|
</Badge>
|
||||||
<span className="text-muted-foreground text-xs">
|
{next && (
|
||||||
{next.label}: {formatDateOnly(next.date)}
|
<span className="text-muted-foreground text-xs">
|
||||||
</span>
|
{next.label}: {formatDateOnly(next.date)}
|
||||||
)}
|
</span>
|
||||||
</>
|
)}
|
||||||
) : (
|
</>
|
||||||
a.needsVisa && (
|
) : (
|
||||||
<Badge variant="outline" className="gap-1">
|
a.needsVisa && (
|
||||||
<ShieldCheck className="h-3 w-3" />
|
<Badge variant="outline" className="gap-1">
|
||||||
Visa support
|
<ShieldCheck className="h-3 w-3" />
|
||||||
</Badge>
|
Visa support
|
||||||
)
|
</Badge>
|
||||||
)}
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{lunchEvent && programId && (
|
||||||
|
<LunchPickForm
|
||||||
|
attendingMemberId={a.id}
|
||||||
|
programId={programId}
|
||||||
|
lunchEventId={lunchEvent.id}
|
||||||
|
canEdit={canEditLunch}
|
||||||
|
editingOnBehalfOf={
|
||||||
|
isLeadActing ? (user.name ?? user.email) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2727,7 +2727,9 @@ export const applicantRouter = router({
|
|||||||
},
|
},
|
||||||
finalistConfirmation: {
|
finalistConfirmation: {
|
||||||
include: {
|
include: {
|
||||||
attendingMembers: { select: { userId: true, needsVisa: true } },
|
attendingMembers: {
|
||||||
|
select: { id: true, userId: true, needsVisa: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2757,6 +2759,7 @@ export const applicantRouter = router({
|
|||||||
project: {
|
project: {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
title: project.title,
|
title: project.title,
|
||||||
|
programId: project.program.id,
|
||||||
teamMembers: project.teamMembers.map((tm) => ({
|
teamMembers: project.teamMembers.map((tm) => ({
|
||||||
userId: tm.userId,
|
userId: tm.userId,
|
||||||
role: tm.role,
|
role: tm.role,
|
||||||
|
|||||||
@@ -227,6 +227,46 @@ export const lunchRouter = router({
|
|||||||
return { ok: true as const }
|
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 ───────────────────────────────────────────────
|
// ─── Manifest + CSV export ───────────────────────────────────────────────
|
||||||
|
|
||||||
getManifest: adminProcedure
|
getManifest: adminProcedure
|
||||||
|
|||||||
Reference in New Issue
Block a user