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:
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal file
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user