diff --git a/backend/prisma/migrations/20260208000000_add_gps_location_unique_constraint/migration.sql b/backend/prisma/migrations/20260208000000_add_gps_location_unique_constraint/migration.sql new file mode 100644 index 0000000..3a5d1cd --- /dev/null +++ b/backend/prisma/migrations/20260208000000_add_gps_location_unique_constraint/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9d3729c..223fbe0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 } diff --git a/backend/src/gps/gps.controller.ts b/backend/src/gps/gps.controller.ts index 5738a7c..8ebf708 100644 --- a/backend/src/gps/gps.controller.ts +++ b/backend/src/gps/gps.controller.ts @@ -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 */ diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index 3fb13b2..f20fb2a 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -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('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 { 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; + for (const device of devices) { + 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) }, - }); - - // 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), - }, + data: { lastActive: new Date(latestPosition.deviceTime) }, }); + } catch (error) { + this.logger.error(`Failed to sync positions for device ${device.traccarDeviceId}: ${error}`); } - } catch (error) { - this.logger.error(`Failed to sync positions: ${error}`); } } diff --git a/backend/src/seed/seed.service.ts b/backend/src/seed/seed.service.ts index 86cef68..7025e31 100644 --- a/backend/src/seed/seed.service.ts +++ b/backend/src/seed/seed.service.ts @@ -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(); + 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); + // Helper: create a flight record + const makeFlight = (vipId: string, opts: any) => ({ + vipId, + flightDate: new Date(), + ...opts, + }); + + // ============================================================ + // 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', + })); + } + + // 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 = 'scheduled'; + let status = df.statusOverride || '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'; + if (status === 'landed') { + actualArrival = new Date(scheduledArrival.getTime() + 5 * 60000); } - flights.push({ - vipId: vip.id, - flightNumber: flightNum, - flightDate: new Date(), + 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; } diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts index 9927e24..7d88037 100644 --- a/backend/src/settings/settings.controller.ts +++ b/backend/src/settings/settings.controller.ts @@ -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() { diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts index 71ed158..6bc1402 100644 --- a/backend/src/settings/settings.service.ts +++ b/backend/src/settings/settings.service.ts @@ -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 */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6590871..bcd6b4a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { > + + diff --git a/frontend/src/components/AccountabilityRosterPDF.tsx b/frontend/src/components/AccountabilityRosterPDF.tsx new file mode 100644 index 0000000..dbcb084 --- /dev/null +++ b/frontend/src/components/AccountabilityRosterPDF.tsx @@ -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 = () => ( + + + Name + + + Organization + + + Contact + + + Emergency Contact + + + Party + + + ); + + const renderVipRow = (vip: VIP, index: number, isRoster: boolean) => ( + + + {vip.name} + {formatDepartment(vip.department)} + + + {vip.organization ? ( + {vip.organization} + ) : ( + - + )} + + + {vip.phone && {vip.phone}} + {vip.email && {vip.email}} + {!vip.phone && !vip.email && No contact info} + + + {vip.emergencyContactName ? ( + <> + {vip.emergencyContactName} + {vip.emergencyContactPhone && ( + {vip.emergencyContactPhone} + )} + + ) : ( + Not provided + )} + + + {vip.partySize} + + + ); + + return ( + + + {/* Watermarks */} + {config.showDraftWatermark && ( + + DRAFT + + )} + {config.showConfidentialWatermark && ( + + CONFIDENTIAL + + )} + + {/* Header */} + + {config.logoUrl && ( + + + + )} + + {config.organizationName} + Accountability Roster + Emergency Preparedness & Personnel Tracking + + {config.headerMessage && ( + {config.headerMessage} + )} + + {(config.showTimestamp || config.showAppUrl) && ( + + {config.showTimestamp && ( + Generated: {generatedAt} + )} + {config.showAppUrl && ( + + Latest version: {typeof window !== 'undefined' ? window.location.origin : ''} + + )} + + )} + + + {/* Summary Stats */} + + + {totalPeople} + Total People + + + {activeCount} + Active VIPs + + + {rosterCount} + Roster Only + + + + {/* Active VIPs Table */} + {activeVips.length > 0 && ( + + + Active VIPs ({activeVips.length} entries, {activeCount} people) + + + {renderTableHeader()} + {activeVips.map((vip, i) => renderVipRow(vip, i, false))} + + + )} + + {/* Roster Only Table */} + {rosterOnlyVips.length > 0 && ( + + + Roster Only ({rosterOnlyVips.length} entries, {rosterCount} people) + + + {renderTableHeader()} + {rosterOnlyVips.map((vip, i) => renderVipRow(vip, i, true))} + + + )} + + {/* Empty State */} + {vips.length === 0 && ( + No personnel records found. + )} + + {/* Custom Footer Message */} + {config.footerMessage && ( + + {config.footerMessage} + + )} + + {/* Footer */} + + + + {config.contactLabel} + {config.contactEmail} + {config.contactPhone} + {config.secondaryContactName && ( + + {config.secondaryContactName} + {config.secondaryContactPhone ? ` - ${config.secondaryContactPhone}` : ''} + + )} + + + + `Page ${pageNumber} of ${totalPages}` + } + /> + + + + + + ); +} diff --git a/frontend/src/components/DriverChatModal.tsx b/frontend/src/components/DriverChatModal.tsx index 9bc0e21..8cf0b47 100644 --- a/frontend/src/components/DriverChatModal.tsx +++ b/frontend/src/components/DriverChatModal.tsx @@ -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(null); const inputRef = useRef(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 (
- {formatTime(msg.timestamp)} + {formatDateTime(msg.timestamp)}

