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,12 @@
-- Delete duplicate rows keeping the first entry (by id) for each deviceId+timestamp pair
DELETE FROM "gps_location_history" a
USING "gps_location_history" b
WHERE a."id" > b."id"
AND a."deviceId" = b."deviceId"
AND a."timestamp" = b."timestamp";
-- Drop the existing index that covered deviceId+timestamp (non-unique)
DROP INDEX IF EXISTS "gps_location_history_deviceId_timestamp_idx";
-- CreateIndex (unique constraint replaces the old non-unique index)
CREATE UNIQUE INDEX "gps_location_history_deviceId_timestamp_key" ON "gps_location_history"("deviceId", "timestamp");

View File

@@ -430,7 +430,7 @@ model GpsLocationHistory {
createdAt DateTime @default(now())
@@map("gps_location_history")
@@index([deviceId, timestamp])
@@unique([deviceId, timestamp]) // Prevent duplicate position records
@@index([timestamp]) // For cleanup job
}

View File

@@ -78,6 +78,15 @@ export class GpsController {
return this.gpsService.getEnrolledDevices();
}
/**
* Get QR code info for an enrolled device
*/
@Get('devices/:driverId/qr')
@Roles(Role.ADMINISTRATOR)
async getDeviceQr(@Param('driverId') driverId: string) {
return this.gpsService.getDeviceQrInfo(driverId);
}
/**
* Enroll a driver for GPS tracking
*/

View File

