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,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Loader2, Plane } from 'lucide-react'
|
import { Download, Loader2, Plane } from 'lucide-react'
|
||||||
import type { FlightDetailStatus } from '@prisma/client'
|
import type { FlightDetailStatus } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
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) {
|
export function TravelTab({ programId }: Props) {
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
@@ -306,11 +347,31 @@ export function TravelTab({ programId }: Props) {
|
|||||||
<Plane className="text-muted-foreground h-4 w-4" />
|
<Plane className="text-muted-foreground h-4 w-4" />
|
||||||
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
|
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
|
||||||
</div>
|
</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="all" label="All" count={totals.all} />
|
||||||
<StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} />
|
<StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} />
|
||||||
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
|
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
|
||||||
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} 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 { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog'
|
||||||
import type { VisaStatus } from '@prisma/client'
|
import type { VisaStatus } from '@prisma/client'
|
||||||
|
|
||||||
@@ -56,6 +56,53 @@ function nextDate(row: {
|
|||||||
return { label: '—', date: null }
|
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) {
|
export function VisasTab({ programId }: Props) {
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
|
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.
|
continue to flow over email and are never stored on this platform.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/admin/settings?tab=edition">
|
<Button
|
||||||
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
|
variant="outline"
|
||||||
Edition settings
|
size="sm"
|
||||||
</Link>
|
disabled={!data || data.length === 0}
|
||||||
</Button>
|
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>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<StatusPill value="all" label="All" count={(data ?? []).length} />
|
<StatusPill value="all" label="All" count={(data ?? []).length} />
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ export const logisticsRouter = router({
|
|||||||
for (const [k, v] of Object.entries(rest)) {
|
for (const [k, v] of Object.entries(rest)) {
|
||||||
if (v !== undefined) data[k] = v
|
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({
|
const detail = await ctx.prisma.flightDetail.upsert({
|
||||||
where: { attendingMemberId },
|
where: { attendingMemberId },
|
||||||
create: { attendingMemberId, ...(data as object) },
|
create: { attendingMemberId, ...(data as object) },
|
||||||
|
|||||||
@@ -174,6 +174,64 @@ describe('logistics flight detail procedures', () => {
|
|||||||
expect(count).toBe(1)
|
expect(count).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('upsertFlightDetail rejects when departureAt < arrivalAt', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const { program, lead, attendingMember } = await setupConfirmedFinalist(
|
||||||
|
`flight-dep-before-arr-${uid()}`,
|
||||||
|
)
|
||||||
|
programIds.push(program.id)
|
||||||
|
userIds.push(lead.id)
|
||||||
|
|
||||||
|
const caller = createCaller(logisticsRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
|
||||||
|
const arrivalDate = new Date('2026-06-28T14:00:00Z')
|
||||||
|
const departureBeforeArrival = new Date('2026-06-28T10:00:00Z') // 4 hours earlier
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.upsertFlightDetail({
|
||||||
|
attendingMemberId: attendingMember.id,
|
||||||
|
arrivalAt: arrivalDate,
|
||||||
|
departureAt: departureBeforeArrival,
|
||||||
|
arrivalFlightNumber: 'AF7400',
|
||||||
|
departureFlightNumber: 'AF7405',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Departure must be after arrival' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsertFlightDetail succeeds when departureAt >= arrivalAt', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const { program, lead, attendingMember } = await setupConfirmedFinalist(
|
||||||
|
`flight-dep-after-arr-${uid()}`,
|
||||||
|
)
|
||||||
|
programIds.push(program.id)
|
||||||
|
userIds.push(lead.id)
|
||||||
|
|
||||||
|
const caller = createCaller(logisticsRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
|
||||||
|
const arrivalDate = new Date('2026-06-28T10:00:00Z')
|
||||||
|
const departureAfterArrival = new Date('2026-06-30T14:00:00Z') // 2 days later
|
||||||
|
|
||||||
|
const result = await caller.upsertFlightDetail({
|
||||||
|
attendingMemberId: attendingMember.id,
|
||||||
|
arrivalAt: arrivalDate,
|
||||||
|
departureAt: departureAfterArrival,
|
||||||
|
arrivalFlightNumber: 'AF7400',
|
||||||
|
departureFlightNumber: 'AF7405',
|
||||||
|
})
|
||||||
|
expect(result.arrivalFlightNumber).toBe('AF7400')
|
||||||
|
expect(result.departureFlightNumber).toBe('AF7405')
|
||||||
|
})
|
||||||
|
|
||||||
it('setFlightStatus toggles PENDING ↔ CONFIRMED', async () => {
|
it('setFlightStatus toggles PENDING ↔ CONFIRMED', async () => {
|
||||||
const admin = await createTestUser('SUPER_ADMIN')
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
userIds.push(admin.id)
|
userIds.push(admin.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user