diff --git a/frontend/src/components/DriverScheduleModal.tsx b/frontend/src/components/DriverScheduleModal.tsx index 9a4fc9d..5da6fbd 100644 --- a/frontend/src/components/DriverScheduleModal.tsx +++ b/frontend/src/components/DriverScheduleModal.tsx @@ -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': diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index 65d05c0..18ed191 100644 --- a/frontend/src/components/EventForm.tsx +++ b/frontend/src/components/EventForm.tsx @@ -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 ''; diff --git a/frontend/src/components/FlightCard.tsx b/frontend/src/components/FlightCard.tsx index e92d46e..206734b 100644 --- a/frontend/src/components/FlightCard.tsx +++ b/frontend/src/components/FlightCard.tsx @@ -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 ; + } + if (status === 'active') { + return ; + } + if (status === 'cancelled' || status === 'diverted') { + return ; + } + return ; +} + +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 ( +
+
+
+ +
+
+ + {layover.risk === 'missed' ? ( + <>CONNECTION MISSED at {layover.airport} - arrived {formatLayoverDuration(layover.effectiveMinutes)} + ) : ( + <>{formatLayoverDuration(layover.scheduledMinutes)} layover at {layover.airport} + )} + + {layover.risk === 'warning' && layover.effectiveMinutes !== layover.scheduledMinutes && ( + + + now {formatLayoverDuration(layover.effectiveMinutes)} + + )} + {layover.risk === 'critical' && ( + + + only {formatLayoverDuration(layover.effectiveMinutes)} remaining + + )} +
+
+ ); +} + +// ============================================================ +// 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 (
- {/* Alert banner */} {alert && (
@@ -75,22 +138,16 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
)} - {/* Header */}
- {/* Status dot */}
- - {/* Flight number + airline */}
{flight.flightNumber} {flight.airlineName && ( {flight.airlineName} )}
- - {/* VIP name */} {flight.vip && (
| @@ -104,8 +161,6 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
)}
- - {/* Actions */}
{onEdit && ( - )} {onDelete && ( - )} @@ -137,12 +184,10 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
- {/* Progress Bar */}
- {/* Footer - expandable details */}
+ +
+
+ + {/* Terminal/gate info for current segment */} + {isCurrent && (seg.arrivalTerminal || seg.arrivalGate || seg.arrivalBaggage) && ( +
+ {seg.arrivalTerminal && Terminal {seg.arrivalTerminal}} + {seg.arrivalGate && Gate {seg.arrivalGate}} + {seg.arrivalBaggage && Baggage {seg.arrivalBaggage}} +
+ )} + + {/* Expanded details */} + {isExpanded && ( +
+
+
+
Departure
+
+ {seg.scheduledDeparture &&
Scheduled: {formatDateTime(seg.scheduledDeparture)}
} + {seg.actualDeparture &&
Actual: {formatDateTime(seg.actualDeparture)}
} + {seg.departureDelay != null && seg.departureDelay > 0 && ( +
Delay: {seg.departureDelay} min
+ )} + {seg.departureTerminal &&
Terminal: {seg.departureTerminal}
} + {seg.departureGate &&
Gate: {seg.departureGate}
} +
+
+
+
Arrival
+
+ {seg.scheduledArrival &&
Scheduled: {formatDateTime(seg.scheduledArrival)}
} + {seg.actualArrival &&
Actual: {formatDateTime(seg.actualArrival)}
} + {seg.arrivalDelay != null && seg.arrivalDelay > 0 && ( +
Delay: {seg.arrivalDelay} min
+ )} + {seg.arrivalTerminal &&
Terminal: {seg.arrivalTerminal}
} + {seg.arrivalGate &&
Gate: {seg.arrivalGate}
} + {seg.arrivalBaggage &&
Baggage: {seg.arrivalBaggage}
} +
+
+
+
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+ )} +
+
+ ); + })} + + {/* Footer */} +
+ + + Updated {formatRelativeTime(currentFlight?.lastPolledAt)} + + + {journey.effectiveStatus} + +
+
+ ); +} + +// ============================================================ +// 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 ; + } + // Single-segment journey: render as single flight card + return ; + } + + if (flight) { + return ; + } + + return null; +} diff --git a/frontend/src/components/FlightProgressBar.tsx b/frontend/src/components/FlightProgressBar.tsx index c2bd184..eb8183f 100644 --- a/frontend/src/components/FlightProgressBar.tsx +++ b/frontend/src/components/FlightProgressBar.tsx @@ -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'; diff --git a/frontend/src/components/InlineDriverSelector.tsx b/frontend/src/components/InlineDriverSelector.tsx index fe5d19d..4af298a 100644 --- a/frontend/src/components/InlineDriverSelector.tsx +++ b/frontend/src/components/InlineDriverSelector.tsx @@ -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); diff --git a/frontend/src/contexts/TimezoneContext.tsx b/frontend/src/contexts/TimezoneContext.tsx new file mode 100644 index 0000000..d5818df --- /dev/null +++ b/frontend/src/contexts/TimezoneContext.tsx @@ -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({ + 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 ( + mutation.mutate(tz), + }} + > + {children} + + ); +} + +/** + * 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); +} diff --git a/frontend/src/hooks/useFormattedDate.ts b/frontend/src/hooks/useFormattedDate.ts new file mode 100644 index 0000000..6f5abc0 --- /dev/null +++ b/frontend/src/hooks/useFormattedDate.ts @@ -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 }; +} diff --git a/frontend/src/hooks/useGps.ts b/frontend/src/hooks/useGps.ts index 0ddc8ef..da0f84a 100644 --- a/frontend/src/hooks/useGps.ts +++ b/frontend/src/hooks/useGps.ts @@ -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({ + 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, }); } diff --git a/frontend/src/lib/journeyUtils.ts b/frontend/src/lib/journeyUtils.ts new file mode 100644 index 0000000..1725e02 --- /dev/null +++ b/frontend/src/lib/journeyUtils.ts @@ -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(); + 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`; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 4633408..b2dfd05 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 }), }); } diff --git a/frontend/src/pages/AdminTools.tsx b/frontend/src/pages/AdminTools.tsx index 465a914..de7ad8d 100644 --- a/frontend/src/pages/AdminTools.tsx +++ b/frontend/src/pages/AdminTools.tsx @@ -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() { + {/* App Timezone */} +
+
+ +

