feat(logistics): departure-after-arrival validation + travel/visa CSV export
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s
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:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user