@@ -256,6 +256,44 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
};
}
/**
* Get QR code info for an already-enrolled device
*/
async getDeviceQrInfo(driverId: string): Promise<{
driverName: string;
deviceIdentifier: string;
serverUrl: string;
qrCodeUrl: string;
updateIntervalSeconds: number;
}> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
include: { driver: { select: { id: true, name: true } } },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
const settings = await this.getSettings();
const serverUrl = this.traccarClient.getDeviceServerUrl();
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', device.deviceIdentifier);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
return {
driverName: device.driver.name,
deviceIdentifier: device.deviceIdentifier,
serverUrl,
qrCodeUrl: qrUrl.toString(),
updateIntervalSeconds: settings.updateIntervalSeconds,
};
}
/**
* Unenroll a driver from GPS tracking
*/
@@ -562,7 +600,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
* Sync positions from Traccar to our database (for history/stats)
* Called periodically via cron job
*/
@Cron(CronExpression.EVERY_MINUTE)
@Cron(CronExpression.EVERY_30_SECONDS)
async syncPositions(): Promise<void> {
const devices = await this.prisma.gpsDevice.findMany({
where: {
@@ -572,36 +610,51 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
if (devices.length === 0) return;
try {
const positions = await this.traccarClient.getAllPositions();
const now = new Date();
for (const device of devices) {
const position = positions.find((p) => p.deviceId === device.traccarDeviceId);
if (!position) continue;
try {
// Calculate "since" from device's last active time with 5s overlap buffer
// Falls back to 2 minutes ago if no lastActive
const since = device.lastActive
? new Date(device.lastActive.getTime() - 5000)
: new Date(now.getTime() - 120000);
// Update last active timestamp
const positions = await this.traccarClient.getPositionHistory(
device.traccarDeviceId,
since,
now,
);
if (positions.length === 0) continue;
// Batch insert with skipDuplicates (unique constraint on deviceId+timestamp)
await this.prisma.gpsLocationHistory.createMany({
data: positions.map((p) => ({
deviceId: device.id,
latitude: p.latitude,
longitude: p.longitude,
altitude: p.altitude || null,
speed: this.traccarClient.knotsToMph(p.speed || 0),
course: p.course || null,
accuracy: p.accuracy || null,
battery: p.attributes?.batteryLevel || null,
timestamp: new Date(p.deviceTime),
})),
skipDuplicates: true,
});
// Update lastActive to the latest position timestamp
const latestPosition = positions.reduce((latest, p) =>
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
);
await this.prisma.gpsDevice.update({
where: { id: device.id },
data: { lastActive: new Date(position.deviceTime) },
data: { lastActive: new Date(latestPosition.deviceTime) },
});
// Store in history
await this.prisma.gpsLocationHistory.create({
data: {
deviceId: device.id,
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude || null,
speed: this.traccarClient.knotsToMph(position.speed || 0),
course: position.course || null,
accuracy: position.accuracy || null,
battery: position.attributes?.batteryLevel || null,
timestamp: new Date(position.deviceTime),
},
});
}
} catch (error) {
this.logger.error(`Failed to sync positions: ${error}`);
this.logger.error(`Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
}
}
}

View File

@@ -215,60 +215,181 @@ export class SeedService {
private getFlightData(vips: any[]) {
const flights: any[] = [];
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
const destination = 'SLC';
vips.forEach((vip, index) => {
const airline = airlines[index % airlines.length];
const flightNum = `${airline}${1000 + index * 123}`;
const origin = origins[index % origins.length];
// Build a name->vip lookup for named scenarios
const vipByName = new Map<string, any>();
vips.forEach(v => vipByName.set(v.name, v));
// Arrival flight - times relative to now
const arrivalOffset = (index % 8) * 30 - 60;
const scheduledArrival = this.relativeTime(arrivalOffset);
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
// Helper: create a flight record
const makeFlight = (vipId: string, opts: any) => ({
vipId,
flightDate: new Date(),
...opts,
});
let status = 'scheduled';
let actualArrival = null;
if (arrivalOffset < -30) {
status = 'landed';
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
} else if (arrivalOffset < 0) {
status = 'landing';
} else if (index % 5 === 0) {
status = 'delayed';
// ============================================================
// NAMED MULTI-SEGMENT SCENARIOS
// ============================================================
// Roger Krone: 3-segment journey, all landed cleanly
// BWI -> ORD -> DEN -> SLC
const krone = vipByName.get('Roger A. Krone');
if (krone) {
flights.push(makeFlight(krone.id, {
segment: 1, flightNumber: 'UA410', departureAirport: 'BWI', arrivalAirport: 'ORD',
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-240),
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-235),
status: 'landed',
}));
flights.push(makeFlight(krone.id, {
segment: 2, flightNumber: 'UA672', departureAirport: 'ORD', arrivalAirport: 'DEN',
scheduledDeparture: this.relativeTime(-150), scheduledArrival: this.relativeTime(-60),
actualDeparture: this.relativeTime(-148), actualArrival: this.relativeTime(-55),
status: 'landed',
}));
flights.push(makeFlight(krone.id, {
segment: 3, flightNumber: 'UA1190', departureAirport: 'DEN', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-20), scheduledArrival: this.relativeTime(40),
actualDeparture: this.relativeTime(-18), actualArrival: null,
status: 'active',
arrivalTerminal: '2', arrivalGate: 'B7',
}));
}
flights.push({
vipId: vip.id,
flightNumber: flightNum,
flightDate: new Date(),
// Sarah Chen: 2-segment, leg 1 landed, leg 2 active/arriving
// JFK -> ORD -> SLC
const chen = vipByName.get('Sarah Chen');
if (chen) {
flights.push(makeFlight(chen.id, {
segment: 1, flightNumber: 'AA234', departureAirport: 'JFK', arrivalAirport: 'ORD',
scheduledDeparture: this.relativeTime(-300), scheduledArrival: this.relativeTime(-180),
actualDeparture: this.relativeTime(-298), actualArrival: this.relativeTime(-175),
status: 'landed',
}));
flights.push(makeFlight(chen.id, {
segment: 2, flightNumber: 'AA1456', departureAirport: 'ORD', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-90), scheduledArrival: this.relativeTime(30),
actualDeparture: this.relativeTime(-88), actualArrival: null,
status: 'active',
arrivalTerminal: '1', arrivalGate: 'A12',
}));
}
// Roberto Gonzalez: 2-segment, leg 1 DELAYED 45min - threatens connection
// LAX -> DFW -> SLC (90min layover scheduled, now ~45min WARNING)
const gonzalez = vipByName.get('Roberto Gonzalez');
if (gonzalez) {
flights.push(makeFlight(gonzalez.id, {
segment: 1, flightNumber: 'DL890', departureAirport: 'LAX', arrivalAirport: 'DFW',
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
estimatedArrival: this.relativeTime(-45), // 45min late
actualDeparture: this.relativeTime(-195), // departed 45min late
departureDelay: 45, arrivalDelay: 45,
status: 'active', // still in the air
}));
flights.push(makeFlight(gonzalez.id, {
segment: 2, flightNumber: 'DL1522', departureAirport: 'DFW', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(0), scheduledArrival: this.relativeTime(150),
status: 'scheduled',
departureTerminal: 'E', departureGate: 'E14',
}));
}
// Thomas Anderson: 2-segment, MISSED CONNECTION
// BOS -> ORD -> SLC (leg 1 arrived 25min late, leg 2 already departed)
const anderson = vipByName.get('Thomas Anderson');
if (anderson) {
flights.push(makeFlight(anderson.id, {
segment: 1, flightNumber: 'JB320', departureAirport: 'BOS', arrivalAirport: 'ORD',
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
actualDeparture: this.relativeTime(-215), actualArrival: this.relativeTime(-65),
departureDelay: 25, arrivalDelay: 25,
status: 'landed',
}));
flights.push(makeFlight(anderson.id, {
segment: 2, flightNumber: 'JB988', departureAirport: 'ORD', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-75), scheduledArrival: this.relativeTime(15),
actualDeparture: this.relativeTime(-75), // departed on time - before leg 1 landed
status: 'active', // plane left without him
}));
}
// Marcus Johnson: 2-segment, both landed cleanly
// ATL -> DEN -> SLC
const johnson = vipByName.get('Marcus Johnson');
if (johnson) {
flights.push(makeFlight(johnson.id, {
segment: 1, flightNumber: 'DL512', departureAirport: 'ATL', arrivalAirport: 'DEN',
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-210),
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-205),
status: 'landed',
}));
flights.push(makeFlight(johnson.id, {
segment: 2, flightNumber: 'DL1780', departureAirport: 'DEN', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-120), scheduledArrival: this.relativeTime(-30),
actualDeparture: this.relativeTime(-118), actualArrival: this.relativeTime(-25),
status: 'landed',
arrivalTerminal: '2', arrivalGate: 'C4', arrivalBaggage: '3',
}));
}
// James O'Brien: 2-segment, both scheduled (future)
// DFW -> DEN -> SLC
const obrien = vipByName.get("James O'Brien");
if (obrien) {
flights.push(makeFlight(obrien.id, {
segment: 1, flightNumber: 'UA780', departureAirport: 'DFW', arrivalAirport: 'DEN',
scheduledDeparture: this.relativeTime(60), scheduledArrival: this.relativeTime(180),
status: 'scheduled',
}));
flights.push(makeFlight(obrien.id, {
segment: 2, flightNumber: 'UA1340', departureAirport: 'DEN', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(240), scheduledArrival: this.relativeTime(330),
status: 'scheduled',
}));
}
// ============================================================
// DIRECT FLIGHTS (single segment)
// ============================================================
const directFlights: Array<{ name: string; airline: string; num: string; origin: string; offset: number; statusOverride?: string }> = [
{ name: 'Jennifer Wu', airline: 'AA', num: 'AA1023', origin: 'ORD', offset: 60 },
{ name: 'Priya Sharma', airline: 'UA', num: 'UA567', origin: 'SFO', offset: -15, statusOverride: 'active' },
{ name: 'David Okonkwo', airline: 'DL', num: 'DL1345', origin: 'SEA', offset: 120 },
{ name: 'Yuki Tanaka', airline: 'AA', num: 'AA890', origin: 'LAX', offset: 90 },
{ name: 'Isabella Costa', airline: 'SW', num: 'SW2210', origin: 'MIA', offset: -45, statusOverride: 'active' },
{ name: 'Fatima Al-Rahman', airline: 'AS', num: 'AS440', origin: 'SEA', offset: 180 },
{ name: 'William Zhang', airline: 'DL', num: 'DL1678', origin: 'ATL', offset: -90, statusOverride: 'landed' },
{ name: 'Alexander Volkov', airline: 'UA', num: 'UA2100', origin: 'DEN', offset: 45 },
];
for (const df of directFlights) {
const vip = vipByName.get(df.name);
if (!vip) continue;
const scheduledArrival = this.relativeTime(df.offset);
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
let status = df.statusOverride || 'scheduled';
let actualArrival = null;
if (status === 'landed') {
actualArrival = new Date(scheduledArrival.getTime() + 5 * 60000);
}
flights.push(makeFlight(vip.id, {
segment: 1,
departureAirport: origin,
flightNumber: df.num,
departureAirport: df.origin,
arrivalAirport: destination,
scheduledDeparture,
scheduledArrival,
actualDeparture: status !== 'scheduled' ? scheduledDeparture : null,
actualArrival,
status,
});
// Some VIPs have connecting flights (segment 2)
if (index % 4 === 0) {
const connectOrigin = origins[(index + 3) % origins.length];
flights.push({
vipId: vip.id,
flightNumber: `${airline}${500 + index}`,
flightDate: new Date(),
segment: 2,
departureAirport: connectOrigin,
arrivalAirport: origin,
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
status: 'landed',
});
}));
}
});
return flights;
}

View File

@@ -42,6 +42,23 @@ export class SettingsController {
};
}
/**
* Get app timezone - any authenticated user can read this
*/
@Get('timezone')
getTimezone() {
return this.settingsService.getTimezone();
}
/**
* Update app timezone - admin only
*/
@Patch('timezone')
@CanUpdate('Settings')
updateTimezone(@Body() dto: { timezone: string }) {
return this.settingsService.updateTimezone(dto.timezone);
}
@Get('pdf')
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
getPdfSettings() {

View File

@@ -75,6 +75,37 @@ export class SettingsService {
}
}
/**
* Get the app-wide timezone setting
*/
async getTimezone(): Promise<{ timezone: string }> {
const settings = await this.getPdfSettings();
return { timezone: settings.timezone };
}
/**
* Update the app-wide timezone setting
*/
async updateTimezone(timezone: string): Promise<{ timezone: string }> {
this.logger.log(`Updating timezone to: ${timezone}`);
// Validate the timezone string
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
} catch {
throw new BadRequestException(`Invalid timezone: ${timezone}`);
}
const existing = await this.getPdfSettings();
await this.prisma.pdfSettings.update({
where: { id: existing.id },
data: { timezone },
});
return { timezone };
}
/**
* Upload logo as base64 data URL
*/

View File

@@ -4,6 +4,7 @@ import { Auth0Provider } from '@auth0/auth0-react';
import { Toaster } from 'react-hot-toast';
import { AuthProvider } from '@/contexts/AuthContext';
import { AbilityProvider } from '@/contexts/AbilityContext';
import { TimezoneProvider } from '@/contexts/TimezoneContext';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { Layout } from '@/components/Layout';
@@ -68,6 +69,7 @@ function App() {
>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<TimezoneProvider>
<AbilityProvider>
<BrowserRouter
future={{
@@ -138,6 +140,7 @@ function App() {
</Routes>
</BrowserRouter>
</AbilityProvider>
</TimezoneProvider>
</AuthProvider>
</QueryClientProvider>
</Auth0Provider>

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);

View File

@@ -0,0 +1,71 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import toast from 'react-hot-toast';
interface TimezoneContextValue {
timezone: string;
isLoading: boolean;
setTimezone: (tz: string) => void;
}
const TimezoneContext = createContext<TimezoneContextValue>({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
isLoading: false,
setTimezone: () => {},
});
export function TimezoneProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{ timezone: string }>({
queryKey: ['settings', 'timezone'],
queryFn: async () => {
const { data } = await api.get('/settings/timezone');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const mutation = useMutation({
mutationFn: async (timezone: string) => {
const { data } = await api.patch('/settings/timezone', { timezone });
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(['settings', 'timezone'], data);
toast.success('Timezone updated');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update timezone');
},
});
const timezone = data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
return (
<TimezoneContext.Provider
value={{
timezone,
isLoading,
setTimezone: (tz: string) => mutation.mutate(tz),
}}
>
{children}
</TimezoneContext.Provider>
);
}
/**
* Get the app-wide timezone string
*/
export function useTimezone(): string {
return useContext(TimezoneContext).timezone;
}
/**
* Get the full timezone context (timezone, isLoading, setTimezone)
*/
export function useTimezoneContext(): TimezoneContextValue {
return useContext(TimezoneContext);
}

View File

@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import { useTimezone } from '@/contexts/TimezoneContext';
import { formatDate as fmtDate, formatDateTime as fmtDateTime, formatTime as fmtTime } from '@/lib/utils';
/**
* Returns format functions pre-bound with the app-wide timezone.
* Use this in components instead of importing formatDate/DateTime/Time directly.
*/
export function useFormattedDate() {
const timezone = useTimezone();
const formatDate = useCallback(
(date: string | Date) => fmtDate(date, timezone),
[timezone],
);
const formatDateTime = useCallback(
(date: string | Date) => fmtDateTime(date, timezone),
[timezone],
);
const formatTime = useCallback(
(date: string | Date) => fmtTime(date, timezone),
[timezone],
);
return { formatDate, formatDateTime, formatTime, timezone };
}

View File

@@ -8,6 +8,7 @@ import type {
GpsSettings,
EnrollmentResponse,
MyGpsStatus,
DeviceQrInfo,
} from '@/types/gps';
import toast from 'react-hot-toast';
@@ -78,6 +79,20 @@ export function useGpsDevices() {
});
}
/**
* Get QR code info for an enrolled device (on demand)
*/
export function useDeviceQr(driverId: string | null) {
return useQuery<DeviceQrInfo>({
queryKey: ['gps', 'devices', driverId, 'qr'],
queryFn: async () => {
const { data } = await api.get(`/gps/devices/${driverId}/qr`);
return data;
},
enabled: !!driverId,
});
}
/**
* Get all active driver locations (for map)
*/
@@ -88,7 +103,7 @@ export function useDriverLocations() {
const { data } = await api.get('/gps/locations');
return data;
},
refetchInterval: 30000, // Refresh every 30 seconds
refetchInterval: 15000, // Refresh every 15 seconds
});
}
@@ -103,7 +118,7 @@ export function useDriverLocation(driverId: string) {
return data;
},
enabled: !!driverId,
refetchInterval: 30000,
refetchInterval: 15000,
});
}

View File

@@ -0,0 +1,118 @@
import { Flight, Journey, Layover, LayoverRiskLevel } from '@/types';
function getEffectiveArrival(flight: Flight): Date | null {
const t = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
return t ? new Date(t) : null;
}
function getEffectiveDeparture(flight: Flight): Date | null {
const t = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
return t ? new Date(t) : null;
}
function computeLayoverRisk(effectiveMinutes: number, scheduledMinutes: number): LayoverRiskLevel {
if (effectiveMinutes < 0) return 'missed';
if (effectiveMinutes < 30) return 'critical';
if (effectiveMinutes < 60) return 'warning';
if (scheduledMinutes > 0) return 'ok';
return 'none';
}
function computeLayover(arriving: Flight, departing: Flight, index: number): Layover {
const scheduledArr = arriving.scheduledArrival ? new Date(arriving.scheduledArrival) : null;
const scheduledDep = departing.scheduledDeparture ? new Date(departing.scheduledDeparture) : null;
const effectiveArr = getEffectiveArrival(arriving);
const effectiveDep = getEffectiveDeparture(departing);
const scheduledMinutes = scheduledArr && scheduledDep
? (scheduledDep.getTime() - scheduledArr.getTime()) / 60000
: 0;
const effectiveMinutes = effectiveArr && effectiveDep
? (effectiveDep.getTime() - effectiveArr.getTime()) / 60000
: scheduledMinutes;
return {
airport: arriving.arrivalAirport,
afterSegmentIndex: index,
scheduledMinutes: Math.round(scheduledMinutes),
effectiveMinutes: Math.round(effectiveMinutes),
risk: computeLayoverRisk(effectiveMinutes, scheduledMinutes),
};
}
function computeEffectiveStatus(flights: Flight[]): { effectiveStatus: string; currentSegmentIndex: number } {
// Critical statuses on any segment take priority
for (let i = 0; i < flights.length; i++) {
const s = flights[i].status?.toLowerCase();
if (s === 'cancelled' || s === 'diverted' || s === 'incident') {
return { effectiveStatus: s, currentSegmentIndex: i };
}
}
// Find first non-terminal segment (the "active" one)
for (let i = 0; i < flights.length; i++) {
const s = flights[i].status?.toLowerCase();
const isTerminal = s === 'landed' || !!flights[i].actualArrival;
if (!isTerminal) {
return { effectiveStatus: s || 'scheduled', currentSegmentIndex: i };
}
}
// All segments landed
const last = flights.length - 1;
return { effectiveStatus: flights[last].status?.toLowerCase() || 'landed', currentSegmentIndex: last };
}
export function groupFlightsIntoJourneys(flights: Flight[]): Journey[] {
const byVip = new Map<string, Flight[]>();
for (const flight of flights) {
const group = byVip.get(flight.vipId) || [];
group.push(flight);
byVip.set(flight.vipId, group);
}
const journeys: Journey[] = [];
for (const [vipId, vipFlights] of byVip) {
// Sort chronologically by departure time, then segment as tiebreaker
const sorted = [...vipFlights].sort((a, b) => {
const depA = a.scheduledDeparture || a.flightDate;
const depB = b.scheduledDeparture || b.flightDate;
const timeDiff = new Date(depA).getTime() - new Date(depB).getTime();
if (timeDiff !== 0) return timeDiff;
return a.segment - b.segment;
});
const layovers: Layover[] = [];
for (let i = 0; i < sorted.length - 1; i++) {
layovers.push(computeLayover(sorted[i], sorted[i + 1], i));
}
const { effectiveStatus, currentSegmentIndex } = computeEffectiveStatus(sorted);
journeys.push({
vipId,
vip: sorted[0]?.vip,
flights: sorted,
layovers,
effectiveStatus,
currentSegmentIndex,
hasLayoverRisk: layovers.some(l => l.risk === 'warning' || l.risk === 'critical' || l.risk === 'missed'),
origin: sorted[0]?.departureAirport,
destination: sorted[sorted.length - 1]?.arrivalAirport,
isMultiSegment: sorted.length > 1,
});
}
return journeys;
}
export function formatLayoverDuration(minutes: number): string {
if (minutes < 0) return `${Math.abs(minutes)}min late`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}min`;
if (m === 0) return `${h}h`;
return `${h}h ${m}min`;
}

View File

@@ -5,16 +5,17 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date): string {
export function formatDate(date: string | Date, timeZone?: string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
...(timeZone && { timeZone }),
});
}
export function formatDateTime(date: string | Date): string {
export function formatDateTime(date: string | Date, timeZone?: string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('en-US', {
year: 'numeric',
@@ -22,13 +23,15 @@ export function formatDateTime(date: string | Date): string {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
...(timeZone && { timeZone }),
});
}
export function formatTime(date: string | Date): string {
export function formatTime(date: string | Date, timeZone?: string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
...(timeZone && { timeZone }),
});
}

View File

@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { handleError } from '@/lib/errorHandler';
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
import { useTimezoneContext } from '@/contexts/TimezoneContext';
import {
Database,
Trash2,
@@ -25,6 +26,7 @@ import {
Palette,
ExternalLink,
Shield,
Globe,
} from 'lucide-react';
interface Stats {
@@ -51,9 +53,27 @@ interface MessageStats {
driversWithMessages: number;
}
const COMMON_TIMEZONES = [
{ value: 'America/New_York', label: 'Eastern (ET)' },
{ value: 'America/Chicago', label: 'Central (CT)' },
{ value: 'America/Denver', label: 'Mountain (MT)' },
{ value: 'America/Los_Angeles', label: 'Pacific (PT)' },
{ value: 'America/Anchorage', label: 'Alaska (AKT)' },
{ value: 'Pacific/Honolulu', label: 'Hawaii (HT)' },
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
{ value: 'UTC', label: 'UTC' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
{ value: 'Europe/Berlin', label: 'Berlin (CET/CEST)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ value: 'Asia/Shanghai', label: 'Shanghai (CST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
];
export function AdminTools() {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const { timezone, setTimezone } = useTimezoneContext();
// Signal state
const [showQRCode, setShowQRCode] = useState(false);
@@ -433,6 +453,33 @@ export function AdminTools() {
</div>
</div>
{/* App Timezone */}
<div className="bg-card border border-border shadow-soft rounded-lg p-6 mb-8 transition-colors">
<div className="flex items-center mb-4">
<Globe className="h-5 w-5 text-blue-600 mr-2" />
<h2 className="text-lg font-medium text-foreground">App Timezone</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
All dates and times throughout the app will display in this timezone. Set this to match your event location.
</p>
<div className="flex items-center gap-4">
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary w-full max-w-xs"
>
{COMMON_TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label} ({tz.value})
</option>
))}
</select>
<span className="text-sm text-muted-foreground">
Current: {new Date().toLocaleTimeString('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })}
</span>
</div>
</div>
{/* PDF Customization Settings */}
<PdfSettingsSection />

View File

@@ -27,6 +27,9 @@ import { DriverLocationModal } from '@/components/DriverLocationModal';
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
import { useDriverLocations } from '@/hooks/useGps';
import type { DriverLocation } from '@/types/gps';
import type { Flight } from '@/types';
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
interface Event {
id: string;
@@ -105,6 +108,7 @@ const SCROLL_PAUSE_AT_END = 2000; // pause 2 seconds at top/bottom
const USER_INTERACTION_PAUSE = 20000; // pause 20 seconds after user interaction
export function CommandCenter() {
const { formatTime, formatDateTime, timezone } = useFormattedDate();
const [currentTime, setCurrentTime] = useState(new Date());
const [lastRefresh, setLastRefresh] = useState(new Date());
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
@@ -193,7 +197,7 @@ export function CommandCenter() {
},
});
const { data: flights } = useQuery<VIP['flights']>({
const { data: flights } = useQuery<Flight[]>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
@@ -201,6 +205,12 @@ export function CommandCenter() {
},
});
// Group flights into journeys for connection risk detection
const journeys = useMemo(() => {
if (!flights || flights.length === 0) return [];
return groupFlightsIntoJourneys(flights);
}, [flights]);
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => {
@@ -330,7 +340,7 @@ export function CommandCenter() {
});
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
const getFlightArrivalTime = (flight: VIP['flights'][0]) =>
const getFlightArrivalTime = (flight: { actualArrival: string | null; estimatedArrival: string | null; scheduledArrival: string | null }) =>
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
const upcomingArrivals = vips
@@ -442,7 +452,7 @@ export function CommandCenter() {
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
flights.forEach((flight: any) => {
flights.forEach((flight) => {
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
if (!arrivalTime) return;
const arrDate = new Date(arrivalTime);
@@ -474,6 +484,33 @@ export function CommandCenter() {
});
}
// Connection risk alerts from journey analysis
journeys.forEach((journey) => {
if (!journey.hasLayoverRisk) return;
const vipName = journey.vip?.name || 'Unknown VIP';
journey.layovers.forEach((layover) => {
if (layover.risk === 'missed') {
alerts.push({
type: 'critical',
message: `${vipName}: Connection MISSED at ${layover.airport} - arrived ${formatLayoverDuration(Math.abs(layover.effectiveMinutes))} after departure`,
link: '/flights',
});
} else if (layover.risk === 'critical') {
alerts.push({
type: 'critical',
message: `${vipName}: Connection at ${layover.airport} critical - only ${formatLayoverDuration(layover.effectiveMinutes)} layover`,
link: '/flights',
});
} else if (layover.risk === 'warning') {
alerts.push({
type: 'warning',
message: `${vipName}: Connection at ${layover.airport} tight - ${formatLayoverDuration(layover.effectiveMinutes)} layover (was ${formatLayoverDuration(layover.scheduledMinutes)})`,
link: '/flights',
});
}
});
});
// Get time until event
function getTimeUntil(dateStr: string) {
const eventTime = new Date(dateStr);
@@ -560,10 +597,10 @@ export function CommandCenter() {
{/* Live Clock */}
<div className="text-right">
<div className="text-4xl font-mono font-bold text-foreground tabular-nums">
{currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: timezone })}
</div>
<div className="text-sm text-muted-foreground">
{currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
{currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: timezone })}
</div>
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground mt-1">
<Radio className="h-3 w-3 text-green-500 animate-pulse" />
@@ -768,7 +805,7 @@ export function CommandCenter() {
<div className="text-right flex-shrink-0">
<p className="text-xs text-muted-foreground">ETA</p>
<p className="text-lg font-bold text-green-600 dark:text-green-400">
{new Date(trip.endTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{formatTime(new Date(trip.endTime))}
</p>
</div>
</div>
@@ -874,7 +911,7 @@ export function CommandCenter() {
{getTimeUntil(trip.startTime)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(trip.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{formatTime(new Date(trip.startTime))}
</p>
</div>
</div>
@@ -910,15 +947,19 @@ export function CommandCenter() {
) : (
<div className="divide-y divide-border">
{upcomingArrivals.map((vip) => {
// Find this VIP's journey if it exists
const journey = journeys.find(j => j.vipId === vip.id);
const flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
const arrival = vip.expectedArrival || (flight && getFlightArrivalTime(flight));
const delay = flight ? Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0) : 0;
const flightStatus = flight?.status?.toLowerCase();
const isCancelled = flightStatus === 'cancelled';
const isActive = flightStatus === 'active';
const isLanded = flightStatus === 'landed' || !!flight?.actualArrival;
const lastFlight = journey ? journey.flights[journey.flights.length - 1] : flight;
const finalArrival = lastFlight ? (lastFlight.actualArrival || lastFlight.estimatedArrival || lastFlight.scheduledArrival) : null;
const arrival = vip.expectedArrival || finalArrival;
const currentFlight = journey ? journey.flights[journey.currentSegmentIndex] : flight;
const delay = currentFlight ? Math.max(currentFlight.arrivalDelay || 0, currentFlight.departureDelay || 0) : 0;
const effectiveStatus = journey?.effectiveStatus || currentFlight?.status?.toLowerCase() || 'scheduled';
const isCancelled = effectiveStatus === 'cancelled';
const isActive = effectiveStatus === 'active';
const isLanded = effectiveStatus === 'landed';
// Color-code: green (on time / landed), amber (delayed), red (cancelled), purple (in flight)
const timeColor = isCancelled
? 'text-red-600 dark:text-red-400'
: isLanded
@@ -929,7 +970,9 @@ export function CommandCenter() {
? 'text-purple-600 dark:text-purple-400'
: 'text-blue-600 dark:text-blue-400';
const borderColor = isCancelled
const borderColor = journey?.hasLayoverRisk
? 'border-l-orange-500'
: isCancelled
? 'border-l-red-500'
: delay > 30
? 'border-l-amber-500'
@@ -939,13 +982,24 @@ export function CommandCenter() {
? 'border-l-emerald-500'
: 'border-l-blue-500';
// Build route chain
const routeChain = journey && journey.isMultiSegment
? journey.flights.map(f => f.departureAirport).concat([journey.flights[journey.flights.length - 1].arrivalAirport]).join(' → ')
: flight ? `${flight.departureAirport}${flight.arrivalAirport}` : '';
return (
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{delay > 15 && (
{journey?.hasLayoverRisk && (
<span className="flex items-center gap-0.5 px-1 py-0 text-[10px] rounded bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
<AlertTriangle className="w-2.5 h-2.5" />
risk
</span>
)}
{delay > 15 && !journey?.hasLayoverRisk && (
<span className="flex items-center gap-0.5 px-1 py-0 text-[10px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<AlertTriangle className="w-2.5 h-2.5" />
+{delay}m
@@ -958,18 +1012,16 @@ export function CommandCenter() {
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
{flight && (
<>
<span className="font-medium">{flight.flightNumber}</span>
<span>{flight.departureAirport} {flight.arrivalAirport}</span>
</>
<span>{routeChain}</span>
{journey?.isMultiSegment && (
<span className="text-muted-foreground/60">{journey.flights.length} legs</span>
)}
</div>
{flight && (flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
{currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
<div className="flex gap-2 text-[10px] text-muted-foreground mt-0.5">
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
{flight.arrivalBaggage && <span>Bag {flight.arrivalBaggage}</span>}
{currentFlight.arrivalTerminal && <span>T{currentFlight.arrivalTerminal}</span>}
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
{currentFlight.arrivalBaggage && <span>Bag {currentFlight.arrivalBaggage}</span>}
</div>
)}
</div>
@@ -979,7 +1031,7 @@ export function CommandCenter() {
</p>
{arrival && !isCancelled && !isLanded && (
<p className="text-[10px] text-muted-foreground">
{new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
{formatTime(new Date(arrival))}
</p>
)}
</div>

View File

@@ -1,11 +1,14 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Users, Car, Plane, Clock, AlertTriangle } from 'lucide-react';
import { Users, Car, Plane, Clock, AlertTriangle, Link2 } from 'lucide-react';
import { VIP, Driver, ScheduleEvent, Flight } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import { FlightProgressBar } from '@/components/FlightProgressBar';
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
export function Dashboard() {
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
const { data: vips } = useQuery<VIP[]>({
queryKey: ['vips'],
queryFn: async () => {
@@ -66,6 +69,25 @@ export function Dashboard() {
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
.slice(0, 5) || [];
const journeys = useMemo(() => {
if (!flights || flights.length === 0) return [];
return groupFlightsIntoJourneys(flights);
}, [flights]);
const upcomingJourneys = useMemo(() => {
return journeys
.filter(j => j.effectiveStatus !== 'cancelled' && j.effectiveStatus !== 'landed')
.sort((a, b) => {
// Active journeys first, then by ETA
if (a.effectiveStatus === 'active' && b.effectiveStatus !== 'active') return -1;
if (b.effectiveStatus === 'active' && a.effectiveStatus !== 'active') return 1;
const etaA = a.flights[a.currentSegmentIndex]?.estimatedArrival || a.flights[a.currentSegmentIndex]?.scheduledArrival || '';
const etaB = b.flights[b.currentSegmentIndex]?.estimatedArrival || b.flights[b.currentSegmentIndex]?.scheduledArrival || '';
return etaA.localeCompare(etaB);
})
.slice(0, 5);
}, [journeys]);
const stats = [
{
name: 'Total VIPs',
@@ -184,18 +206,22 @@ export function Dashboard() {
Flight Status
</h2>
{/* Status summary */}
{flights && flights.length > 0 && (
{/* Journey status summary */}
{journeys.length > 0 && (
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-border">
{(() => {
const inFlight = flights.filter(f => f.status?.toLowerCase() === 'active').length;
const delayed = flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length;
const cancelled = flights.filter(f => f.status?.toLowerCase() === 'cancelled').length;
const landed = flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length;
const scheduled = flights.filter(f => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(f.status?.toLowerCase() || '') && !f.actualArrival).length;
const inFlight = journeys.filter(j => j.effectiveStatus === 'active').length;
const connectionRisk = journeys.filter(j => j.hasLayoverRisk).length;
const cancelled = journeys.filter(j => j.effectiveStatus === 'cancelled').length;
const landed = journeys.filter(j => j.effectiveStatus === 'landed').length;
const scheduled = journeys.filter(j => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(j.effectiveStatus)).length;
return (
<>
<span className="flex items-center gap-1.5 text-sm">
<span className="font-medium">{journeys.length}</span>
<span className="text-muted-foreground">journeys</span>
</span>
{inFlight > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
@@ -203,11 +229,11 @@ export function Dashboard() {
<span className="text-muted-foreground">in flight</span>
</span>
)}
{delayed > 0 && (
{connectionRisk > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span className="font-medium text-amber-600 dark:text-amber-400">{delayed}</span>
<span className="text-muted-foreground">delayed</span>
<span className="w-2 h-2 rounded-full bg-orange-500" />
<span className="font-medium text-orange-600 dark:text-orange-400">{connectionRisk}</span>
<span className="text-muted-foreground">at risk</span>
</span>
)}
{cancelled > 0 && (
@@ -233,35 +259,43 @@ export function Dashboard() {
</div>
)}
{/* Arriving soon flights */}
{upcomingFlights.length > 0 ? (
{/* Upcoming journeys */}
{upcomingJourneys.length > 0 ? (
<div className="space-y-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Arriving Soon
Active & Upcoming Journeys
</h3>
{upcomingFlights.map((flight) => {
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
const eta = flight.estimatedArrival || flight.scheduledArrival;
const borderColor = delay > 30 ? 'border-amber-500' :
flight.status?.toLowerCase() === 'active' ? 'border-purple-500' :
flight.status?.toLowerCase() === 'cancelled' ? 'border-red-500' :
{upcomingJourneys.map((journey) => {
const currentFlight = journey.flights[journey.currentSegmentIndex];
const lastFlight = journey.flights[journey.flights.length - 1];
const delay = Math.max(currentFlight?.arrivalDelay || 0, currentFlight?.departureDelay || 0);
const routeChain = journey.flights.map(f => f.departureAirport).concat([lastFlight.arrivalAirport]).join(' → ');
const borderColor = journey.hasLayoverRisk ? 'border-orange-500' :
delay > 30 ? 'border-amber-500' :
journey.effectiveStatus === 'active' ? 'border-purple-500' :
journey.effectiveStatus === 'cancelled' ? 'border-red-500' :
'border-indigo-500';
return (
<div
key={flight.id}
key={journey.vipId}
className={`border-l-4 ${borderColor} pl-4 py-2 hover:bg-accent transition-colors rounded-r`}
>
<div className="flex justify-between items-start mb-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{flight.flightNumber}</span>
{flight.airlineName && (
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
<span className="text-sm font-medium text-foreground">{journey.vip?.name || 'Unknown'}</span>
{journey.isMultiSegment && (
<span className="text-xs text-muted-foreground">{journey.flights.length} legs</span>
)}
<span className="text-muted-foreground/50">|</span>
<span className="text-sm text-foreground/80">{flight.vip?.name}</span>
</div>
<div className="flex items-center gap-2">
{journey.hasLayoverRisk && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
<Link2 className="w-3 h-3" />
Connection risk
</span>
)}
{delay > 15 && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<AlertTriangle className="w-3 h-3" />
@@ -269,24 +303,51 @@ export function Dashboard() {
</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full ${
flight.status?.toLowerCase() === 'active' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
flight.status?.toLowerCase() === 'cancelled' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' :
journey.effectiveStatus === 'active' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
journey.effectiveStatus === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
journey.effectiveStatus === 'cancelled' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' :
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
{flight.status || 'scheduled'}
{journey.effectiveStatus}
</span>
</div>
</div>
<FlightProgressBar flight={flight} compact />
{/* Route chain */}
<div className="text-xs text-muted-foreground mb-1">
{routeChain}
{currentFlight && (
<span className="ml-2 text-foreground/60">
{currentFlight.flightNumber}
{currentFlight.airlineName && ` · ${currentFlight.airlineName}`}
</span>
)}
</div>
{/* Terminal/gate info for arriving flights */}
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
{/* Progress bar for current segment */}
{currentFlight && <FlightProgressBar flight={currentFlight} compact />}
{/* Layover risk info */}
{journey.hasLayoverRisk && journey.layovers.filter(l => l.risk === 'warning' || l.risk === 'critical' || l.risk === 'missed').map((layover, idx) => (
<div key={idx} className={`flex items-center gap-1 mt-1 text-xs ${
layover.risk === 'missed' ? 'text-red-600 dark:text-red-400' :
layover.risk === 'critical' ? 'text-red-600 dark:text-red-400' :
'text-amber-600 dark:text-amber-400'
}`}>
<AlertTriangle className="w-3 h-3" />
{layover.airport}: {layover.risk === 'missed'
? `Connection missed (${formatLayoverDuration(layover.effectiveMinutes)})`
: `Layover ${formatLayoverDuration(layover.effectiveMinutes)} (was ${formatLayoverDuration(layover.scheduledMinutes)})`
}
</div>
))}
{/* Terminal/gate info for arriving current segment */}
{currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
{flight.arrivalTerminal && <span>Terminal {flight.arrivalTerminal}</span>}
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
{flight.arrivalBaggage && <span>Baggage {flight.arrivalBaggage}</span>}
{currentFlight.arrivalTerminal && <span>Terminal {currentFlight.arrivalTerminal}</span>}
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
{currentFlight.arrivalBaggage && <span>Baggage {currentFlight.arrivalBaggage}</span>}
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import {
User,
Phone,
@@ -244,6 +245,7 @@ export function DriverProfile() {
}
function GpsStatsSection() {
const { formatDate, formatDateTime } = useFormattedDate();
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
const updateConsent = useUpdateGpsConsent();
@@ -355,7 +357,7 @@ function GpsStatsSection() {
) : gpsStats ? (
<div className="p-6">
<p className="text-sm text-muted-foreground mb-4">
Stats for the last 7 days ({new Date(gpsStats.period.from).toLocaleDateString()} - {new Date(gpsStats.period.to).toLocaleDateString()})
Stats for the last 7 days ({formatDate(gpsStats.period.from)} - {formatDate(gpsStats.period.to)})
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
@@ -386,7 +388,7 @@ function GpsStatsSection() {
{gpsStats.stats.topSpeedTimestamp && (
<p className="text-xs text-muted-foreground mt-4 text-center">
Top speed recorded on {new Date(gpsStats.stats.topSpeedTimestamp).toLocaleString()}
Top speed recorded on {formatDateTime(gpsStats.stats.topSpeedTimestamp)}
</p>
)}
</div>

View File

@@ -4,11 +4,11 @@ import { useLocation, useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { ScheduleEvent, EventType } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { EventForm, EventFormData } from '@/components/EventForm';
import { Loading } from '@/components/Loading';
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
import { useFormattedDate } from '@/hooks/useFormattedDate';
type ActivityFilter = 'ALL' | EventType;
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
@@ -18,6 +18,7 @@ export function EventList() {
const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

View File

@@ -13,6 +13,7 @@ import {
Clock,
ChevronDown,
ChevronRight,
Link2,
} from 'lucide-react';
import { FlightForm, FlightFormData } from '@/components/FlightForm';
import { FlightCard } from '@/components/FlightCard';
@@ -22,95 +23,117 @@ import { FilterModal } from '@/components/FilterModal';
import { FilterChip } from '@/components/FilterChip';
import { useDebounce } from '@/hooks/useDebounce';
import { useFlights, useFlightBudget, useRefreshActiveFlights } from '@/hooks/useFlights';
import { Flight } from '@/types';
import { Flight, Journey } from '@/types';
import { groupFlightsIntoJourneys } from '@/lib/journeyUtils';
type FlightGroup = {
type JourneyGroup = {
key: string;
label: string;
icon: typeof AlertTriangle;
flights: Flight[];
journeys: Journey[];
color: string;
defaultCollapsed?: boolean;
};
function groupFlights(flights: Flight[]): FlightGroup[] {
function getJourneyEta(j: Journey): string {
// Use the current/last segment's best available arrival time
const seg = j.flights[j.currentSegmentIndex] || j.flights[j.flights.length - 1];
return seg.estimatedArrival || seg.scheduledArrival || '';
}
function getJourneyDeparture(j: Journey): string {
// Use the first non-landed segment's departure, or first segment
for (const f of j.flights) {
if (f.status?.toLowerCase() !== 'landed' && !f.actualArrival) {
return f.estimatedDeparture || f.scheduledDeparture || '';
}
}
return j.flights[0]?.scheduledDeparture || j.flights[0]?.flightDate || '';
}
function getJourneyMaxDelay(j: Journey): number {
return Math.max(...j.flights.map(f => Math.max(f.arrivalDelay || 0, f.departureDelay || 0)));
}
function groupJourneys(journeys: Journey[]): JourneyGroup[] {
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
const groups: FlightGroup[] = [
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, flights: [], color: 'text-red-500' },
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, flights: [], color: 'text-purple-500' },
{ key: 'active', label: 'In Flight', icon: Plane, flights: [], color: 'text-purple-500' },
{ key: 'departing', label: 'Departing Soon', icon: Clock, flights: [], color: 'text-blue-500' },
{ key: 'scheduled', label: 'Scheduled', icon: Clock, flights: [], color: 'text-muted-foreground' },
{ key: 'completed', label: 'Completed', icon: Plane, flights: [], color: 'text-emerald-500', defaultCollapsed: true },
const groups: JourneyGroup[] = [
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, journeys: [], color: 'text-red-500' },
{ key: 'connection-risk', label: 'Connection Risk', icon: Link2, journeys: [], color: 'text-orange-500' },
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, journeys: [], color: 'text-purple-500' },
{ key: 'active', label: 'In Flight', icon: Plane, journeys: [], color: 'text-purple-500' },
{ key: 'departing', label: 'Departing Soon', icon: Clock, journeys: [], color: 'text-blue-500' },
{ key: 'scheduled', label: 'Scheduled', icon: Clock, journeys: [], color: 'text-muted-foreground' },
{ key: 'completed', label: 'Completed', icon: Plane, journeys: [], color: 'text-emerald-500', defaultCollapsed: true },
];
for (const flight of flights) {
const status = flight.status?.toLowerCase();
const eta = flight.estimatedArrival || flight.scheduledArrival;
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
for (const journey of journeys) {
const status = journey.effectiveStatus;
// Connection risk: any journey with layover risk (separate from alerts)
if (journey.hasLayoverRisk) {
groups[1].journeys.push(journey);
// Also place in the appropriate status group below (don't continue)
}
// Alerts: cancelled, diverted, incident
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
groups[0].flights.push(flight);
groups[0].journeys.push(journey);
continue;
}
// Completed: landed
if (status === 'landed' || flight.actualArrival) {
groups[5].flights.push(flight);
// Completed: all segments landed
if (status === 'landed') {
groups[6].journeys.push(journey);
continue;
}
// Arriving soon: active flight landing within 2h
const eta = getJourneyEta(journey);
const departure = getJourneyDeparture(journey);
// Arriving soon: active journey with final arrival within 2h
if (status === 'active' && eta && new Date(eta) <= twoHoursFromNow) {
groups[1].flights.push(flight);
groups[2].journeys.push(journey);
continue;
}
// In flight: active
if (status === 'active') {
groups[2].flights.push(flight);
groups[3].journeys.push(journey);
continue;
}
// Departing soon: departure within 4h
// Departing soon: next departure within 4h
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
groups[3].flights.push(flight);
groups[4].journeys.push(journey);
continue;
}
// Everything else is scheduled
groups[4].flights.push(flight);
groups[5].journeys.push(journey);
}
// Sort within groups
groups[0].flights.sort((a, b) => (b.arrivalDelay || 0) - (a.arrivalDelay || 0)); // Worst first
groups[1].flights.sort((a, b) => {
const etaA = a.estimatedArrival || a.scheduledArrival || '';
const etaB = b.estimatedArrival || b.scheduledArrival || '';
return etaA.localeCompare(etaB);
groups[0].journeys.sort((a, b) => getJourneyMaxDelay(b) - getJourneyMaxDelay(a));
groups[1].journeys.sort((a, b) => {
// Worst risk first: missed > critical > warning
const riskOrder = { missed: 0, critical: 1, warning: 2, ok: 3, none: 4 };
const worstA = Math.min(...a.layovers.map(l => riskOrder[l.risk] ?? 4));
const worstB = Math.min(...b.layovers.map(l => riskOrder[l.risk] ?? 4));
return worstA - worstB;
});
groups[2].flights.sort((a, b) => {
const etaA = a.estimatedArrival || a.scheduledArrival || '';
const etaB = b.estimatedArrival || b.scheduledArrival || '';
return etaA.localeCompare(etaB);
});
groups[3].flights.sort((a, b) => {
const depA = a.estimatedDeparture || a.scheduledDeparture || '';
const depB = b.estimatedDeparture || b.scheduledDeparture || '';
return depA.localeCompare(depB);
});
groups[4].flights.sort((a, b) => {
const depA = a.scheduledDeparture || a.flightDate;
const depB = b.scheduledDeparture || b.flightDate;
return depA.localeCompare(depB);
});
groups[5].flights.sort((a, b) => {
const arrA = a.actualArrival || a.scheduledArrival || '';
const arrB = b.actualArrival || b.scheduledArrival || '';
groups[2].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
groups[3].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
groups[4].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
groups[5].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
groups[6].journeys.sort((a, b) => {
const lastA = a.flights[a.flights.length - 1];
const lastB = b.flights[b.flights.length - 1];
const arrA = lastA.actualArrival || lastA.scheduledArrival || '';
const arrB = lastB.actualArrival || lastB.scheduledArrival || '';
return arrB.localeCompare(arrA); // Most recent first
});
@@ -204,7 +227,7 @@ export function FlightList() {
},
});
// Filter flights
// Filter flights, then group into journeys
const filteredFlights = useMemo(() => {
if (!flights) return [];
@@ -223,7 +246,20 @@ export function FlightList() {
});
}, [flights, debouncedSearchTerm, selectedStatuses]);
const flightGroups = useMemo(() => groupFlights(filteredFlights), [filteredFlights]);
// Group filtered flights into journeys - if any segment of a journey matches search,
// we include the full journey (all segments for that VIP)
const journeys = useMemo(() => {
if (!flights || filteredFlights.length === 0) return [];
// Get VIP IDs that have at least one matching flight
const matchingVipIds = new Set(filteredFlights.map(f => f.vipId));
// Build journeys from ALL flights for matching VIPs (so we don't lose segments)
const allMatchingFlights = flights.filter(f => matchingVipIds.has(f.vipId));
return groupFlightsIntoJourneys(allMatchingFlights);
}, [flights, filteredFlights]);
const journeyGroups = useMemo(() => groupJourneys(journeys), [journeys]);
const toggleGroup = (key: string) => {
setCollapsedGroups(prev => {
@@ -295,17 +331,23 @@ export function FlightList() {
setIsSubmitting(false);
};
// Stats
const stats = useMemo(() => {
if (!flights) return { active: 0, delayed: 0, onTime: 0, landed: 0 };
return {
active: flights.filter(f => f.status?.toLowerCase() === 'active').length,
delayed: flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length,
onTime: flights.filter(f => f.status === 'scheduled' && !(f.departureDelay && f.departureDelay > 15)).length,
landed: flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length,
};
// Stats based on all journeys (not just filtered)
const allJourneys = useMemo(() => {
if (!flights) return [];
return groupFlightsIntoJourneys(flights);
}, [flights]);
const stats = useMemo(() => {
if (!allJourneys.length) return { active: 0, delayed: 0, connectionRisk: 0, landed: 0, total: 0 };
return {
active: allJourneys.filter(j => j.effectiveStatus === 'active').length,
delayed: allJourneys.filter(j => j.flights.some(f => (f.arrivalDelay || f.departureDelay || 0) > 15)).length,
connectionRisk: allJourneys.filter(j => j.hasLayoverRisk).length,
landed: allJourneys.filter(j => j.effectiveStatus === 'landed').length,
total: allJourneys.length,
};
}, [allJourneys]);
if (isLoading) {
return (
<div>
@@ -333,21 +375,27 @@ export function FlightList() {
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Flight Tracking</h1>
{flights && flights.length > 0 && (
{allJourneys.length > 0 && (
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span>{stats.total} journeys</span>
{stats.active > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
{stats.active} in flight
</span>
)}
{stats.connectionRisk > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-orange-500" />
{stats.connectionRisk} at risk
</span>
)}
{stats.delayed > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500" />
{stats.delayed} delayed
</span>
)}
<span>{stats.onTime} scheduled</span>
<span>{stats.landed} landed</span>
</div>
)}
@@ -355,7 +403,7 @@ export function FlightList() {
<div className="flex items-center gap-3">
<BudgetIndicator />
{flights && flights.length > 0 && (
{allJourneys.length > 0 && (
<button
onClick={() => refreshActiveMutation.mutate()}
disabled={refreshActiveMutation.isPending}
@@ -376,7 +424,7 @@ export function FlightList() {
</div>
{/* Search and Filter */}
{flights && flights.length > 0 && (
{allJourneys.length > 0 && (
<div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
<div className="flex gap-3">
<div className="flex-1 relative">
@@ -420,7 +468,7 @@ export function FlightList() {
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{filteredFlights.length}</span> of <span className="font-medium text-foreground">{flights?.length || 0}</span> flights
Showing <span className="font-medium text-foreground">{journeys.length}</span> of <span className="font-medium text-foreground">{allJourneys.length}</span> journeys
</div>
{(searchTerm || selectedStatuses.length > 0) && (
<button
@@ -435,11 +483,11 @@ export function FlightList() {
</div>
)}
{/* Flight Groups */}
{flights && flights.length > 0 ? (
{/* Journey Groups */}
{allJourneys.length > 0 ? (
<div className="space-y-6">
{flightGroups.map((group) => {
if (group.flights.length === 0) return null;
{journeyGroups.map((group) => {
if (group.journeys.length === 0) return null;
const isCollapsed = collapsedGroups.has(group.key);
const Icon = group.icon;
@@ -460,18 +508,19 @@ export function FlightList() {
{group.label}
</span>
<span className="text-xs text-muted-foreground font-normal">
({group.flights.length})
({group.journeys.length})
</span>
<div className="flex-1 border-t border-border/50 ml-2" />
</button>
{/* Flight cards */}
{/* Journey cards */}
{!isCollapsed && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{group.flights.map((flight) => (
{group.journeys.map((journey) => (
<FlightCard
key={flight.id}
flight={flight}
key={journey.vipId}
journey={journey.isMultiSegment ? journey : undefined}
flight={journey.isMultiSegment ? undefined : journey.flights[0]}
onEdit={handleEdit}
onDelete={handleDelete}
/>

View File

@@ -39,6 +39,7 @@ import {
useTraccarSetupStatus,
useTraccarSetup,
useOpenTraccarAdmin,
useDeviceQr,
} from '@/hooks/useGps';
import { Loading } from '@/components/Loading';
import { ErrorMessage } from '@/components/ErrorMessage';
@@ -113,6 +114,7 @@ export function GpsTracking() {
const [searchTerm, setSearchTerm] = useState('');
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
// Check admin access
if (backendUser?.role !== 'ADMINISTRATOR') {
@@ -134,6 +136,7 @@ export function GpsTracking() {
const { data: devices, isLoading: devicesLoading } = useGpsDevices();
const { data: traccarStatus } = useTraccarSetupStatus();
const { data: driverStats } = useDriverStats(selectedDriverId);
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
// Mutations
const updateSettings = useUpdateGpsSettings();
@@ -491,7 +494,15 @@ export function GpsTracking() {
<td className="px-4 py-3 text-sm text-muted-foreground">
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3 flex items-center gap-2">
<button
onClick={() => setShowQrDriverId(device.driverId)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
title="Show QR code"
>
<QrCode className="h-4 w-4 mr-1" />
QR
</button>
<button
onClick={() => {
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
@@ -603,18 +614,21 @@ export function GpsTracking() {
</label>
<input
type="number"
min={30}
min={10}
max={300}
defaultValue={settings.updateIntervalSeconds}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value >= 30 && value <= 300 && value !== settings.updateIntervalSeconds) {
if (value >= 10 && value <= 300 && value !== settings.updateIntervalSeconds) {
updateSettings.mutate({ updateIntervalSeconds: value });
}
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary"
/>
<p className="mt-1 text-xs text-muted-foreground">How often drivers report their location (30-300 seconds)</p>
<p className="mt-1 text-xs text-muted-foreground">How often drivers report their location (10-300 seconds)</p>
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-800 dark:text-amber-200">
<strong>Tip:</strong> 15-30s recommended for active events (smooth routes), 60s for routine use (saves battery). Changing this only affects new QR code enrollments.
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
@@ -871,6 +885,129 @@ export function GpsTracking() {
</div>
</div>
)}
{/* Device QR Code Modal */}
{showQrDriverId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center p-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">
{qrInfo ? `${qrInfo.driverName} - Setup QR` : 'Device QR Code'}
</h3>
<button
onClick={() => setShowQrDriverId(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
{qrLoading ? (
<Loading message="Loading QR code..." />
) : qrInfo ? (
<>
{/* QR Code */}
<div className="bg-white dark:bg-gray-900 p-4 rounded-lg border border-border text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<QrCode className="h-5 w-5 text-primary" />
<span className="text-sm font-medium">Scan with Traccar Client</span>
</div>
<div className="flex justify-center mb-3">
<QRCodeSVG
value={qrInfo.qrCodeUrl}
size={200}
level="M"
includeMargin
/>
</div>
<p className="text-xs text-muted-foreground">
Open Traccar Client app {''} tap the QR icon {''} scan this code
</p>
</div>
{/* Download links */}
<div className="bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">Download Traccar Client</span>
</div>
<div className="flex gap-2 justify-center">
<a
href="https://apps.apple.com/app/traccar-client/id843156974"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
iOS App Store
</a>
<a
href="https://play.google.com/store/apps/details?id=org.traccar.client"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
>
Google Play
</a>
</div>
</div>
{/* Manual fallback */}
<details className="bg-muted/30 rounded-lg border border-border">
<summary className="p-3 text-sm font-medium cursor-pointer hover:bg-muted/50 transition-colors">
Manual Setup (if QR doesn't work)
</summary>
<div className="px-3 pb-3 space-y-2">
<div>
<label className="text-xs text-muted-foreground">Device ID</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono">
{qrInfo.deviceIdentifier}
</code>
<button
onClick={() => copyToClipboard(qrInfo.deviceIdentifier)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Server URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono text-xs">
{qrInfo.serverUrl}
</code>
<button
onClick={() => copyToClipboard(qrInfo.serverUrl)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground mt-2">
<li>Open Traccar Client and enter Device ID and Server URL above</li>
<li>Set frequency to {qrInfo.updateIntervalSeconds} seconds</li>
<li>Tap "Service Status" to start tracking</li>
</ol>
</div>
</details>
</>
) : (
<ErrorMessage message="Failed to load QR code info" />
)}
<button
onClick={() => setShowQrDriverId(null)}
className="w-full px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
>
Done
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import toast from 'react-hot-toast';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import {
Calendar,
Clock,
@@ -44,6 +45,8 @@ interface DriverWithSchedule {
}
export function MySchedule() {
const { formatDate, formatTime } = useFormattedDate();
const { data: profile, isLoading, error } = useQuery<DriverWithSchedule>({
queryKey: ['my-driver-profile'],
queryFn: async () => {
@@ -123,31 +126,6 @@ export function MySchedule() {
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
.slice(0, 5); // Last 5 completed
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
return 'Tomorrow';
}
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'IN_PROGRESS':

View File

@@ -1,7 +1,11 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { pdf } from '@react-pdf/renderer';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import { AccountabilityRosterPDF } from '@/components/AccountabilityRosterPDF';
import { usePdfSettings } from '@/hooks/useSettings';
import toast from 'react-hot-toast';
import {
FileText,
Users,
@@ -13,6 +17,10 @@ import {
Download,
UserCheck,
ClipboardList,
Send,
MessageCircle,
X,
Loader2,
} from 'lucide-react';
interface VIP {
@@ -36,6 +44,10 @@ export function Reports() {
const [activeReport, setActiveReport] = useState<ReportType>('accountability');
const [searchTerm, setSearchTerm] = useState('');
const [departmentFilter, setDepartmentFilter] = useState<string>('all');
const [showSignalModal, setShowSignalModal] = useState(false);
const [signalPhoneNumber, setSignalPhoneNumber] = useState('');
const [signalMessage, setSignalMessage] = useState('');
const [isSendingSignal, setIsSendingSignal] = useState(false);
const { data: vips, isLoading } = useQuery<VIP[]>({
queryKey: ['vips'],
@@ -45,6 +57,8 @@ export function Reports() {
},
});
const { data: pdfSettings } = usePdfSettings();
const reports = [
{
id: 'accountability' as const,
@@ -52,9 +66,6 @@ export function Reports() {
icon: ClipboardList,
description: 'Complete list of all personnel for emergency preparedness',
},
// Future reports can be added here:
// { id: 'schedule-summary', name: 'Schedule Summary', icon: Calendar, description: '...' },
// { id: 'driver-assignments', name: 'Driver Assignments', icon: Car, description: '...' },
];
// Filter VIPs based on search and department
@@ -125,6 +136,90 @@ export function Reports() {
link.click();
};
// Export to PDF
const handleExportPDF = async () => {
if (!filteredVips) return;
try {
const blob = await pdf(
<AccountabilityRosterPDF
vips={filteredVips}
settings={pdfSettings}
/>
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const now = new Date();
const timestamp = now.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }).replace(' ', '') +
'_' + now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).replace(':', '');
link.download = `Accountability_Roster_${timestamp}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('PDF downloaded');
} catch (error) {
console.error('[PDF] Generation failed:', error);
toast.error('Failed to generate PDF. Please try again.');
}
};
// Send PDF via Signal
const handleSendViaSignal = async () => {
if (!filteredVips || !signalPhoneNumber.trim()) return;
setIsSendingSignal(true);
try {
const pdfBlob = await pdf(
<AccountabilityRosterPDF
vips={filteredVips}
settings={pdfSettings}
/>
).toBlob();
// Convert blob to base64
const reader = new FileReader();
const base64Promise = new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
const base64 = (reader.result as string).split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
reader.readAsDataURL(pdfBlob);
const base64Data = await base64Promise;
const now = new Date();
const timestamp = now.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }).replace(' ', '') +
'_' + now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).replace(':', '');
const filename = `Accountability_Roster_${timestamp}.pdf`;
const response = await api.post('/signal/send-attachment', {
to: signalPhoneNumber,
message: signalMessage || 'Accountability Roster attached',
attachment: base64Data,
filename,
mimeType: 'application/pdf',
});
if (response.data.success) {
toast.success('Roster sent via Signal!');
setShowSignalModal(false);
setSignalPhoneNumber('');
setSignalMessage('');
} else {
toast.error(response.data.error || 'Failed to send via Signal');
}
} catch (error: any) {
console.error('[Signal] Failed to send:', error);
toast.error(error.response?.data?.message || 'Failed to send via Signal');
} finally {
setIsSendingSignal(false);
}
};
if (isLoading) {
return <Loading message="Loading report data..." />;
}
@@ -237,13 +332,29 @@ export function Reports() {
<option value="OTHER">Other</option>
</select>
</div>
<div className="flex gap-2">
<button
onClick={handleExportCSV}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 border border-input rounded-lg text-foreground hover:bg-accent transition-colors"
>
<Download className="h-4 w-4" />
Export CSV
CSV
</button>
<button
onClick={handleExportPDF}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<FileText className="h-4 w-4" />
PDF
</button>
<button
onClick={() => setShowSignalModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Send className="h-4 w-4" />
Signal
</button>
</div>
</div>
{/* Active VIPs Table */}
@@ -452,6 +563,101 @@ export function Reports() {
)}
</div>
)}
{/* Signal Send Modal */}
{showSignalModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Send Roster via Signal
</h2>
<button
onClick={() => {
setShowSignalModal(false);
setSignalPhoneNumber('');
setSignalMessage('');
}}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
<p className="text-sm text-muted-foreground">
Send the Accountability Roster PDF directly to a phone via Signal.
</p>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Phone Number *
</label>
<input
type="tel"
value={signalPhoneNumber}
onChange={(e) => setSignalPhoneNumber(e.target.value)}
placeholder="+1 (555) 123-4567"
className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p className="text-xs text-muted-foreground mt-1">
Include country code (e.g., +1 for US)
</p>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Message (optional)
</label>
<textarea
value={signalMessage}
onChange={(e) => setSignalMessage(e.target.value)}
placeholder="Accountability Roster attached"
rows={2}
className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
/>
</div>
<div className="bg-muted/50 p-3 rounded-lg">
<p className="text-xs text-muted-foreground">
<strong>Attachment:</strong> Accountability_Roster_[timestamp].pdf
</p>
</div>
</div>
<div className="flex gap-3 p-4 border-t border-border">
<button
onClick={() => {
setShowSignalModal(false);
setSignalPhoneNumber('');
setSignalMessage('');
}}
className="flex-1 px-4 py-2 border border-input rounded-lg text-foreground hover:bg-accent transition-colors"
>
Cancel
</button>
<button
onClick={handleSendViaSignal}
disabled={!signalPhoneNumber.trim() || isSendingSignal}
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{isSendingSignal ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="h-4 w-4" />
Send PDF
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { api } from '@/lib/api';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
import { useState } from 'react';
import { Loading } from '@/components/Loading';
@@ -20,6 +21,7 @@ interface User {
export function UserList() {
const queryClient = useQueryClient();
const { formatDate } = useFormattedDate();
const [processingUser, setProcessingUser] = useState<string | null>(null);
const { data: users, isLoading } = useQuery<User[]>({
@@ -168,7 +170,7 @@ export function UserList() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
{formatDate(user.createdAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-2">

View File

@@ -8,6 +8,7 @@ import { EventForm, EventFormData } from '@/components/EventForm';
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
import { ScheduleEvent } from '@/types';
import { usePdfSettings } from '@/hooks/useSettings';
import { useFormattedDate } from '@/hooks/useFormattedDate';
import {
ArrowLeft,
Calendar,
@@ -52,6 +53,7 @@ export function VIPSchedule() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
// State for edit modal
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
@@ -169,12 +171,7 @@ export function VIPSchedule() {
// Group events by day
const eventsByDay = sortedEvents.reduce((acc, event) => {
const date = new Date(event.startTime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const date = formatDate(event.startTime);
if (!acc[date]) {
acc[date] = [];
}
@@ -211,13 +208,6 @@ export function VIPSchedule() {
}
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
};
const handleExport = async () => {
if (!vip) return;
@@ -362,13 +352,7 @@ export function VIPSchedule() {
<div>
<p className="text-sm text-muted-foreground mb-1">Expected Arrival</p>
<p className="font-medium text-foreground">
{new Date(vip.expectedArrival).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
{formatDateTime(vip.expectedArrival)}
</p>
</div>
)}
@@ -399,12 +383,7 @@ export function VIPSchedule() {
{flight.scheduledArrival && (
<p className="text-sm text-blue-900 dark:text-blue-100">
Arrives:{' '}
{new Date(flight.scheduledArrival).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
{formatDateTime(flight.scheduledArrival)}
</p>
)}
{flight.status && (

View File

@@ -88,6 +88,14 @@ export interface EnrollmentResponse {
signalMessageSent?: boolean;
}
export interface DeviceQrInfo {
driverName: string;
deviceIdentifier: string;
serverUrl: string;
qrCodeUrl: string;
updateIntervalSeconds: number;
}
export interface MyGpsStatus {
enrolled: boolean;
driverId?: string;

View File

@@ -210,3 +210,27 @@ export interface FlightBudget {
remaining: number;
month: string;
}
// Multi-segment journey types
export type LayoverRiskLevel = 'none' | 'ok' | 'warning' | 'critical' | 'missed';
export interface Layover {
airport: string;
afterSegmentIndex: number;
scheduledMinutes: number;
effectiveMinutes: number;
risk: LayoverRiskLevel;
}
export interface Journey {
vipId: string;
vip: VIP | undefined;
flights: Flight[];
layovers: Layover[];
effectiveStatus: string;
currentSegmentIndex: number;
hasLayoverRisk: boolean;
origin: string;
destination: string;
isMultiSegment: boolean;
}