App Timezone

+
+

+ All dates and times throughout the app will display in this timezone. Set this to match your event location. +

+
+ + + Current: {new Date().toLocaleTimeString('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })} + +
+
+ {/* PDF Customization Settings */} diff --git a/frontend/src/pages/CommandCenter.tsx b/frontend/src/pages/CommandCenter.tsx index 0eee8b3..db97306 100644 --- a/frontend/src/pages/CommandCenter.tsx +++ b/frontend/src/pages/CommandCenter.tsx @@ -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(null); @@ -193,7 +197,7 @@ export function CommandCenter() { }, }); - const { data: flights } = useQuery({ + const { data: flights } = useQuery({ 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 */}
- {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 })}
- {currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} + {currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: timezone })}
@@ -768,7 +805,7 @@ export function CommandCenter() {

ETA

- {new Date(trip.endTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })} + {formatTime(new Date(trip.endTime))}

@@ -874,7 +911,7 @@ export function CommandCenter() { {getTimeUntil(trip.startTime)}

- {new Date(trip.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })} + {formatTime(new Date(trip.startTime))}

@@ -910,15 +947,19 @@ export function CommandCenter() { ) : (
{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 (

{vip.name}

- {delay > 15 && ( + {journey?.hasLayoverRisk && ( + + + risk + + )} + {delay > 15 && !journey?.hasLayoverRisk && ( +{delay}m @@ -958,18 +1012,16 @@ export function CommandCenter() { )}
- {flight && ( - <> - {flight.flightNumber} - {flight.departureAirport} → {flight.arrivalAirport} - + {routeChain} + {journey?.isMultiSegment && ( + {journey.flights.length} legs )}
- {flight && (flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && ( + {currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
- {flight.arrivalTerminal && T{flight.arrivalTerminal}} - {flight.arrivalGate && Gate {flight.arrivalGate}} - {flight.arrivalBaggage && Bag {flight.arrivalBaggage}} + {currentFlight.arrivalTerminal && T{currentFlight.arrivalTerminal}} + {currentFlight.arrivalGate && Gate {currentFlight.arrivalGate}} + {currentFlight.arrivalBaggage && Bag {currentFlight.arrivalBaggage}}
)}
@@ -979,7 +1031,7 @@ export function CommandCenter() {

{arrival && !isCancelled && !isLanded && (

- {new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} + {formatTime(new Date(arrival))}

)}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d046447..fb734cc 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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({ 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 - {/* Status summary */} - {flights && flights.length > 0 && ( + {/* Journey status summary */} + {journeys.length > 0 && (
{(() => { - 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 ( <> + + {journeys.length} + journeys + {inFlight > 0 && ( @@ -203,11 +229,11 @@ export function Dashboard() { in flight )} - {delayed > 0 && ( + {connectionRisk > 0 && ( - - {delayed} - delayed + + {connectionRisk} + at risk )} {cancelled > 0 && ( @@ -233,35 +259,43 @@ export function Dashboard() {
)} - {/* Arriving soon flights */} - {upcomingFlights.length > 0 ? ( + {/* Upcoming journeys */} + {upcomingJourneys.length > 0 ? (

- Arriving Soon + Active & Upcoming Journeys

- {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 (
- {flight.flightNumber} - {flight.airlineName && ( - {flight.airlineName} + {journey.vip?.name || 'Unknown'} + {journey.isMultiSegment && ( + {journey.flights.length} legs )} - | - {flight.vip?.name}
+ {journey.hasLayoverRisk && ( + + + Connection risk + + )} {delay > 15 && ( @@ -269,24 +303,51 @@ export function Dashboard() { )} - {flight.status || 'scheduled'} + {journey.effectiveStatus}
- + {/* Route chain */} +
+ {routeChain} + {currentFlight && ( + + {currentFlight.flightNumber} + {currentFlight.airlineName && ` · ${currentFlight.airlineName}`} + + )} +
- {/* Terminal/gate info for arriving flights */} - {(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && ( + {/* Progress bar for current segment */} + {currentFlight && } + + {/* Layover risk info */} + {journey.hasLayoverRisk && journey.layovers.filter(l => l.risk === 'warning' || l.risk === 'critical' || l.risk === 'missed').map((layover, idx) => ( +
+ + {layover.airport}: {layover.risk === 'missed' + ? `Connection missed (${formatLayoverDuration(layover.effectiveMinutes)})` + : `Layover ${formatLayoverDuration(layover.effectiveMinutes)} (was ${formatLayoverDuration(layover.scheduledMinutes)})` + } +
+ ))} + + {/* Terminal/gate info for arriving current segment */} + {currentFlight && (currentFlight.arrivalTerminal || currentFlight.arrivalGate || currentFlight.arrivalBaggage) && (
- {flight.arrivalTerminal && Terminal {flight.arrivalTerminal}} - {flight.arrivalGate && Gate {flight.arrivalGate}} - {flight.arrivalBaggage && Baggage {flight.arrivalBaggage}} + {currentFlight.arrivalTerminal && Terminal {currentFlight.arrivalTerminal}} + {currentFlight.arrivalGate && Gate {currentFlight.arrivalGate}} + {currentFlight.arrivalBaggage && Baggage {currentFlight.arrivalBaggage}}
)}
diff --git a/frontend/src/pages/DriverProfile.tsx b/frontend/src/pages/DriverProfile.tsx index 3f65378..f2a1d09 100644 --- a/frontend/src/pages/DriverProfile.tsx +++ b/frontend/src/pages/DriverProfile.tsx @@ -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 ? (

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

@@ -386,7 +388,7 @@ function GpsStatsSection() { {gpsStats.stats.topSpeedTimestamp && (

- Top speed recorded on {new Date(gpsStats.stats.topSpeedTimestamp).toLocaleString()} + Top speed recorded on {formatDateTime(gpsStats.stats.topSpeedTimestamp)}

)}
diff --git a/frontend/src/pages/EventList.tsx b/frontend/src/pages/EventList.tsx index 7573bab..0ca7bf3 100644 --- a/frontend/src/pages/EventList.tsx +++ b/frontend/src/pages/EventList.tsx @@ -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(null); const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/frontend/src/pages/FlightList.tsx b/frontend/src/pages/FlightList.tsx index 692f2f0..710f856 100644 --- a/frontend/src/pages/FlightList.tsx +++ b/frontend/src/pages/FlightList.tsx @@ -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 (
@@ -333,21 +375,27 @@ export function FlightList() {

Flight Tracking

- {flights && flights.length > 0 && ( + {allJourneys.length > 0 && (
+ {stats.total} journeys {stats.active > 0 && ( {stats.active} in flight )} + {stats.connectionRisk > 0 && ( + + + {stats.connectionRisk} at risk + + )} {stats.delayed > 0 && ( {stats.delayed} delayed )} - {stats.onTime} scheduled {stats.landed} landed
)} @@ -355,7 +403,7 @@ export function FlightList() {
- {flights && flights.length > 0 && ( + {allJourneys.length > 0 && (
{/* Search and Filter */} - {flights && flights.length > 0 && ( + {allJourneys.length > 0 && (
@@ -420,7 +468,7 @@ export function FlightList() {
- Showing {filteredFlights.length} of {flights?.length || 0} flights + Showing {journeys.length} of {allJourneys.length} journeys
{(searchTerm || selectedStatuses.length > 0) && ( - {/* Flight cards */} + {/* Journey cards */} {!isCollapsed && (
- {group.flights.map((flight) => ( + {group.journeys.map((journey) => ( diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx index 1a9f96c..5c4f800 100644 --- a/frontend/src/pages/GpsTracking.tsx +++ b/frontend/src/pages/GpsTracking.tsx @@ -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(''); const [enrollmentResult, setEnrollmentResult] = useState(null); + const [showQrDriverId, setShowQrDriverId] = useState(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() { {device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'} - + +
@@ -871,6 +885,129 @@ export function GpsTracking() {
)} + + {/* Device QR Code Modal */} + {showQrDriverId && ( +
+
+
+

+ {qrInfo ? `${qrInfo.driverName} - Setup QR` : 'Device QR Code'} +

+ +
+ +
+ {qrLoading ? ( + + ) : qrInfo ? ( + <> + {/* QR Code */} +
+
+ + Scan with Traccar Client +
+
+ +
+

+ Open Traccar Client app {'→'} tap the QR icon {'→'} scan this code +

+
+ + {/* Download links */} +
+
+ + Download Traccar Client +
+ +
+ + {/* Manual fallback */} +
+ + Manual Setup (if QR doesn't work) + +
+
+ +
+ + {qrInfo.deviceIdentifier} + + +
+
+
+ +
+ + {qrInfo.serverUrl} + + +
+
+
    +
  1. Open Traccar Client and enter Device ID and Server URL above
  2. +
  3. Set frequency to {qrInfo.updateIntervalSeconds} seconds
  4. +
  5. Tap "Service Status" to start tracking
  6. +
+
+
+ + ) : ( + + )} + + +
+
+
+ )}
); } diff --git a/frontend/src/pages/MySchedule.tsx b/frontend/src/pages/MySchedule.tsx index da8940c..da96983 100644 --- a/frontend/src/pages/MySchedule.tsx +++ b/frontend/src/pages/MySchedule.tsx @@ -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({ 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': diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 9cc0ce8..c2de5b5 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -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('accountability'); const [searchTerm, setSearchTerm] = useState(''); const [departmentFilter, setDepartmentFilter] = useState('all'); + const [showSignalModal, setShowSignalModal] = useState(false); + const [signalPhoneNumber, setSignalPhoneNumber] = useState(''); + const [signalMessage, setSignalMessage] = useState(''); + const [isSendingSignal, setIsSendingSignal] = useState(false); const { data: vips, isLoading } = useQuery({ 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( + + ).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( + + ).toBlob(); + + // Convert blob to base64 + const reader = new FileReader(); + const base64Promise = new Promise((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 ; } @@ -237,13 +332,29 @@ export function Reports() {
- +
+ + + +
{/* Active VIPs Table */} @@ -452,6 +563,101 @@ export function Reports() { )}
)} + + {/* Signal Send Modal */} + {showSignalModal && ( +
+
+
+

+ + Send Roster via Signal +

+ +
+ +
+

+ Send the Accountability Roster PDF directly to a phone via Signal. +

+ +
+ + 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" + /> +

+ Include country code (e.g., +1 for US) +

+
+ +
+ +