218 lines
6.5 KiB
TypeScript
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>
|
||
|
|
)
|
||
|
|
}
|