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:
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal file
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Accountability Roster PDF Generator
|
||||
*
|
||||
* Professional roster document for emergency preparedness.
|
||||
* Follows VIPSchedulePDF patterns for consistent styling.
|
||||
*/
|
||||
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Font,
|
||||
Image,
|
||||
} from '@react-pdf/renderer';
|
||||
import { PdfSettings } from '@/types/settings';
|
||||
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: 'Helvetica' },
|
||||
{ src: 'Helvetica-Bold', fontWeight: 'bold' },
|
||||
],
|
||||
});
|
||||
|
||||
interface VIP {
|
||||
id: string;
|
||||
name: string;
|
||||
organization: string | null;
|
||||
department: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
isRosterOnly: boolean;
|
||||
partySize: number;
|
||||
}
|
||||
|
||||
interface AccountabilityRosterPDFProps {
|
||||
vips: VIP[];
|
||||
settings?: PdfSettings | null;
|
||||
}
|
||||
|
||||
const createStyles = (accentColor: string = '#2c3e50', _pageSize: 'LETTER' | 'A4' = 'LETTER') =>
|
||||
StyleSheet.create({
|
||||
page: {
|
||||
padding: 40,
|
||||
paddingBottom: 80,
|
||||
fontSize: 9,
|
||||
fontFamily: 'Helvetica',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#333333',
|
||||
},
|
||||
|
||||
// Watermark
|
||||
watermark: {
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%) rotate(-45deg)',
|
||||
fontSize: 72,
|
||||
color: '#888888',
|
||||
opacity: 0.2,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 0,
|
||||
},
|
||||
|
||||
// Logo
|
||||
logoContainer: {
|
||||
marginBottom: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
maxWidth: 130,
|
||||
maxHeight: 50,
|
||||
objectFit: 'contain',
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
marginBottom: 20,
|
||||
borderBottom: `2 solid ${accentColor}`,
|
||||
paddingBottom: 15,
|
||||
},
|
||||
orgName: {
|
||||
fontSize: 9,
|
||||
color: '#7f8c8d',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 2,
|
||||
marginBottom: 6,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 10,
|
||||
color: '#7f8c8d',
|
||||
},
|
||||
customMessage: {
|
||||
fontSize: 9,
|
||||
color: '#7f8c8d',
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderLeft: `3 solid ${accentColor}`,
|
||||
},
|
||||
timestampBar: {
|
||||
marginTop: 10,
|
||||
paddingTop: 8,
|
||||
borderTop: '1 solid #ecf0f1',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 7,
|
||||
color: '#95a5a6',
|
||||
},
|
||||
|
||||
// Summary stats row
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 15,
|
||||
gap: 10,
|
||||
},
|
||||
summaryCard: {
|
||||
flex: 1,
|
||||
padding: 10,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderLeft: `3 solid ${accentColor}`,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 8,
|
||||
color: '#7f8c8d',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Section
|
||||
sectionTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 8,
|
||||
paddingBottom: 4,
|
||||
borderBottom: `2 solid ${accentColor}`,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 18,
|
||||
},
|
||||
|
||||
// Table
|
||||
table: {
|
||||
borderLeft: '1 solid #dee2e6',
|
||||
borderRight: '1 solid #dee2e6',
|
||||
borderTop: '1 solid #dee2e6',
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: accentColor,
|
||||
minHeight: 24,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
color: '#ffffff',
|
||||
fontSize: 7,
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
padding: 6,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottom: '1 solid #dee2e6',
|
||||
minHeight: 28,
|
||||
},
|
||||
tableRowAlt: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
},
|
||||
tableRowRoster: {
|
||||
backgroundColor: '#fef9e7',
|
||||
},
|
||||
tableRowRosterAlt: {
|
||||
backgroundColor: '#fdf3d0',
|
||||
},
|
||||
tableCell: {
|
||||
padding: 5,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cellName: {
|
||||
fontSize: 9,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
cellDept: {
|
||||
fontSize: 7,
|
||||
color: '#7f8c8d',
|
||||
marginTop: 1,
|
||||
},
|
||||
cellText: {
|
||||
fontSize: 8,
|
||||
color: '#34495e',
|
||||
},
|
||||
cellSmall: {
|
||||
fontSize: 7,
|
||||
color: '#7f8c8d',
|
||||
},
|
||||
cellCenter: {
|
||||
fontSize: 9,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
textAlign: 'center',
|
||||
},
|
||||
cellNoData: {
|
||||
fontSize: 7,
|
||||
color: '#bdc3c7',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Column widths
|
||||
colName: { width: '22%' },
|
||||
colOrg: { width: '18%' },
|
||||
colContact: { width: '22%' },
|
||||
colEmergency: { width: '22%' },
|
||||
colParty: { width: '8%' },
|
||||
colNotes: { width: '8%' },
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 25,
|
||||
left: 40,
|
||||
right: 40,
|
||||
paddingTop: 10,
|
||||
borderTop: '1 solid #dee2e6',
|
||||
},
|
||||
footerContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
footerLeft: {
|
||||
maxWidth: '60%',
|
||||
},
|
||||
footerTitle: {
|
||||
fontSize: 8,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
marginBottom: 3,
|
||||
},
|
||||
footerContact: {
|
||||
fontSize: 7,
|
||||
color: '#7f8c8d',
|
||||
marginBottom: 1,
|
||||
},
|
||||
footerRight: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: 7,
|
||||
color: '#95a5a6',
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: 30,
|
||||
color: '#95a5a6',
|
||||
fontSize: 11,
|
||||
},
|
||||
});
|
||||
|
||||
const formatDepartment = (dept: string) => {
|
||||
switch (dept) {
|
||||
case 'OFFICE_OF_DEVELOPMENT':
|
||||
return 'Office of Dev';
|
||||
case 'ADMIN':
|
||||
return 'Admin';
|
||||
default:
|
||||
return dept;
|
||||
}
|
||||
};
|
||||
|
||||
export function AccountabilityRosterPDF({
|
||||
vips,
|
||||
settings,
|
||||
}: AccountabilityRosterPDFProps) {
|
||||
const config = settings || {
|
||||
organizationName: 'VIP Transportation Services',
|
||||
accentColor: '#2c3e50',
|
||||
contactEmail: 'coordinator@example.com',
|
||||
contactPhone: '(555) 123-4567',
|
||||
contactLabel: 'Questions or Changes?',
|
||||
showDraftWatermark: false,
|
||||
showConfidentialWatermark: false,
|
||||
showTimestamp: true,
|
||||
showAppUrl: false,
|
||||
pageSize: 'LETTER' as const,
|
||||
logoUrl: null,
|
||||
tagline: null,
|
||||
headerMessage: null,
|
||||
footerMessage: null,
|
||||
secondaryContactName: null,
|
||||
secondaryContactPhone: null,
|
||||
};
|
||||
|
||||
const styles = createStyles(config.accentColor, config.pageSize);
|
||||
|
||||
const generatedAt = new Date().toLocaleString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const activeVips = vips.filter((v) => !v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const rosterOnlyVips = vips.filter((v) => v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const totalPeople = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
const activeCount = activeVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
const rosterCount = rosterOnlyVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
|
||||
const renderTableHeader = () => (
|
||||
<View style={styles.tableHeader}>
|
||||
<View style={[styles.tableHeaderCell, styles.colName]}>
|
||||
<Text>Name</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colOrg]}>
|
||||
<Text>Organization</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colContact]}>
|
||||
<Text>Contact</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colEmergency]}>
|
||||
<Text>Emergency Contact</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colParty]}>
|
||||
<Text>Party</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderVipRow = (vip: VIP, index: number, isRoster: boolean) => (
|
||||
<View
|
||||
key={vip.id}
|
||||
style={[
|
||||
styles.tableRow,
|
||||
isRoster
|
||||
? index % 2 === 1 ? styles.tableRowRosterAlt : styles.tableRowRoster
|
||||
: index % 2 === 1 ? styles.tableRowAlt : {},
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
<View style={[styles.tableCell, styles.colName]}>
|
||||
<Text style={styles.cellName}>{vip.name}</Text>
|
||||
<Text style={styles.cellDept}>{formatDepartment(vip.department)}</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colOrg]}>
|
||||
{vip.organization ? (
|
||||
<Text style={styles.cellText}>{vip.organization}</Text>
|
||||
) : (
|
||||
<Text style={styles.cellNoData}>-</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colContact]}>
|
||||
{vip.phone && <Text style={styles.cellText}>{vip.phone}</Text>}
|
||||
{vip.email && <Text style={styles.cellSmall}>{vip.email}</Text>}
|
||||
{!vip.phone && !vip.email && <Text style={styles.cellNoData}>No contact info</Text>}
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colEmergency]}>
|
||||
{vip.emergencyContactName ? (
|
||||
<>
|
||||
<Text style={styles.cellText}>{vip.emergencyContactName}</Text>
|
||||
{vip.emergencyContactPhone && (
|
||||
<Text style={styles.cellSmall}>{vip.emergencyContactPhone}</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.cellNoData}>Not provided</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colParty]}>
|
||||
<Text style={styles.cellCenter}>{vip.partySize}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size={config.pageSize} style={styles.page}>
|
||||
{/* Watermarks */}
|
||||
{config.showDraftWatermark && (
|
||||
<View style={styles.watermark} fixed>
|
||||
<Text>DRAFT</Text>
|
||||
</View>
|
||||
)}
|
||||
{config.showConfidentialWatermark && (
|
||||
<View style={styles.watermark} fixed>
|
||||
<Text>CONFIDENTIAL</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
{config.logoUrl && (
|
||||
<View style={styles.logoContainer}>
|
||||
<Image src={config.logoUrl} style={styles.logo} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.orgName}>{config.organizationName}</Text>
|
||||
<Text style={styles.title}>Accountability Roster</Text>
|
||||
<Text style={styles.subtitle}>Emergency Preparedness & Personnel Tracking</Text>
|
||||
|
||||
{config.headerMessage && (
|
||||
<Text style={styles.customMessage}>{config.headerMessage}</Text>
|
||||
)}
|
||||
|
||||
{(config.showTimestamp || config.showAppUrl) && (
|
||||
<View style={styles.timestampBar}>
|
||||
{config.showTimestamp && (
|
||||
<Text style={styles.timestamp}>Generated: {generatedAt}</Text>
|
||||
)}
|
||||
{config.showAppUrl && (
|
||||
<Text style={styles.timestamp}>
|
||||
Latest version: {typeof window !== 'undefined' ? window.location.origin : ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryValue}>{totalPeople}</Text>
|
||||
<Text style={styles.summaryLabel}>Total People</Text>
|
||||
</View>
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryValue}>{activeCount}</Text>
|
||||
<Text style={styles.summaryLabel}>Active VIPs</Text>
|
||||
</View>
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryValue}>{rosterCount}</Text>
|
||||
<Text style={styles.summaryLabel}>Roster Only</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Active VIPs Table */}
|
||||
{activeVips.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Active VIPs ({activeVips.length} entries, {activeCount} people)
|
||||
</Text>
|
||||
<View style={styles.table}>
|
||||
{renderTableHeader()}
|
||||
{activeVips.map((vip, i) => renderVipRow(vip, i, false))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Roster Only Table */}
|
||||
{rosterOnlyVips.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Roster Only ({rosterOnlyVips.length} entries, {rosterCount} people)
|
||||
</Text>
|
||||
<View style={styles.table}>
|
||||
{renderTableHeader()}
|
||||
{rosterOnlyVips.map((vip, i) => renderVipRow(vip, i, true))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{vips.length === 0 && (
|
||||
<Text style={styles.emptyState}>No personnel records found.</Text>
|
||||
)}
|
||||
|
||||
{/* Custom Footer Message */}
|
||||
{config.footerMessage && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.customMessage}>{config.footerMessage}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<View style={styles.footerContent}>
|
||||
<View style={styles.footerLeft}>
|
||||
<Text style={styles.footerTitle}>{config.contactLabel}</Text>
|
||||
<Text style={styles.footerContact}>{config.contactEmail}</Text>
|
||||
<Text style={styles.footerContact}>{config.contactPhone}</Text>
|
||||
{config.secondaryContactName && (
|
||||
<Text style={styles.footerContact}>
|
||||
{config.secondaryContactName}
|
||||
{config.secondaryContactPhone ? ` - ${config.secondaryContactPhone}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.footerRight}>
|
||||
<Text
|
||||
style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`Page ${pageNumber} of ${totalPages}`
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Send, Loader2 } from 'lucide-react';
|
||||
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
||||
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
@@ -18,6 +19,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
||||
const [message, setMessage] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { formatDateTime } = useFormattedDate();
|
||||
|
||||
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
||||
const sendMessage = useSendMessage();
|
||||
@@ -66,22 +68,6 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
@@ -126,7 +112,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
||||
<p className={`text-[10px] mt-1 ${
|
||||
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
||||
}`}>
|
||||
{formatTime(msg.timestamp)}
|
||||
{formatDateTime(msg.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '@/lib/api';
|
||||
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Driver } from '@/types';
|
||||
import { useState } from 'react';
|
||||
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||
|
||||
interface ScheduleEvent {
|
||||
id: string;
|
||||
@@ -36,6 +37,7 @@ interface DriverScheduleModalProps {
|
||||
|
||||
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const { formatDate, formatTime } = useFormattedDate();
|
||||
|
||||
const dateString = selectedDate.toISOString().split('T')[0];
|
||||
|
||||
@@ -85,23 +87,6 @@ export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleM
|
||||
|
||||
const isToday = selectedDate.toDateString() === new Date().toDateString();
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||
|
||||
interface EventFormProps {
|
||||
event?: ScheduleEvent | null;
|
||||
@@ -39,6 +39,8 @@ interface ScheduleConflict {
|
||||
}
|
||||
|
||||
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
||||
const { formatDateTime } = useFormattedDate();
|
||||
|
||||
// Helper to convert ISO datetime to datetime-local format
|
||||
const toDatetimeLocal = (isoString: string | null | undefined) => {
|
||||
if (!isoString) return '';
|
||||
|
||||
@@ -6,18 +6,22 @@ import {
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Users,
|
||||
CheckCircle,
|
||||
Link2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Flight } from '@/types';
|
||||
import { Flight, Journey, Layover } from '@/types';
|
||||
import { FlightProgressBar } from './FlightProgressBar';
|
||||
import { useRefreshFlight } from '@/hooks/useFlights';
|
||||
import { formatLayoverDuration } from '@/lib/journeyUtils';
|
||||
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||
|
||||
interface FlightCardProps {
|
||||
flight: Flight;
|
||||
flight?: Flight;
|
||||
journey?: Journey;
|
||||
onEdit?: (flight: Flight) => void;
|
||||
onDelete?: (flight: Flight) => void;
|
||||
}
|
||||
@@ -58,16 +62,75 @@ function formatRelativeTime(isoString: string | null): string {
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
function getSegmentStatusIcon(flight: Flight) {
|
||||
const status = flight.status?.toLowerCase();
|
||||
if (status === 'landed' || flight.actualArrival) {
|
||||
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />;
|
||||
}
|
||||
if (status === 'active') {
|
||||
return <Plane className="w-3.5 h-3.5 text-purple-500" />;
|
||||
}
|
||||
if (status === 'cancelled' || status === 'diverted') {
|
||||
return <XCircle className="w-3.5 h-3.5 text-red-500" />;
|
||||
}
|
||||
return <Clock className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function LayoverRow({ layover }: { layover: Layover }) {
|
||||
const riskColors = {
|
||||
none: 'text-muted-foreground',
|
||||
ok: 'text-muted-foreground',
|
||||
warning: 'bg-amber-50 dark:bg-amber-950/20 text-amber-700 dark:text-amber-400',
|
||||
critical: 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-400',
|
||||
missed: 'bg-red-100 dark:bg-red-950/30 text-red-800 dark:text-red-300',
|
||||
};
|
||||
|
||||
const isBadge = layover.risk === 'warning' || layover.risk === 'critical' || layover.risk === 'missed';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-4 py-1.5 text-xs ${isBadge ? riskColors[layover.risk] : ''}`}>
|
||||
<div className="flex items-center gap-1.5 ml-6">
|
||||
<div className="w-px h-3 bg-border" />
|
||||
<Link2 className="w-3 h-3 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className={isBadge ? 'font-medium' : 'text-muted-foreground'}>
|
||||
{layover.risk === 'missed' ? (
|
||||
<>CONNECTION MISSED at {layover.airport} - arrived {formatLayoverDuration(layover.effectiveMinutes)}</>
|
||||
) : (
|
||||
<>{formatLayoverDuration(layover.scheduledMinutes)} layover at {layover.airport}</>
|
||||
)}
|
||||
</span>
|
||||
{layover.risk === 'warning' && layover.effectiveMinutes !== layover.scheduledMinutes && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 text-[10px] font-semibold">
|
||||
<AlertTriangle className="w-2.5 h-2.5" />
|
||||
now {formatLayoverDuration(layover.effectiveMinutes)}
|
||||
</span>
|
||||
)}
|
||||
{layover.risk === 'critical' && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 text-[10px] font-semibold">
|
||||
<AlertTriangle className="w-2.5 h-2.5" />
|
||||
only {formatLayoverDuration(layover.effectiveMinutes)} remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SINGLE FLIGHT CARD (original behavior)
|
||||
// ============================================================
|
||||
|
||||
function SingleFlightCard({ flight, onEdit, onDelete }: { flight: Flight; onEdit?: (f: Flight) => void; onDelete?: (f: Flight) => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const refreshMutation = useRefreshFlight();
|
||||
const alert = getAlertBanner(flight);
|
||||
const dotColor = getStatusDotColor(flight);
|
||||
const isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
|
||||
const { formatDateTime } = useFormattedDate();
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||
{/* Alert banner */}
|
||||
{alert && (
|
||||
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
@@ -75,22 +138,16 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Status dot */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||
|
||||
{/* Flight number + airline */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-foreground">{flight.flightNumber}</span>
|
||||
{flight.airlineName && (
|
||||
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* VIP name */}
|
||||
{flight.vip && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
@@ -104,8 +161,6 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => refreshMutation.mutate(flight.id)}
|
||||
@@ -116,20 +171,12 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(flight)}
|
||||
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Edit flight"
|
||||
>
|
||||
<button onClick={() => onEdit(flight)} className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground" title="Edit flight">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(flight)}
|
||||
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500"
|
||||
title="Delete flight"
|
||||
>
|
||||
<button onClick={() => onDelete(flight)} className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500" title="Delete flight">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
@@ -137,12 +184,10 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-4">
|
||||
<FlightProgressBar flight={flight} />
|
||||
</div>
|
||||
|
||||
{/* Footer - expandable details */}
|
||||
<div className="px-4 pb-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
@@ -165,14 +210,13 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
|
||||
{expanded && (
|
||||
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
|
||||
{/* Detailed times */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="font-medium text-foreground mb-1">Departure</div>
|
||||
<div className="space-y-0.5 text-muted-foreground">
|
||||
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
|
||||
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
|
||||
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
|
||||
{flight.scheduledDeparture && <div>Scheduled: {formatDateTime(flight.scheduledDeparture)}</div>}
|
||||
{flight.estimatedDeparture && <div>Estimated: {formatDateTime(flight.estimatedDeparture)}</div>}
|
||||
{flight.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(flight.actualDeparture)}</div>}
|
||||
{flight.departureDelay != null && flight.departureDelay > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.departureDelay} min</div>
|
||||
)}
|
||||
@@ -183,9 +227,9 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
<div>
|
||||
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||
<div className="space-y-0.5 text-muted-foreground">
|
||||
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
|
||||
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
|
||||
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
|
||||
{flight.scheduledArrival && <div>Scheduled: {formatDateTime(flight.scheduledArrival)}</div>}
|
||||
{flight.estimatedArrival && <div>Estimated: {formatDateTime(flight.estimatedArrival)}</div>}
|
||||
{flight.actualArrival && <div className="text-foreground">Actual: {formatDateTime(flight.actualArrival)}</div>}
|
||||
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.arrivalDelay} min</div>
|
||||
)}
|
||||
@@ -195,12 +239,8 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aircraft info */}
|
||||
{flight.aircraftType && (
|
||||
<div className="text-muted-foreground">
|
||||
Aircraft: {flight.aircraftType}
|
||||
</div>
|
||||
<div className="text-muted-foreground">Aircraft: {flight.aircraftType}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -208,3 +248,220 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MULTI-SEGMENT JOURNEY CARD
|
||||
// ============================================================
|
||||
|
||||
function JourneyCard({ journey, onEdit, onDelete }: { journey: Journey; onEdit?: (f: Flight) => void; onDelete?: (f: Flight) => void }) {
|
||||
const [expandedSeg, setExpandedSeg] = useState<number | null>(null);
|
||||
const refreshMutation = useRefreshFlight();
|
||||
const currentFlight = journey.flights[journey.currentSegmentIndex];
|
||||
const dotColor = getStatusDotColor(currentFlight);
|
||||
const vip = journey.vip || currentFlight?.vip;
|
||||
const { formatDateTime } = useFormattedDate();
|
||||
|
||||
// Route chain: BWI -> ORD -> SLC
|
||||
const routeChain = [journey.origin, ...journey.flights.slice(1).map(f => f.departureAirport), journey.destination]
|
||||
.filter((v, i, a) => a.indexOf(v) === i); // dedupe
|
||||
|
||||
// Connection risk banner
|
||||
const worstLayover = journey.layovers.reduce<Layover | null>((worst, l) => {
|
||||
if (!worst) return l;
|
||||
if (l.risk === 'missed') return l;
|
||||
if (l.risk === 'critical' && worst.risk !== 'missed') return l;
|
||||
if (l.risk === 'warning' && worst.risk !== 'missed' && worst.risk !== 'critical') return l;
|
||||
return worst;
|
||||
}, null);
|
||||
|
||||
const connectionBanner = worstLayover && (worstLayover.risk === 'warning' || worstLayover.risk === 'critical' || worstLayover.risk === 'missed')
|
||||
? {
|
||||
message: worstLayover.risk === 'missed'
|
||||
? `CONNECTION MISSED at ${worstLayover.airport}`
|
||||
: worstLayover.risk === 'critical'
|
||||
? `CONNECTION AT RISK - only ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`
|
||||
: `Connection tight - ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`,
|
||||
color: worstLayover.risk === 'missed'
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
|
||||
: worstLayover.risk === 'critical'
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
|
||||
: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400',
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||
{/* Connection risk banner */}
|
||||
{connectionBanner && (
|
||||
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${connectionBanner.color}`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{connectionBanner.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Journey header */}
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||
{vip && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-foreground">{vip.name}</span>
|
||||
{vip.partySize > 1 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Users className="w-3 h-3" />
|
||||
+{vip.partySize - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Route chain */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{routeChain.map((code, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-muted-foreground/40">{'→'}</span>}
|
||||
<span className={i === 0 || i === routeChain.length - 1 ? 'font-bold text-foreground' : ''}>{code}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||
{journey.flights.length} legs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segment stack */}
|
||||
{journey.flights.map((seg, i) => {
|
||||
const isExpanded = expandedSeg === i;
|
||||
const isCurrent = i === journey.currentSegmentIndex;
|
||||
|
||||
return (
|
||||
<div key={seg.id}>
|
||||
{/* Layover row between segments */}
|
||||
{i > 0 && journey.layovers[i - 1] && (
|
||||
<LayoverRow layover={journey.layovers[i - 1]} />
|
||||
)}
|
||||
|
||||
{/* Segment row */}
|
||||
<div className={`px-4 py-2 ${isCurrent ? 'bg-accent/30' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Leg label + status icon */}
|
||||
<div className="flex items-center gap-1.5 min-w-[60px]">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase">Leg {i + 1}</span>
|
||||
{getSegmentStatusIcon(seg)}
|
||||
</div>
|
||||
|
||||
{/* Compact progress bar */}
|
||||
<div className="flex-1">
|
||||
<FlightProgressBar flight={seg} compact />
|
||||
</div>
|
||||
|
||||
{/* Flight number + actions */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground">{seg.flightNumber}</span>
|
||||
<button
|
||||
onClick={() => refreshMutation.mutate(seg.id)}
|
||||
disabled={refreshMutation.isPending}
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpandedSeg(isExpanded ? null : i)}
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal/gate info for current segment */}
|
||||
{isCurrent && (seg.arrivalTerminal || seg.arrivalGate || seg.arrivalBaggage) && (
|
||||
<div className="flex gap-3 mt-1 ml-[68px] text-[10px] text-muted-foreground">
|
||||
{seg.arrivalTerminal && <span>Terminal {seg.arrivalTerminal}</span>}
|
||||
{seg.arrivalGate && <span>Gate {seg.arrivalGate}</span>}
|
||||
{seg.arrivalBaggage && <span>Baggage {seg.arrivalBaggage}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50 text-xs">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="font-medium text-foreground mb-1">Departure</div>
|
||||
<div className="space-y-0.5 text-muted-foreground">
|
||||
{seg.scheduledDeparture && <div>Scheduled: {formatDateTime(seg.scheduledDeparture)}</div>}
|
||||
{seg.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(seg.actualDeparture)}</div>}
|
||||
{seg.departureDelay != null && seg.departureDelay > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.departureDelay} min</div>
|
||||
)}
|
||||
{seg.departureTerminal && <div>Terminal: {seg.departureTerminal}</div>}
|
||||
{seg.departureGate && <div>Gate: {seg.departureGate}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||
<div className="space-y-0.5 text-muted-foreground">
|
||||
{seg.scheduledArrival && <div>Scheduled: {formatDateTime(seg.scheduledArrival)}</div>}
|
||||
{seg.actualArrival && <div className="text-foreground">Actual: {formatDateTime(seg.actualArrival)}</div>}
|
||||
{seg.arrivalDelay != null && seg.arrivalDelay > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.arrivalDelay} min</div>
|
||||
)}
|
||||
{seg.arrivalTerminal && <div>Terminal: {seg.arrivalTerminal}</div>}
|
||||
{seg.arrivalGate && <div>Gate: {seg.arrivalGate}</div>}
|
||||
{seg.arrivalBaggage && <div>Baggage: {seg.arrivalBaggage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{onEdit && (
|
||||
<button onClick={() => onEdit(seg)} className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400">Edit</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button onClick={() => onDelete(seg)} className="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-1.5 border-t border-border/50 text-xs text-muted-foreground flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Updated {formatRelativeTime(currentFlight?.lastPolledAt)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||
{journey.effectiveStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXPORT: Routes to single or journey card
|
||||
// ============================================================
|
||||
|
||||
export function FlightCard({ flight, journey, onEdit, onDelete }: FlightCardProps) {
|
||||
if (journey) {
|
||||
// Multi-segment journeys always use JourneyCard, single-segment journeys too when passed as journey
|
||||
if (journey.isMultiSegment) {
|
||||
return <JourneyCard journey={journey} onEdit={onEdit} onDelete={onDelete} />;
|
||||
}
|
||||
// Single-segment journey: render as single flight card
|
||||
return <SingleFlightCard flight={journey.flights[0]} onEdit={onEdit} onDelete={onDelete} />;
|
||||
}
|
||||
|
||||
if (flight) {
|
||||
return <SingleFlightCard flight={flight} onEdit={onEdit} onDelete={onDelete} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { Plane } from 'lucide-react';
|
||||
import { Flight } from '@/types';
|
||||
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||
|
||||
interface FlightProgressBarProps {
|
||||
flight: Flight;
|
||||
@@ -59,16 +60,8 @@ function getTrackBgColor(flight: Flight): string {
|
||||
return 'bg-muted';
|
||||
}
|
||||
|
||||
function formatTime(isoString: string | null): string {
|
||||
if (!isoString) return '--:--';
|
||||
return new Date(isoString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
|
||||
const { formatTime } = useFormattedDate();
|
||||
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
||||
const status = flight.status?.toLowerCase();
|
||||
const isActive = status === 'active';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
@@ -32,6 +32,7 @@ export function InlineDriverSelector({
|
||||
currentDriverName,
|
||||
onDriverChange,
|
||||
}: InlineDriverSelectorProps) {
|
||||
const { formatDateTime } = useFormattedDate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
||||
|
||||
71
frontend/src/contexts/TimezoneContext.tsx
Normal file
71
frontend/src/contexts/TimezoneContext.tsx
Normal 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);
|
||||
}
|
||||
28
frontend/src/hooks/useFormattedDate.ts
Normal file
28
frontend/src/hooks/useFormattedDate.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
118
frontend/src/lib/journeyUtils.ts
Normal file
118
frontend/src/lib/journeyUtils.ts
Normal 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`;
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
<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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
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" />
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user