From fe630e0e2d90c946de0414b4db73188bb0945827 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 19:37:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20Visas=20tab=20=E2=80=94=20table?= =?UTF-8?q?=20+=20edit=20dialog=20+=20visibility=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activates the previously-disabled Visas tab on /admin/logistics. VisasTab renders a flat table joined per attendee per project, sorted by status priority. Status filter pills mirror the Confirmations tab. The header carries a "Visible to teams" Switch backed by a new logistics.getVisaVisibility query and the existing setVisaVisibility mutation; toggling it controls whether members see their own status. VisaEditDialog is a per-row editor with a status dropdown, nationality input, three native date inputs (invitation / appointment / decision), and a notes textarea. No file uploads — the platform deliberately holds zero document artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(admin)/admin/logistics/page.tsx | 7 +- .../admin/logistics/visa-edit-dialog.tsx | 217 ++++++++++++++ src/components/admin/logistics/visas-tab.tsx | 268 ++++++++++++++++++ src/server/routers/logistics.ts | 11 + tests/unit/visa-admin.test.ts | 6 +- 5 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 src/components/admin/logistics/visa-edit-dialog.tsx create mode 100644 src/components/admin/logistics/visas-tab.tsx diff --git a/src/app/(admin)/admin/logistics/page.tsx b/src/app/(admin)/admin/logistics/page.tsx index 697dcf5..d2e2339 100644 --- a/src/app/(admin)/admin/logistics/page.tsx +++ b/src/app/(admin)/admin/logistics/page.tsx @@ -16,6 +16,7 @@ import { import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab' import { TravelTab } from '@/components/admin/logistics/travel-tab' import { HotelsTab } from '@/components/admin/logistics/hotels-tab' +import { VisasTab } from '@/components/admin/logistics/visas-tab' export default function LogisticsPage() { const { currentEdition } = useEdition() @@ -50,9 +51,8 @@ export default function LogisticsPage() { Hotels - + Visas - (soon) Lunch @@ -81,6 +81,9 @@ export default function LogisticsPage() { + + + ) diff --git a/src/components/admin/logistics/visa-edit-dialog.tsx b/src/components/admin/logistics/visa-edit-dialog.tsx new file mode 100644 index 0000000..ec81b99 --- /dev/null +++ b/src/components/admin/logistics/visa-edit-dialog.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useEffect, useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import type { VisaStatus } from '@prisma/client' + +const STATUS_OPTIONS: { value: VisaStatus; label: string }[] = [ + { value: 'NOT_NEEDED', label: 'Not needed' }, + { value: 'REQUESTED', label: 'Requested' }, + { value: 'INVITATION_SENT', label: 'Invitation sent' }, + { value: 'APPOINTMENT_BOOKED', label: 'Appointment booked' }, + { value: 'GRANTED', label: 'Granted' }, + { value: 'DENIED', label: 'Denied' }, +] + +function toDateInputValue(d: Date | null | undefined): string { + if (!d) return '' + const dt = new Date(d) + if (Number.isNaN(dt.getTime())) return '' + // YYYY-MM-DD for + return dt.toISOString().slice(0, 10) +} + +function fromDateInputValue(s: string): Date | null { + if (!s) return null + const dt = new Date(s) + return Number.isNaN(dt.getTime()) ? null : dt +} + +export type VisaEditTarget = { + id: string + status: VisaStatus + nationality: string | null + invitationSentAt: Date | null + appointmentAt: Date | null + decisionAt: Date | null + notes: string | null + attendeeName: string + projectTitle: string +} + +export function VisaEditDialog({ + open, + target, + programId, + onOpenChange, +}: { + open: boolean + target: VisaEditTarget | null + programId: string + onOpenChange: (next: boolean) => void +}) { + const utils = trpc.useUtils() + const [status, setStatus] = useState('REQUESTED') + const [nationality, setNationality] = useState('') + const [invitationSent, setInvitationSent] = useState('') + const [appointment, setAppointment] = useState('') + const [decision, setDecision] = useState('') + const [notes, setNotes] = useState('') + + useEffect(() => { + if (target && open) { + setStatus(target.status) + setNationality(target.nationality ?? '') + setInvitationSent(toDateInputValue(target.invitationSentAt)) + setAppointment(toDateInputValue(target.appointmentAt)) + setDecision(toDateInputValue(target.decisionAt)) + setNotes(target.notes ?? '') + } + }, [target, open]) + + const mutation = trpc.logistics.updateVisaApplication.useMutation({ + onSuccess: () => { + toast.success('Visa application updated') + utils.logistics.listVisaApplications.invalidate({ programId }) + onOpenChange(false) + }, + onError: (e) => toast.error(e.message), + }) + + const handleSave = () => { + if (!target) return + mutation.mutate({ + id: target.id, + status, + nationality: nationality.trim() || null, + invitationSentAt: fromDateInputValue(invitationSent), + appointmentAt: fromDateInputValue(appointment), + decisionAt: fromDateInputValue(decision), + notes: notes.trim() || null, + }) + } + + return ( + { + if (!mutation.isPending) onOpenChange(next) + }} + > + + + Update visa application + + {target + ? `${target.attendeeName} · ${target.projectTitle}` + : 'Loading…'} + + + +
+
+ + +
+ +
+ + setNationality(e.target.value)} + placeholder="Self-declared, optional" + /> +
+ +
+
+ + setInvitationSent(e.target.value)} + /> +
+
+ + setAppointment(e.target.value)} + /> +
+
+ + setDecision(e.target.value)} + /> +
+
+ +
+ +