feat(logistics): departure-after-arrival validation + travel/visa CSV export
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s

- upsertFlightDetail throws BAD_REQUEST when departureAt < arrivalAt
- Travel tab: Download CSV button (project/attendee/email/flight fields/status/visa)
- Visas tab: Download CSV button (project/attendee/nationality/status/dates/notes)
- TDD: 2 new tests (rejects invalid, accepts valid); all 6 flight tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 16:56:22 +02:00
parent 53b623fb20
commit 97951deb68
4 changed files with 203 additions and 9 deletions

View File

@@ -26,7 +26,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Loader2, Plane } from 'lucide-react'
import { Download, Loader2, Plane } from 'lucide-react'
import type { FlightDetailStatus } from '@prisma/client'
interface Props {
@@ -240,6 +240,47 @@ function FlightEditorSheet({
)
}
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function buildTravelCsv(rows: AttendeeRow[]): string {
const header = [
'Project',
'Attendee',
'Email',
'Arrival date/time',
'Arrival flight',
'Arrival airport',
'Departure date/time',
'Departure flight',
'Departure airport',
'Status',
'Needs visa',
].join(',')
const lines = rows.map((r) => {
const fd = r.flightDetail
return [
csvEscape(r.confirmation.project.title),
csvEscape(r.user.name ?? r.user.email),
csvEscape(r.user.email),
csvEscape(fd?.arrivalAt ? new Date(fd.arrivalAt).toLocaleString() : ''),
csvEscape(fd?.arrivalFlightNumber),
csvEscape(fd?.arrivalAirport),
csvEscape(fd?.departureAt ? new Date(fd.departureAt).toLocaleString() : ''),
csvEscape(fd?.departureFlightNumber),
csvEscape(fd?.departureAirport),
csvEscape(fd?.status ?? ''),
r.needsVisa ? 'Yes' : 'No',
].join(',')
})
return [header, ...lines].join('\r\n')
}
export function TravelTab({ programId }: Props) {
const utils = trpc.useUtils()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
@@ -306,11 +347,31 @@ export function TravelTab({ programId }: Props) {
<Plane className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
</div>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<StatusPill value="all" label="All" count={totals.all} />
<StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} />
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
<Button
variant="outline"
size="sm"
disabled={!data || data.length === 0}
onClick={() => {
if (!data) return
const csv = buildTravelCsv(data as AttendeeRow[])
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'travel-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
</div>
</div>
</CardHeader>

View File

@@ -15,7 +15,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Settings as SettingsIcon, ShieldOff } from 'lucide-react'
import { Download, Settings as SettingsIcon, ShieldOff } from 'lucide-react'
import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog'
import type { VisaStatus } from '@prisma/client'
@@ -56,6 +56,53 @@ function nextDate(row: {
return { label: '—', date: null }
}
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
type VisaRow = {
status: VisaStatus
nationality: string | null
invitationSentAt: Date | null
appointmentAt: Date | null
decisionAt: Date | null
notes: string | null
project: { id: string; title: string }
attendee: { id: string; user: { id: string; name: string | null; email: string } }
}
function buildVisaCsv(rows: VisaRow[]): string {
const header = [
'Project',
'Attendee',
'Email',
'Nationality',
'Status',
'Invitation sent',
'Appointment',
'Decision',
'Notes',
].join(',')
const lines = rows.map((r) => {
return [
csvEscape(r.project.title),
csvEscape(r.attendee.user.name ?? r.attendee.user.email),
csvEscape(r.attendee.user.email),
csvEscape(r.nationality),
csvEscape(r.status),
csvEscape(r.invitationSentAt ? new Date(r.invitationSentAt).toLocaleDateString() : ''),
csvEscape(r.appointmentAt ? new Date(r.appointmentAt).toLocaleDateString() : ''),
csvEscape(r.decisionAt ? new Date(r.decisionAt).toLocaleDateString() : ''),
csvEscape(r.notes),
].join(',')
})
return [header, ...lines].join('\r\n')
}
export function VisasTab({ programId }: Props) {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
@@ -117,12 +164,34 @@ export function VisasTab({ programId }: Props) {
continue to flow over email and are never stored on this platform.
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings?tab=edition">
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
Edition settings
</Link>
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!data || data.length === 0}
onClick={() => {
if (!data) return
const csv = buildVisaCsv(data)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'visa-applications.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings?tab=edition">
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
Edition settings
</Link>
</Button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<StatusPill value="all" label="All" count={(data ?? []).length} />

View File

@@ -162,6 +162,12 @@ export const logisticsRouter = router({
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) data[k] = v
}
if (input.arrivalAt && input.departureAt && input.departureAt < input.arrivalAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Departure must be after arrival',
})
}
const detail = await ctx.prisma.flightDetail.upsert({
where: { attendingMemberId },
create: { attendingMemberId, ...(data as object) },