Files
MOPC-Portal/src/components/admin/logistics/visa-edit-dialog.tsx
Matt fe630e0e2d feat: admin Visas tab — table + edit dialog + visibility toggle
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) <noreply@anthropic.com>
2026-04-28 19:37:55 +02:00

218 lines
6.5 KiB
TypeScript

'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 <input type="date">
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<VisaStatus>('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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (!mutation.isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update visa application</DialogTitle>
<DialogDescription>
{target
? `${target.attendeeName} · ${target.projectTitle}`
: 'Loading…'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="visa-status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as VisaStatus)}>
<SelectTrigger id="visa-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="visa-nationality">Nationality</Label>
<Input
id="visa-nationality"
value={nationality}
onChange={(e) => setNationality(e.target.value)}
placeholder="Self-declared, optional"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="visa-invitation">Invitation sent</Label>
<Input
id="visa-invitation"
type="date"
value={invitationSent}
onChange={(e) => setInvitationSent(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visa-appointment">Appointment</Label>
<Input
id="visa-appointment"
type="date"
value={appointment}
onChange={(e) => setAppointment(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visa-decision">Decision</Label>
<Input
id="visa-decision"
type="date"
value={decision}
onChange={(e) => setDecision(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="visa-notes">Notes</Label>
<Textarea
id="visa-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Free-text notes — embassy, contact, follow-ups, etc. No documents."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={!target || mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}