feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps

Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 07:36:51 +01:00
parent 0f0f1cbf38
commit a4d360aae9
33 changed files with 2136 additions and 361 deletions

View File

@@ -0,0 +1,524 @@
/**
* Accountability Roster PDF Generator
*
* Professional roster document for emergency preparedness.
* Follows VIPSchedulePDF patterns for consistent styling.
*/
import {
Document,
Page,
Text,
View,
StyleSheet,
Font,
Image,
} from '@react-pdf/renderer';
import { PdfSettings } from '@/types/settings';
Font.register({
family: 'Helvetica',
fonts: [
{ src: 'Helvetica' },
{ src: 'Helvetica-Bold', fontWeight: 'bold' },
],
});
interface VIP {
id: string;
name: string;
organization: string | null;
department: string;
phone: string | null;
email: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
isRosterOnly: boolean;
partySize: number;
}
interface AccountabilityRosterPDFProps {
vips: VIP[];
settings?: PdfSettings | null;
}
const createStyles = (accentColor: string = '#2c3e50', _pageSize: 'LETTER' | 'A4' = 'LETTER') =>
StyleSheet.create({
page: {
padding: 40,
paddingBottom: 80,
fontSize: 9,
fontFamily: 'Helvetica',
backgroundColor: '#ffffff',
color: '#333333',
},
// Watermark
watermark: {
position: 'absolute',
top: '40%',
left: '50%',
transform: 'translate(-50%, -50%) rotate(-45deg)',
fontSize: 72,
color: '#888888',
opacity: 0.2,
fontWeight: 'bold',
zIndex: 0,
},
// Logo
logoContainer: {
marginBottom: 10,
flexDirection: 'row',
justifyContent: 'center',
},
logo: {
maxWidth: 130,
maxHeight: 50,
objectFit: 'contain',
},
// Header
header: {
marginBottom: 20,
borderBottom: `2 solid ${accentColor}`,
paddingBottom: 15,
},
orgName: {
fontSize: 9,
color: '#7f8c8d',
textTransform: 'uppercase',
letterSpacing: 2,
marginBottom: 6,
},
title: {
fontSize: 22,
fontWeight: 'bold',
color: accentColor,
marginBottom: 4,
},
subtitle: {
fontSize: 10,
color: '#7f8c8d',
},
customMessage: {
fontSize: 9,
color: '#7f8c8d',
marginTop: 8,
padding: 8,
backgroundColor: '#f8f9fa',
borderLeft: `3 solid ${accentColor}`,
},
timestampBar: {
marginTop: 10,
paddingTop: 8,
borderTop: '1 solid #ecf0f1',
flexDirection: 'row',
justifyContent: 'space-between',
},
timestamp: {
fontSize: 7,
color: '#95a5a6',
},
// Summary stats row
summaryRow: {
flexDirection: 'row',
marginBottom: 15,
gap: 10,
},
summaryCard: {
flex: 1,
padding: 10,
backgroundColor: '#f8f9fa',
borderLeft: `3 solid ${accentColor}`,
},
summaryValue: {
fontSize: 18,
fontWeight: 'bold',
color: '#2c3e50',
},
summaryLabel: {
fontSize: 8,
color: '#7f8c8d',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Section
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
color: accentColor,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 8,
paddingBottom: 4,
borderBottom: `2 solid ${accentColor}`,
},
section: {
marginBottom: 18,
},
// Table
table: {
borderLeft: '1 solid #dee2e6',
borderRight: '1 solid #dee2e6',
borderTop: '1 solid #dee2e6',
},
tableHeader: {
flexDirection: 'row',
backgroundColor: accentColor,
minHeight: 24,
},
tableHeaderCell: {
color: '#ffffff',
fontSize: 7,
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: 0.5,
padding: 6,
justifyContent: 'center',
},
tableRow: {
flexDirection: 'row',
borderBottom: '1 solid #dee2e6',
minHeight: 28,
},
tableRowAlt: {
backgroundColor: '#f8f9fa',
},
tableRowRoster: {
backgroundColor: '#fef9e7',
},
tableRowRosterAlt: {
backgroundColor: '#fdf3d0',
},
tableCell: {
padding: 5,
justifyContent: 'center',
},
cellName: {
fontSize: 9,
fontWeight: 'bold',
color: '#2c3e50',
},
cellDept: {
fontSize: 7,
color: '#7f8c8d',
marginTop: 1,
},
cellText: {
fontSize: 8,
color: '#34495e',
},
cellSmall: {
fontSize: 7,
color: '#7f8c8d',
},
cellCenter: {
fontSize: 9,
fontWeight: 'bold',
color: '#2c3e50',
textAlign: 'center',
},
cellNoData: {
fontSize: 7,
color: '#bdc3c7',
fontStyle: 'italic',
},
// Column widths
colName: { width: '22%' },
colOrg: { width: '18%' },
colContact: { width: '22%' },
colEmergency: { width: '22%' },
colParty: { width: '8%' },
colNotes: { width: '8%' },
// Footer
footer: {
position: 'absolute',
bottom: 25,
left: 40,
right: 40,
paddingTop: 10,
borderTop: '1 solid #dee2e6',
},
footerContent: {
flexDirection: 'row',
justifyContent: 'space-between',
},
footerLeft: {
maxWidth: '60%',
},
footerTitle: {
fontSize: 8,
fontWeight: 'bold',
color: '#2c3e50',
marginBottom: 3,
},
footerContact: {
fontSize: 7,
color: '#7f8c8d',
marginBottom: 1,
},
footerRight: {
textAlign: 'right',
},
pageNumber: {
fontSize: 7,
color: '#95a5a6',
},
// Empty state
emptyState: {
textAlign: 'center',
padding: 30,
color: '#95a5a6',
fontSize: 11,
},
});
const formatDepartment = (dept: string) => {
switch (dept) {
case 'OFFICE_OF_DEVELOPMENT':
return 'Office of Dev';
case 'ADMIN':
return 'Admin';
default:
return dept;
}
};
export function AccountabilityRosterPDF({
vips,
settings,
}: AccountabilityRosterPDFProps) {
const config = settings || {
organizationName: 'VIP Transportation Services',
accentColor: '#2c3e50',
contactEmail: 'coordinator@example.com',
contactPhone: '(555) 123-4567',
contactLabel: 'Questions or Changes?',
showDraftWatermark: false,
showConfidentialWatermark: false,
showTimestamp: true,
showAppUrl: false,
pageSize: 'LETTER' as const,
logoUrl: null,
tagline: null,
headerMessage: null,
footerMessage: null,
secondaryContactName: null,
secondaryContactPhone: null,
};
const styles = createStyles(config.accentColor, config.pageSize);
const generatedAt = new Date().toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
const activeVips = vips.filter((v) => !v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
const rosterOnlyVips = vips.filter((v) => v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
const totalPeople = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
const activeCount = activeVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
const rosterCount = rosterOnlyVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
const renderTableHeader = () => (
<View style={styles.tableHeader}>
<View style={[styles.tableHeaderCell, styles.colName]}>
<Text>Name</Text>
</View>
<View style={[styles.tableHeaderCell, styles.colOrg]}>
<Text>Organization</Text>
</View>
<View style={[styles.tableHeaderCell, styles.colContact]}>
<Text>Contact</Text>
</View>
<View style={[styles.tableHeaderCell, styles.colEmergency]}>
<Text>Emergency Contact</Text>
</View>
<View style={[styles.tableHeaderCell, styles.colParty]}>
<Text>Party</Text>
</View>
</View>
);
const renderVipRow = (vip: VIP, index: number, isRoster: boolean) => (
<View
key={vip.id}
style={[
styles.tableRow,
isRoster
? index % 2 === 1 ? styles.tableRowRosterAlt : styles.tableRowRoster
: index % 2 === 1 ? styles.tableRowAlt : {},
]}
wrap={false}
>
<View style={[styles.tableCell, styles.colName]}>
<Text style={styles.cellName}>{vip.name}</Text>
<Text style={styles.cellDept}>{formatDepartment(vip.department)}</Text>
</View>
<View style={[styles.tableCell, styles.colOrg]}>
{vip.organization ? (
<Text style={styles.cellText}>{vip.organization}</Text>
) : (
<Text style={styles.cellNoData}>-</Text>
)}
</View>
<View style={[styles.tableCell, styles.colContact]}>
{vip.phone && <Text style={styles.cellText}>{vip.phone}</Text>}
{vip.email && <Text style={styles.cellSmall}>{vip.email}</Text>}
{!vip.phone && !vip.email && <Text style={styles.cellNoData}>No contact info</Text>}
</View>
<View style={[styles.tableCell, styles.colEmergency]}>
{vip.emergencyContactName ? (
<>
<Text style={styles.cellText}>{vip.emergencyContactName}</Text>
{vip.emergencyContactPhone && (
<Text style={styles.cellSmall}>{vip.emergencyContactPhone}</Text>
)}
</>
) : (
<Text style={styles.cellNoData}>Not provided</Text>
)}
</View>
<View style={[styles.tableCell, styles.colParty]}>
<Text style={styles.cellCenter}>{vip.partySize}</Text>
</View>
</View>
);
return (
<Document>
<Page size={config.pageSize} style={styles.page}>
{/* Watermarks */}
{config.showDraftWatermark && (
<View style={styles.watermark} fixed>
<Text>DRAFT</Text>
</View>
)}
{config.showConfidentialWatermark && (
<View style={styles.watermark} fixed>
<Text>CONFIDENTIAL</Text>
</View>
)}
{/* Header */}
<View style={styles.header}>
{config.logoUrl && (
<View style={styles.logoContainer}>
<Image src={config.logoUrl} style={styles.logo} />
</View>
)}
<Text style={styles.orgName}>{config.organizationName}</Text>
<Text style={styles.title}>Accountability Roster</Text>
<Text style={styles.subtitle}>Emergency Preparedness & Personnel Tracking</Text>
{config.headerMessage && (
<Text style={styles.customMessage}>{config.headerMessage}</Text>
)}
{(config.showTimestamp || config.showAppUrl) && (
<View style={styles.timestampBar}>
{config.showTimestamp && (
<Text style={styles.timestamp}>Generated: {generatedAt}</Text>
)}
{config.showAppUrl && (
<Text style={styles.timestamp}>
Latest version: {typeof window !== 'undefined' ? window.location.origin : ''}
</Text>
)}
</View>
)}
</View>
{/* Summary Stats */}
<View style={styles.summaryRow}>
<View style={styles.summaryCard}>
<Text style={styles.summaryValue}>{totalPeople}</Text>
<Text style={styles.summaryLabel}>Total People</Text>
</View>
<View style={styles.summaryCard}>
<Text style={styles.summaryValue}>{activeCount}</Text>
<Text style={styles.summaryLabel}>Active VIPs</Text>
</View>
<View style={styles.summaryCard}>
<Text style={styles.summaryValue}>{rosterCount}</Text>
<Text style={styles.summaryLabel}>Roster Only</Text>
</View>
</View>
{/* Active VIPs Table */}
{activeVips.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Active VIPs ({activeVips.length} entries, {activeCount} people)
</Text>
<View style={styles.table}>
{renderTableHeader()}
{activeVips.map((vip, i) => renderVipRow(vip, i, false))}
</View>
</View>
)}
{/* Roster Only Table */}
{rosterOnlyVips.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Roster Only ({rosterOnlyVips.length} entries, {rosterCount} people)
</Text>
<View style={styles.table}>
{renderTableHeader()}
{rosterOnlyVips.map((vip, i) => renderVipRow(vip, i, true))}
</View>
</View>
)}
{/* Empty State */}
{vips.length === 0 && (
<Text style={styles.emptyState}>No personnel records found.</Text>
)}
{/* Custom Footer Message */}
{config.footerMessage && (
<View style={styles.section}>
<Text style={styles.customMessage}>{config.footerMessage}</Text>
</View>
)}
{/* Footer */}
<View style={styles.footer} fixed>
<View style={styles.footerContent}>
<View style={styles.footerLeft}>
<Text style={styles.footerTitle}>{config.contactLabel}</Text>
<Text style={styles.footerContact}>{config.contactEmail}</Text>
<Text style={styles.footerContact}>{config.contactPhone}</Text>
{config.secondaryContactName && (
<Text style={styles.footerContact}>
{config.secondaryContactName}
{config.secondaryContactPhone ? ` - ${config.secondaryContactPhone}` : ''}
</Text>
)}
</View>
<View style={styles.footerRight}>
<Text
style={styles.pageNumber}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/>
</View>
</View>
</View>
</Page>
</Document>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { X, Send, Loader2 } from 'lucide-react';
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface Driver {
id: string;
@@ -18,6 +19,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
const [message, setMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const { formatDateTime } = useFormattedDate();
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
const sendMessage = useSendMessage();
@@ -66,22 +68,6 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
@@ -126,7 +112,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
<p className={`text-[10px] mt-1 ${
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
}`}>
{formatTime(msg.timestamp)}
{formatDateTime(msg.timestamp)}
</p>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { api } from '@/lib/api';
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
import { Driver } from '@/types';
import { useState } from 'react';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface ScheduleEvent {
id: string;
@@ -36,6 +37,7 @@ interface DriverScheduleModalProps {
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
const [selectedDate, setSelectedDate] = useState(new Date());
const { formatDate, formatTime } = useFormattedDate();
const dateString = selectedDate.toISOString().split('T')[0];
@@ -85,23 +87,6 @@ export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleM
const isToday = selectedDate.toDateString() === new Date().toDateString();
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'COMPLETED':

View File

@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
import { api } from '@/lib/api';
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface EventFormProps {
event?: ScheduleEvent | null;
@@ -39,6 +39,8 @@ interface ScheduleConflict {
}
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
const { formatDateTime } = useFormattedDate();
// Helper to convert ISO datetime to datetime-local format
const toDatetimeLocal = (isoString: string | null | undefined) => {
if (!isoString) return '';

View File

@@ -6,18 +6,22 @@ import {
Trash2,
AlertTriangle,
Clock,
ToggleLeft,
ToggleRight,
ChevronDown,
ChevronUp,
Users,
CheckCircle,
Link2,
XCircle,
} from 'lucide-react';
import { Flight } from '@/types';
import { Flight, Journey, Layover } from '@/types';
import { FlightProgressBar } from './FlightProgressBar';
import { useRefreshFlight } from '@/hooks/useFlights';
import { formatLayoverDuration } from '@/lib/journeyUtils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface FlightCardProps {
flight: Flight;
flight?: Flight;
journey?: Journey;
onEdit?: (flight: Flight) => void;
onDelete?: (flight: Flight) => void;
}
@@ -58,16 +62,75 @@ function formatRelativeTime(isoString: string | null): string {
return `${Math.floor(hours / 24)}d ago`;
}
export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
function getSegmentStatusIcon(flight: Flight) {
const status = flight.status?.toLowerCase();
if (status === 'landed' || flight.actualArrival) {
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />;
}
if (status === 'active') {
return <Plane className="w-3.5 h-3.5 text-purple-500" />;
}
if (status === 'cancelled' || status === 'diverted') {
return <XCircle className="w-3.5 h-3.5 text-red-500" />;
}
return <Clock className="w-3.5 h-3.5 text-muted-foreground" />;
}
function LayoverRow({ layover }: { layover: Layover }) {
const riskColors = {
none: 'text-muted-foreground',
ok: 'text-muted-foreground',
warning: 'bg-amber-50 dark:bg-amber-950/20 text-amber-700 dark:text-amber-400',
critical: 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-400',
missed: 'bg-red-100 dark:bg-red-950/30 text-red-800 dark:text-red-300',
};
const isBadge = layover.risk === 'warning' || layover.risk === 'critical' || layover.risk === 'missed';
return (
<div className={`flex items-center gap-2 px-4 py-1.5 text-xs ${isBadge ? riskColors[layover.risk] : ''}`}>
<div className="flex items-center gap-1.5 ml-6">
<div className="w-px h-3 bg-border" />
<Link2 className="w-3 h-3 text-muted-foreground/50" />
</div>
<div className="flex items-center gap-2 flex-1">
<span className={isBadge ? 'font-medium' : 'text-muted-foreground'}>
{layover.risk === 'missed' ? (
<>CONNECTION MISSED at {layover.airport} - arrived {formatLayoverDuration(layover.effectiveMinutes)}</>
) : (
<>{formatLayoverDuration(layover.scheduledMinutes)} layover at {layover.airport}</>
)}
</span>
{layover.risk === 'warning' && layover.effectiveMinutes !== layover.scheduledMinutes && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 text-[10px] font-semibold">
<AlertTriangle className="w-2.5 h-2.5" />
now {formatLayoverDuration(layover.effectiveMinutes)}
</span>
)}
{layover.risk === 'critical' && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 text-[10px] font-semibold">
<AlertTriangle className="w-2.5 h-2.5" />
only {formatLayoverDuration(layover.effectiveMinutes)} remaining
</span>
)}
</div>
</div>
);
}
// ============================================================
// SINGLE FLIGHT CARD (original behavior)
// ============================================================
function SingleFlightCard({ flight, onEdit, onDelete }: { flight: Flight; onEdit?: (f: Flight) => void; onDelete?: (f: Flight) => void }) {
const [expanded, setExpanded] = useState(false);
const refreshMutation = useRefreshFlight();
const alert = getAlertBanner(flight);
const dotColor = getStatusDotColor(flight);
const isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
const { formatDateTime } = useFormattedDate();
return (
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
{/* Alert banner */}
{alert && (
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
<AlertTriangle className="w-3.5 h-3.5" />
@@ -75,22 +138,16 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
)}
{/* Header */}
<div className="px-4 pt-3 pb-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
{/* Status dot */}
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
{/* Flight number + airline */}
<div className="flex items-center gap-2">
<span className="font-bold text-foreground">{flight.flightNumber}</span>
{flight.airlineName && (
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
)}
</div>
{/* VIP name */}
{flight.vip && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="text-muted-foreground/50">|</span>
@@ -104,8 +161,6 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={() => refreshMutation.mutate(flight.id)}
@@ -116,20 +171,12 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
</button>
{onEdit && (
<button
onClick={() => onEdit(flight)}
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
title="Edit flight"
>
<button onClick={() => onEdit(flight)} className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground" title="Edit flight">
<Edit3 className="w-4 h-4" />
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(flight)}
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500"
title="Delete flight"
>
<button onClick={() => onDelete(flight)} className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500" title="Delete flight">
<Trash2 className="w-4 h-4" />
</button>
)}
@@ -137,12 +184,10 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
</div>
{/* Progress Bar */}
<div className="px-4">
<FlightProgressBar flight={flight} />
</div>
{/* Footer - expandable details */}
<div className="px-4 pb-2">
<button
onClick={() => setExpanded(!expanded)}
@@ -165,14 +210,13 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
{expanded && (
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
{/* Detailed times */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="font-medium text-foreground mb-1">Departure</div>
<div className="space-y-0.5 text-muted-foreground">
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
{flight.scheduledDeparture && <div>Scheduled: {formatDateTime(flight.scheduledDeparture)}</div>}
{flight.estimatedDeparture && <div>Estimated: {formatDateTime(flight.estimatedDeparture)}</div>}
{flight.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(flight.actualDeparture)}</div>}
{flight.departureDelay != null && flight.departureDelay > 0 && (
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.departureDelay} min</div>
)}
@@ -183,9 +227,9 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
<div>
<div className="font-medium text-foreground mb-1">Arrival</div>
<div className="space-y-0.5 text-muted-foreground">
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
{flight.scheduledArrival && <div>Scheduled: {formatDateTime(flight.scheduledArrival)}</div>}
{flight.estimatedArrival && <div>Estimated: {formatDateTime(flight.estimatedArrival)}</div>}
{flight.actualArrival && <div className="text-foreground">Actual: {formatDateTime(flight.actualArrival)}</div>}
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.arrivalDelay} min</div>
)}
@@ -195,12 +239,8 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
</div>
</div>
{/* Aircraft info */}
{flight.aircraftType && (
<div className="text-muted-foreground">
Aircraft: {flight.aircraftType}
</div>
<div className="text-muted-foreground">Aircraft: {flight.aircraftType}</div>
)}
</div>
)}
@@ -208,3 +248,220 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
</div>
);
}
// ============================================================
// MULTI-SEGMENT JOURNEY CARD
// ============================================================
function JourneyCard({ journey, onEdit, onDelete }: { journey: Journey; onEdit?: (f: Flight) => void; onDelete?: (f: Flight) => void }) {
const [expandedSeg, setExpandedSeg] = useState<number | null>(null);
const refreshMutation = useRefreshFlight();
const currentFlight = journey.flights[journey.currentSegmentIndex];
const dotColor = getStatusDotColor(currentFlight);
const vip = journey.vip || currentFlight?.vip;
const { formatDateTime } = useFormattedDate();
// Route chain: BWI -> ORD -> SLC
const routeChain = [journey.origin, ...journey.flights.slice(1).map(f => f.departureAirport), journey.destination]
.filter((v, i, a) => a.indexOf(v) === i); // dedupe
// Connection risk banner
const worstLayover = journey.layovers.reduce<Layover | null>((worst, l) => {
if (!worst) return l;
if (l.risk === 'missed') return l;
if (l.risk === 'critical' && worst.risk !== 'missed') return l;
if (l.risk === 'warning' && worst.risk !== 'missed' && worst.risk !== 'critical') return l;
return worst;
}, null);
const connectionBanner = worstLayover && (worstLayover.risk === 'warning' || worstLayover.risk === 'critical' || worstLayover.risk === 'missed')
? {
message: worstLayover.risk === 'missed'
? `CONNECTION MISSED at ${worstLayover.airport}`
: worstLayover.risk === 'critical'
? `CONNECTION AT RISK - only ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`
: `Connection tight - ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`,
color: worstLayover.risk === 'missed'
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
: worstLayover.risk === 'critical'
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400',
}
: null;
return (
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
{/* Connection risk banner */}
{connectionBanner && (
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${connectionBanner.color}`}>
<AlertTriangle className="w-3.5 h-3.5" />
{connectionBanner.message}
</div>
)}
{/* Journey header */}
<div className="px-4 pt-3 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
{vip && (
<div className="flex items-center gap-1.5">
<span className="font-bold text-foreground">{vip.name}</span>
{vip.partySize > 1 && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<Users className="w-3 h-3" />
+{vip.partySize - 1}
</span>
)}
</div>
)}
</div>
{/* Route chain */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{routeChain.map((code, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground/40">{'→'}</span>}
<span className={i === 0 || i === routeChain.length - 1 ? 'font-bold text-foreground' : ''}>{code}</span>
</span>
))}
<span className="ml-1 px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
{journey.flights.length} legs
</span>
</div>
</div>
</div>
{/* Segment stack */}
{journey.flights.map((seg, i) => {
const isExpanded = expandedSeg === i;
const isCurrent = i === journey.currentSegmentIndex;
return (
<div key={seg.id}>
{/* Layover row between segments */}
{i > 0 && journey.layovers[i - 1] && (
<LayoverRow layover={journey.layovers[i - 1]} />
)}
{/* Segment row */}
<div className={`px-4 py-2 ${isCurrent ? 'bg-accent/30' : ''}`}>
<div className="flex items-center gap-2">
{/* Leg label + status icon */}
<div className="flex items-center gap-1.5 min-w-[60px]">
<span className="text-[10px] font-semibold text-muted-foreground uppercase">Leg {i + 1}</span>
{getSegmentStatusIcon(seg)}
</div>
{/* Compact progress bar */}
<div className="flex-1">
<FlightProgressBar flight={seg} compact />
</div>
{/* Flight number + actions */}
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className="text-xs font-medium text-muted-foreground">{seg.flightNumber}</span>
<button
onClick={() => refreshMutation.mutate(seg.id)}
disabled={refreshMutation.isPending}
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={`w-3 h-3 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setExpandedSeg(isExpanded ? null : i)}
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground"
>
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
{/* Terminal/gate info for current segment */}
{isCurrent && (seg.arrivalTerminal || seg.arrivalGate || seg.arrivalBaggage) && (
<div className="flex gap-3 mt-1 ml-[68px] text-[10px] text-muted-foreground">
{seg.arrivalTerminal && <span>Terminal {seg.arrivalTerminal}</span>}
{seg.arrivalGate && <span>Gate {seg.arrivalGate}</span>}
{seg.arrivalBaggage && <span>Baggage {seg.arrivalBaggage}</span>}
</div>
)}
{/* Expanded details */}
{isExpanded && (
<div className="mt-2 pt-2 border-t border-border/50 text-xs">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="font-medium text-foreground mb-1">Departure</div>
<div className="space-y-0.5 text-muted-foreground">
{seg.scheduledDeparture && <div>Scheduled: {formatDateTime(seg.scheduledDeparture)}</div>}
{seg.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(seg.actualDeparture)}</div>}
{seg.departureDelay != null && seg.departureDelay > 0 && (
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.departureDelay} min</div>
)}
{seg.departureTerminal && <div>Terminal: {seg.departureTerminal}</div>}
{seg.departureGate && <div>Gate: {seg.departureGate}</div>}
</div>
</div>
<div>
<div className="font-medium text-foreground mb-1">Arrival</div>
<div className="space-y-0.5 text-muted-foreground">
{seg.scheduledArrival && <div>Scheduled: {formatDateTime(seg.scheduledArrival)}</div>}
{seg.actualArrival && <div className="text-foreground">Actual: {formatDateTime(seg.actualArrival)}</div>}
{seg.arrivalDelay != null && seg.arrivalDelay > 0 && (
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.arrivalDelay} min</div>
)}
{seg.arrivalTerminal && <div>Terminal: {seg.arrivalTerminal}</div>}
{seg.arrivalGate && <div>Gate: {seg.arrivalGate}</div>}
{seg.arrivalBaggage && <div>Baggage: {seg.arrivalBaggage}</div>}
</div>
</div>
</div>
<div className="flex items-center gap-3 mt-2">
{onEdit && (
<button onClick={() => onEdit(seg)} className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400">Edit</button>
)}
{onDelete && (
<button onClick={() => onDelete(seg)} className="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Delete</button>
)}
</div>
</div>
)}
</div>
</div>
);
})}
{/* Footer */}
<div className="px-4 py-1.5 border-t border-border/50 text-xs text-muted-foreground flex items-center gap-3">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Updated {formatRelativeTime(currentFlight?.lastPolledAt)}
</span>
<span className="px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
{journey.effectiveStatus}
</span>
</div>
</div>
);
}
// ============================================================
// EXPORT: Routes to single or journey card
// ============================================================
export function FlightCard({ flight, journey, onEdit, onDelete }: FlightCardProps) {
if (journey) {
// Multi-segment journeys always use JourneyCard, single-segment journeys too when passed as journey
if (journey.isMultiSegment) {
return <JourneyCard journey={journey} onEdit={onEdit} onDelete={onDelete} />;
}
// Single-segment journey: render as single flight card
return <SingleFlightCard flight={journey.flights[0]} onEdit={onEdit} onDelete={onDelete} />;
}
if (flight) {
return <SingleFlightCard flight={flight} onEdit={onEdit} onDelete={onDelete} />;
}
return null;
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useEffect, useState } from 'react';
import { Plane } from 'lucide-react';
import { Flight } from '@/types';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface FlightProgressBarProps {
flight: Flight;
@@ -59,16 +60,8 @@ function getTrackBgColor(flight: Flight): string {
return 'bg-muted';
}
function formatTime(isoString: string | null): string {
if (!isoString) return '--:--';
return new Date(isoString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
const { formatTime } = useFormattedDate();
const [progress, setProgress] = useState(() => calculateProgress(flight));
const status = flight.status?.toLowerCase();
const isActive = status === 'active';

View File

@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
import { formatDateTime } from '@/lib/utils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface Driver {
id: string;
@@ -32,6 +32,7 @@ export function InlineDriverSelector({
currentDriverName,
onDriverChange,
}: InlineDriverSelectorProps) {
const { formatDateTime } = useFormattedDate();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showConflictDialog, setShowConflictDialog] = useState(false);