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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map("gps_location_history")
|
@@map("gps_location_history")
|
||||||
@@index([deviceId, timestamp])
|
@@unique([deviceId, timestamp]) // Prevent duplicate position records
|
||||||
@@index([timestamp]) // For cleanup job
|
@@index([timestamp]) // For cleanup job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export class GpsController {
|
|||||||
return this.gpsService.getEnrolledDevices();
|
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
|
* 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
|
* 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)
|
* Sync positions from Traccar to our database (for history/stats)
|
||||||
* Called periodically via cron job
|
* Called periodically via cron job
|
||||||
*/
|
*/
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||||
async syncPositions(): Promise<void> {
|
async syncPositions(): Promise<void> {
|
||||||
const devices = await this.prisma.gpsDevice.findMany({
|
const devices = await this.prisma.gpsDevice.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -572,36 +610,51 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
|
|
||||||
if (devices.length === 0) return;
|
if (devices.length === 0) return;
|
||||||
|
|
||||||
try {
|
const now = new Date();
|
||||||
const positions = await this.traccarClient.getAllPositions();
|
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
const position = positions.find((p) => p.deviceId === device.traccarDeviceId);
|
try {
|
||||||
if (!position) continue;
|
// 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({
|
await this.prisma.gpsDevice.update({
|
||||||
where: { id: device.id },
|
where: { id: device.id },
|
||||||
data: { lastActive: new Date(position.deviceTime) },
|
data: { lastActive: new Date(latestPosition.deviceTime) },
|
||||||
});
|
|
||||||
|
|
||||||
// Store in history
|
|
||||||
await this.prisma.gpsLocationHistory.create({
|
|
||||||
data: {
|
|
||||||
deviceId: device.id,
|
|
||||||
latitude: position.latitude,
|
|
||||||
longitude: position.longitude,
|
|
||||||
altitude: position.altitude || null,
|
|
||||||
speed: this.traccarClient.knotsToMph(position.speed || 0),
|
|
||||||
course: position.course || null,
|
|
||||||
accuracy: position.accuracy || null,
|
|
||||||
battery: position.attributes?.batteryLevel || null,
|
|
||||||
timestamp: new Date(position.deviceTime),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to sync positions 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[]) {
|
private getFlightData(vips: any[]) {
|
||||||
const flights: 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';
|
const destination = 'SLC';
|
||||||
|
|
||||||
vips.forEach((vip, index) => {
|
// Build a name->vip lookup for named scenarios
|
||||||
const airline = airlines[index % airlines.length];
|
const vipByName = new Map<string, any>();
|
||||||
const flightNum = `${airline}${1000 + index * 123}`;
|
vips.forEach(v => vipByName.set(v.name, v));
|
||||||
const origin = origins[index % origins.length];
|
|
||||||
|
|
||||||
// Arrival flight - times relative to now
|
// Helper: create a flight record
|
||||||
const arrivalOffset = (index % 8) * 30 - 60;
|
const makeFlight = (vipId: string, opts: any) => ({
|
||||||
const scheduledArrival = this.relativeTime(arrivalOffset);
|
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);
|
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
|
||||||
|
|
||||||
let status = 'scheduled';
|
let status = df.statusOverride || 'scheduled';
|
||||||
let actualArrival = null;
|
let actualArrival = null;
|
||||||
if (arrivalOffset < -30) {
|
if (status === 'landed') {
|
||||||
status = 'landed';
|
actualArrival = new Date(scheduledArrival.getTime() + 5 * 60000);
|
||||||
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
|
|
||||||
} else if (arrivalOffset < 0) {
|
|
||||||
status = 'landing';
|
|
||||||
} else if (index % 5 === 0) {
|
|
||||||
status = 'delayed';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flights.push({
|
flights.push(makeFlight(vip.id, {
|
||||||
vipId: vip.id,
|
|
||||||
flightNumber: flightNum,
|
|
||||||
flightDate: new Date(),
|
|
||||||
segment: 1,
|
segment: 1,
|
||||||
departureAirport: origin,
|
flightNumber: df.num,
|
||||||
|
departureAirport: df.origin,
|
||||||
arrivalAirport: destination,
|
arrivalAirport: destination,
|
||||||
scheduledDeparture,
|
scheduledDeparture,
|
||||||
scheduledArrival,
|
scheduledArrival,
|
||||||
|
actualDeparture: status !== 'scheduled' ? scheduledDeparture : null,
|
||||||
actualArrival,
|
actualArrival,
|
||||||
status,
|
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;
|
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')
|
@Get('pdf')
|
||||||
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
||||||
getPdfSettings() {
|
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
|
* Upload logo as base64 data URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Auth0Provider } from '@auth0/auth0-react';
|
|||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { AuthProvider } from '@/contexts/AuthContext';
|
import { AuthProvider } from '@/contexts/AuthContext';
|
||||||
import { AbilityProvider } from '@/contexts/AbilityContext';
|
import { AbilityProvider } from '@/contexts/AbilityContext';
|
||||||
|
import { TimezoneProvider } from '@/contexts/TimezoneContext';
|
||||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
@@ -68,6 +69,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<TimezoneProvider>
|
||||||
<AbilityProvider>
|
<AbilityProvider>
|
||||||
<BrowserRouter
|
<BrowserRouter
|
||||||
future={{
|
future={{
|
||||||
@@ -138,6 +140,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AbilityProvider>
|
</AbilityProvider>
|
||||||
|
</TimezoneProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</Auth0Provider>
|
</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 { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Send, Loader2 } from 'lucide-react';
|
import { X, Send, Loader2 } from 'lucide-react';
|
||||||
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Driver {
|
interface Driver {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
||||||
const sendMessage = useSendMessage();
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div
|
<div
|
||||||
@@ -126,7 +112,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
<p className={`text-[10px] mt-1 ${
|
<p className={`text-[10px] mt-1 ${
|
||||||
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
||||||
}`}>
|
}`}>
|
||||||
{formatTime(msg.timestamp)}
|
{formatDateTime(msg.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '@/lib/api';
|
|||||||
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { Driver } from '@/types';
|
import { Driver } from '@/types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface ScheduleEvent {
|
interface ScheduleEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +37,7 @@ interface DriverScheduleModalProps {
|
|||||||
|
|
||||||
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
const { formatDate, formatTime } = useFormattedDate();
|
||||||
|
|
||||||
const dateString = selectedDate.toISOString().split('T')[0];
|
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 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) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'COMPLETED':
|
case 'COMPLETED':
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
|
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
|
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
event?: ScheduleEvent | null;
|
event?: ScheduleEvent | null;
|
||||||
@@ -39,6 +39,8 @@ interface ScheduleConflict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
// Helper to convert ISO datetime to datetime-local format
|
// Helper to convert ISO datetime to datetime-local format
|
||||||
const toDatetimeLocal = (isoString: string | null | undefined) => {
|
const toDatetimeLocal = (isoString: string | null | undefined) => {
|
||||||
if (!isoString) return '';
|
if (!isoString) return '';
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
ToggleLeft,
|
|
||||||
ToggleRight,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Users,
|
Users,
|
||||||
|
CheckCircle,
|
||||||
|
Link2,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Flight } from '@/types';
|
import { Flight, Journey, Layover } from '@/types';
|
||||||
import { FlightProgressBar } from './FlightProgressBar';
|
import { FlightProgressBar } from './FlightProgressBar';
|
||||||
import { useRefreshFlight } from '@/hooks/useFlights';
|
import { useRefreshFlight } from '@/hooks/useFlights';
|
||||||
|
import { formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface FlightCardProps {
|
interface FlightCardProps {
|
||||||
flight: Flight;
|
flight?: Flight;
|
||||||
|
journey?: Journey;
|
||||||
onEdit?: (flight: Flight) => void;
|
onEdit?: (flight: Flight) => void;
|
||||||
onDelete?: (flight: Flight) => void;
|
onDelete?: (flight: Flight) => void;
|
||||||
}
|
}
|
||||||
@@ -58,16 +62,75 @@ function formatRelativeTime(isoString: string | null): string {
|
|||||||
return `${Math.floor(hours / 24)}d ago`;
|
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 [expanded, setExpanded] = useState(false);
|
||||||
const refreshMutation = useRefreshFlight();
|
const refreshMutation = useRefreshFlight();
|
||||||
const alert = getAlertBanner(flight);
|
const alert = getAlertBanner(flight);
|
||||||
const dotColor = getStatusDotColor(flight);
|
const dotColor = getStatusDotColor(flight);
|
||||||
const isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||||
{/* Alert banner */}
|
|
||||||
{alert && (
|
{alert && (
|
||||||
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
|
<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" />
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
@@ -75,22 +138,16 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="px-4 pt-3 pb-1">
|
<div className="px-4 pt-3 pb-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{/* Status dot */}
|
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||||
|
|
||||||
{/* Flight number + airline */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-bold text-foreground">{flight.flightNumber}</span>
|
<span className="font-bold text-foreground">{flight.flightNumber}</span>
|
||||||
{flight.airlineName && (
|
{flight.airlineName && (
|
||||||
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* VIP name */}
|
|
||||||
{flight.vip && (
|
{flight.vip && (
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<span className="text-muted-foreground/50">|</span>
|
<span className="text-muted-foreground/50">|</span>
|
||||||
@@ -104,8 +161,6 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => refreshMutation.mutate(flight.id)}
|
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' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button onClick={() => onEdit(flight)} className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground" title="Edit flight">
|
||||||
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" />
|
<Edit3 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<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">
|
||||||
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -137,12 +184,10 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<FlightProgressBar flight={flight} />
|
<FlightProgressBar flight={flight} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer - expandable details */}
|
|
||||||
<div className="px-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
@@ -165,14 +210,13 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
|
<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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground mb-1">Departure</div>
|
<div className="font-medium text-foreground mb-1">Departure</div>
|
||||||
<div className="space-y-0.5 text-muted-foreground">
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
|
{flight.scheduledDeparture && <div>Scheduled: {formatDateTime(flight.scheduledDeparture)}</div>}
|
||||||
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
|
{flight.estimatedDeparture && <div>Estimated: {formatDateTime(flight.estimatedDeparture)}</div>}
|
||||||
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
|
{flight.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(flight.actualDeparture)}</div>}
|
||||||
{flight.departureDelay != null && flight.departureDelay > 0 && (
|
{flight.departureDelay != null && flight.departureDelay > 0 && (
|
||||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.departureDelay} min</div>
|
<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>
|
||||||
<div className="font-medium text-foreground mb-1">Arrival</div>
|
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||||
<div className="space-y-0.5 text-muted-foreground">
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
|
{flight.scheduledArrival && <div>Scheduled: {formatDateTime(flight.scheduledArrival)}</div>}
|
||||||
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
|
{flight.estimatedArrival && <div>Estimated: {formatDateTime(flight.estimatedArrival)}</div>}
|
||||||
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
|
{flight.actualArrival && <div className="text-foreground">Actual: {formatDateTime(flight.actualArrival)}</div>}
|
||||||
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
|
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
|
||||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.arrivalDelay} min</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aircraft info */}
|
|
||||||
{flight.aircraftType && (
|
{flight.aircraftType && (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">Aircraft: {flight.aircraftType}</div>
|
||||||
Aircraft: {flight.aircraftType}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -208,3 +248,220 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</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 { useMemo, useEffect, useState } from 'react';
|
||||||
import { Plane } from 'lucide-react';
|
import { Plane } from 'lucide-react';
|
||||||
import { Flight } from '@/types';
|
import { Flight } from '@/types';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface FlightProgressBarProps {
|
interface FlightProgressBarProps {
|
||||||
flight: Flight;
|
flight: Flight;
|
||||||
@@ -59,16 +60,8 @@ function getTrackBgColor(flight: Flight): string {
|
|||||||
return 'bg-muted';
|
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) {
|
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
|
||||||
|
const { formatTime } = useFormattedDate();
|
||||||
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
||||||
const status = flight.status?.toLowerCase();
|
const status = flight.status?.toLowerCase();
|
||||||
const isActive = status === 'active';
|
const isActive = status === 'active';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
|
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Driver {
|
interface Driver {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +32,7 @@ export function InlineDriverSelector({
|
|||||||
currentDriverName,
|
currentDriverName,
|
||||||
onDriverChange,
|
onDriverChange,
|
||||||
}: InlineDriverSelectorProps) {
|
}: InlineDriverSelectorProps) {
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showConflictDialog, setShowConflictDialog] = 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,
|
GpsSettings,
|
||||||
EnrollmentResponse,
|
EnrollmentResponse,
|
||||||
MyGpsStatus,
|
MyGpsStatus,
|
||||||
|
DeviceQrInfo,
|
||||||
} from '@/types/gps';
|
} from '@/types/gps';
|
||||||
import toast from 'react-hot-toast';
|
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)
|
* Get all active driver locations (for map)
|
||||||
*/
|
*/
|
||||||
@@ -88,7 +103,7 @@ export function useDriverLocations() {
|
|||||||
const { data } = await api.get('/gps/locations');
|
const { data } = await api.get('/gps/locations');
|
||||||
return data;
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
enabled: !!driverId,
|
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));
|
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;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleDateString('en-US', {
|
return d.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
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;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleString('en-US', {
|
return d.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -22,13 +23,15 @@ export function formatDateTime(date: string | Date): string {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '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;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleTimeString('en-US', {
|
return d.toLocaleTimeString('en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
|
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
|
||||||
|
import { useTimezoneContext } from '@/contexts/TimezoneContext';
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Shield,
|
Shield,
|
||||||
|
Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
@@ -51,9 +53,27 @@ interface MessageStats {
|
|||||||
driversWithMessages: number;
|
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() {
|
export function AdminTools() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { timezone, setTimezone } = useTimezoneContext();
|
||||||
|
|
||||||
// Signal state
|
// Signal state
|
||||||
const [showQRCode, setShowQRCode] = useState(false);
|
const [showQRCode, setShowQRCode] = useState(false);
|
||||||
@@ -433,6 +453,33 @@ export function AdminTools() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* PDF Customization Settings */}
|
||||||
<PdfSettingsSection />
|
<PdfSettingsSection />
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import { DriverLocationModal } from '@/components/DriverLocationModal';
|
|||||||
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
||||||
import { useDriverLocations } from '@/hooks/useGps';
|
import { useDriverLocations } from '@/hooks/useGps';
|
||||||
import type { DriverLocation } from '@/types/gps';
|
import type { DriverLocation } from '@/types/gps';
|
||||||
|
import type { Flight } from '@/types';
|
||||||
|
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
id: string;
|
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
|
const USER_INTERACTION_PAUSE = 20000; // pause 20 seconds after user interaction
|
||||||
|
|
||||||
export function CommandCenter() {
|
export function CommandCenter() {
|
||||||
|
const { formatTime, formatDateTime, timezone } = useFormattedDate();
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [lastRefresh, setLastRefresh] = useState(new Date());
|
const [lastRefresh, setLastRefresh] = useState(new Date());
|
||||||
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
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'],
|
queryKey: ['flights'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/flights');
|
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)
|
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
|
||||||
const now = currentTime;
|
const now = currentTime;
|
||||||
const awaitingConfirmation = (events || []).filter((event) => {
|
const awaitingConfirmation = (events || []).filter((event) => {
|
||||||
@@ -330,7 +340,7 @@ export function CommandCenter() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
|
// 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;
|
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
|
||||||
const upcomingArrivals = vips
|
const upcomingArrivals = vips
|
||||||
@@ -442,7 +452,7 @@ export function CommandCenter() {
|
|||||||
const todayEnd = new Date(todayStart);
|
const todayEnd = new Date(todayStart);
|
||||||
todayEnd.setDate(todayEnd.getDate() + 1);
|
todayEnd.setDate(todayEnd.getDate() + 1);
|
||||||
|
|
||||||
flights.forEach((flight: any) => {
|
flights.forEach((flight) => {
|
||||||
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
|
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
|
||||||
if (!arrivalTime) return;
|
if (!arrivalTime) return;
|
||||||
const arrDate = new Date(arrivalTime);
|
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
|
// Get time until event
|
||||||
function getTimeUntil(dateStr: string) {
|
function getTimeUntil(dateStr: string) {
|
||||||
const eventTime = new Date(dateStr);
|
const eventTime = new Date(dateStr);
|
||||||
@@ -560,10 +597,10 @@ export function CommandCenter() {
|
|||||||
{/* Live Clock */}
|
{/* Live Clock */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-4xl font-mono font-bold text-foreground tabular-nums">
|
<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>
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
||||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground mt-1">
|
<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" />
|
<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">
|
<div className="text-right flex-shrink-0">
|
||||||
<p className="text-xs text-muted-foreground">ETA</p>
|
<p className="text-xs text-muted-foreground">ETA</p>
|
||||||
<p className="text-lg font-bold text-green-600 dark:text-green-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -874,7 +911,7 @@ export function CommandCenter() {
|
|||||||
{getTimeUntil(trip.startTime)}
|
{getTimeUntil(trip.startTime)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -910,15 +947,19 @@ export function CommandCenter() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{upcomingArrivals.map((vip) => {
|
{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 flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
|
||||||
const arrival = vip.expectedArrival || (flight && getFlightArrivalTime(flight));
|
const lastFlight = journey ? journey.flights[journey.flights.length - 1] : flight;
|
||||||
const delay = flight ? Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0) : 0;
|
const finalArrival = lastFlight ? (lastFlight.actualArrival || lastFlight.estimatedArrival || lastFlight.scheduledArrival) : null;
|
||||||
const flightStatus = flight?.status?.toLowerCase();
|
const arrival = vip.expectedArrival || finalArrival;
|
||||||
const isCancelled = flightStatus === 'cancelled';
|
const currentFlight = journey ? journey.flights[journey.currentSegmentIndex] : flight;
|
||||||
const isActive = flightStatus === 'active';
|
const delay = currentFlight ? Math.max(currentFlight.arrivalDelay || 0, currentFlight.departureDelay || 0) : 0;
|
||||||
const isLanded = flightStatus === 'landed' || !!flight?.actualArrival;
|
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
|
const timeColor = isCancelled
|
||||||
? 'text-red-600 dark:text-red-400'
|
? 'text-red-600 dark:text-red-400'
|
||||||
: isLanded
|
: isLanded
|
||||||
@@ -929,7 +970,9 @@ export function CommandCenter() {
|
|||||||
? 'text-purple-600 dark:text-purple-400'
|
? 'text-purple-600 dark:text-purple-400'
|
||||||
: 'text-blue-600 dark:text-blue-400';
|
: 'text-blue-600 dark:text-blue-400';
|
||||||
|
|
||||||
const borderColor = isCancelled
|
const borderColor = journey?.hasLayoverRisk
|
||||||
|
? 'border-l-orange-500'
|
||||||
|
: isCancelled
|
||||||
? 'border-l-red-500'
|
? 'border-l-red-500'
|
||||||
: delay > 30
|
: delay > 30
|
||||||
? 'border-l-amber-500'
|
? 'border-l-amber-500'
|
||||||
@@ -939,13 +982,24 @@ export function CommandCenter() {
|
|||||||
? 'border-l-emerald-500'
|
? 'border-l-emerald-500'
|
||||||
: 'border-l-blue-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 (
|
return (
|
||||||
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
|
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
|
<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">
|
<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" />
|
<AlertTriangle className="w-2.5 h-2.5" />
|
||||||
+{delay}m
|
+{delay}m
|
||||||
@@ -958,18 +1012,16 @@ export function CommandCenter() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
{flight && (
|
<span>{routeChain}</span>
|
||||||
<>
|
{journey?.isMultiSegment && (
|
||||||
<span className="font-medium">{flight.flightNumber}</span>
|
<span className="text-muted-foreground/60">{journey.flights.length} legs</span>
|
||||||
<span>{flight.departureAirport} → {flight.arrivalAirport}</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex gap-2 text-[10px] text-muted-foreground mt-0.5">
|
||||||
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
|
{currentFlight.arrivalTerminal && <span>T{currentFlight.arrivalTerminal}</span>}
|
||||||
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
|
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
|
||||||
{flight.arrivalBaggage && <span>Bag {flight.arrivalBaggage}</span>}
|
{currentFlight.arrivalBaggage && <span>Bag {currentFlight.arrivalBaggage}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -979,7 +1031,7 @@ export function CommandCenter() {
|
|||||||
</p>
|
</p>
|
||||||
{arrival && !isCancelled && !isLanded && (
|
{arrival && !isCancelled && !isLanded && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
{formatTime(new Date(arrival))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
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 { VIP, Driver, ScheduleEvent, Flight } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import { FlightProgressBar } from '@/components/FlightProgressBar';
|
import { FlightProgressBar } from '@/components/FlightProgressBar';
|
||||||
|
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
|
||||||
const { data: vips } = useQuery<VIP[]>({
|
const { data: vips } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: ['vips'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -66,6 +69,25 @@ export function Dashboard() {
|
|||||||
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
|
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
|
||||||
.slice(0, 5) || [];
|
.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 = [
|
const stats = [
|
||||||
{
|
{
|
||||||
name: 'Total VIPs',
|
name: 'Total VIPs',
|
||||||
@@ -184,18 +206,22 @@ export function Dashboard() {
|
|||||||
Flight Status
|
Flight Status
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Status summary */}
|
{/* Journey status summary */}
|
||||||
{flights && flights.length > 0 && (
|
{journeys.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-border">
|
<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 inFlight = journeys.filter(j => j.effectiveStatus === 'active').length;
|
||||||
const delayed = flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length;
|
const connectionRisk = journeys.filter(j => j.hasLayoverRisk).length;
|
||||||
const cancelled = flights.filter(f => f.status?.toLowerCase() === 'cancelled').length;
|
const cancelled = journeys.filter(j => j.effectiveStatus === 'cancelled').length;
|
||||||
const landed = flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length;
|
const landed = journeys.filter(j => j.effectiveStatus === 'landed').length;
|
||||||
const scheduled = flights.filter(f => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(f.status?.toLowerCase() || '') && !f.actualArrival).length;
|
const scheduled = journeys.filter(j => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(j.effectiveStatus)).length;
|
||||||
|
|
||||||
return (
|
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 && (
|
{inFlight > 0 && (
|
||||||
<span className="flex items-center gap-1.5 text-sm">
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
<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 className="text-muted-foreground">in flight</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{delayed > 0 && (
|
{connectionRisk > 0 && (
|
||||||
<span className="flex items-center gap-1.5 text-sm">
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
<span className="font-medium text-amber-600 dark:text-amber-400">{delayed}</span>
|
<span className="font-medium text-orange-600 dark:text-orange-400">{connectionRisk}</span>
|
||||||
<span className="text-muted-foreground">delayed</span>
|
<span className="text-muted-foreground">at risk</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{cancelled > 0 && (
|
{cancelled > 0 && (
|
||||||
@@ -233,35 +259,43 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Arriving soon flights */}
|
{/* Upcoming journeys */}
|
||||||
{upcomingFlights.length > 0 ? (
|
{upcomingJourneys.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
Arriving Soon
|
Active & Upcoming Journeys
|
||||||
</h3>
|
</h3>
|
||||||
{upcomingFlights.map((flight) => {
|
{upcomingJourneys.map((journey) => {
|
||||||
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
|
const currentFlight = journey.flights[journey.currentSegmentIndex];
|
||||||
const eta = flight.estimatedArrival || flight.scheduledArrival;
|
const lastFlight = journey.flights[journey.flights.length - 1];
|
||||||
const borderColor = delay > 30 ? 'border-amber-500' :
|
const delay = Math.max(currentFlight?.arrivalDelay || 0, currentFlight?.departureDelay || 0);
|
||||||
flight.status?.toLowerCase() === 'active' ? 'border-purple-500' :
|
const routeChain = journey.flights.map(f => f.departureAirport).concat([lastFlight.arrivalAirport]).join(' → ');
|
||||||
flight.status?.toLowerCase() === 'cancelled' ? 'border-red-500' :
|
|
||||||
|
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';
|
'border-indigo-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={flight.id}
|
key={journey.vipId}
|
||||||
className={`border-l-4 ${borderColor} pl-4 py-2 hover:bg-accent transition-colors rounded-r`}
|
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 justify-between items-start mb-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-foreground">{flight.flightNumber}</span>
|
<span className="text-sm font-medium text-foreground">{journey.vip?.name || 'Unknown'}</span>
|
||||||
{flight.airlineName && (
|
{journey.isMultiSegment && (
|
||||||
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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">
|
<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" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
@@ -269,24 +303,51 @@ export function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
<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' :
|
journey.effectiveStatus === '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' :
|
journey.effectiveStatus === '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 === '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'
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
}`}>
|
}`}>
|
||||||
{flight.status || 'scheduled'}
|
{journey.effectiveStatus}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Progress bar for current segment */}
|
||||||
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
|
{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">
|
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
{flight.arrivalTerminal && <span>Terminal {flight.arrivalTerminal}</span>}
|
{currentFlight.arrivalTerminal && <span>Terminal {currentFlight.arrivalTerminal}</span>}
|
||||||
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
|
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
|
||||||
{flight.arrivalBaggage && <span>Baggage {flight.arrivalBaggage}</span>}
|
{currentFlight.arrivalBaggage && <span>Baggage {currentFlight.arrivalBaggage}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Phone,
|
Phone,
|
||||||
@@ -244,6 +245,7 @@ export function DriverProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GpsStatsSection() {
|
function GpsStatsSection() {
|
||||||
|
const { formatDate, formatDateTime } = useFormattedDate();
|
||||||
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
|
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
|
||||||
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
|
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
|
||||||
const updateConsent = useUpdateGpsConsent();
|
const updateConsent = useUpdateGpsConsent();
|
||||||
@@ -355,7 +357,7 @@ function GpsStatsSection() {
|
|||||||
) : gpsStats ? (
|
) : gpsStats ? (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
@@ -386,7 +388,7 @@ function GpsStatsSection() {
|
|||||||
|
|
||||||
{gpsStats.stats.topSpeedTimestamp && (
|
{gpsStats.stats.topSpeedTimestamp && (
|
||||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ScheduleEvent, EventType } from '@/types';
|
import { ScheduleEvent, EventType } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
|
||||||
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { EventForm, EventFormData } from '@/components/EventForm';
|
import { EventForm, EventFormData } from '@/components/EventForm';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
|
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
type ActivityFilter = 'ALL' | EventType;
|
type ActivityFilter = 'ALL' | EventType;
|
||||||
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
|
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
|
||||||
@@ -18,6 +18,7 @@ export function EventList() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Link2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FlightForm, FlightFormData } from '@/components/FlightForm';
|
import { FlightForm, FlightFormData } from '@/components/FlightForm';
|
||||||
import { FlightCard } from '@/components/FlightCard';
|
import { FlightCard } from '@/components/FlightCard';
|
||||||
@@ -22,95 +23,117 @@ import { FilterModal } from '@/components/FilterModal';
|
|||||||
import { FilterChip } from '@/components/FilterChip';
|
import { FilterChip } from '@/components/FilterChip';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { useFlights, useFlightBudget, useRefreshActiveFlights } from '@/hooks/useFlights';
|
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;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: typeof AlertTriangle;
|
icon: typeof AlertTriangle;
|
||||||
flights: Flight[];
|
journeys: Journey[];
|
||||||
color: string;
|
color: string;
|
||||||
defaultCollapsed?: boolean;
|
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 now = new Date();
|
||||||
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const groups: FlightGroup[] = [
|
const groups: JourneyGroup[] = [
|
||||||
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, flights: [], color: 'text-red-500' },
|
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, journeys: [], color: 'text-red-500' },
|
||||||
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, flights: [], color: 'text-purple-500' },
|
{ key: 'connection-risk', label: 'Connection Risk', icon: Link2, journeys: [], color: 'text-orange-500' },
|
||||||
{ key: 'active', label: 'In Flight', icon: Plane, flights: [], color: 'text-purple-500' },
|
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, journeys: [], color: 'text-purple-500' },
|
||||||
{ key: 'departing', label: 'Departing Soon', icon: Clock, flights: [], color: 'text-blue-500' },
|
{ key: 'active', label: 'In Flight', icon: Plane, journeys: [], color: 'text-purple-500' },
|
||||||
{ key: 'scheduled', label: 'Scheduled', icon: Clock, flights: [], color: 'text-muted-foreground' },
|
{ key: 'departing', label: 'Departing Soon', icon: Clock, journeys: [], color: 'text-blue-500' },
|
||||||
{ key: 'completed', label: 'Completed', icon: Plane, flights: [], color: 'text-emerald-500', defaultCollapsed: true },
|
{ 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) {
|
for (const journey of journeys) {
|
||||||
const status = flight.status?.toLowerCase();
|
const status = journey.effectiveStatus;
|
||||||
const eta = flight.estimatedArrival || flight.scheduledArrival;
|
|
||||||
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
|
// 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
|
// Alerts: cancelled, diverted, incident
|
||||||
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
|
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
|
||||||
groups[0].flights.push(flight);
|
groups[0].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completed: landed
|
// Completed: all segments landed
|
||||||
if (status === 'landed' || flight.actualArrival) {
|
if (status === 'landed') {
|
||||||
groups[5].flights.push(flight);
|
groups[6].journeys.push(journey);
|
||||||
continue;
|
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) {
|
if (status === 'active' && eta && new Date(eta) <= twoHoursFromNow) {
|
||||||
groups[1].flights.push(flight);
|
groups[2].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In flight: active
|
// In flight: active
|
||||||
if (status === 'active') {
|
if (status === 'active') {
|
||||||
groups[2].flights.push(flight);
|
groups[3].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Departing soon: departure within 4h
|
// Departing soon: next departure within 4h
|
||||||
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
|
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
|
||||||
groups[3].flights.push(flight);
|
groups[4].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else is scheduled
|
// Everything else is scheduled
|
||||||
groups[4].flights.push(flight);
|
groups[5].journeys.push(journey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort within groups
|
// Sort within groups
|
||||||
groups[0].flights.sort((a, b) => (b.arrivalDelay || 0) - (a.arrivalDelay || 0)); // Worst first
|
groups[0].journeys.sort((a, b) => getJourneyMaxDelay(b) - getJourneyMaxDelay(a));
|
||||||
groups[1].flights.sort((a, b) => {
|
groups[1].journeys.sort((a, b) => {
|
||||||
const etaA = a.estimatedArrival || a.scheduledArrival || '';
|
// Worst risk first: missed > critical > warning
|
||||||
const etaB = b.estimatedArrival || b.scheduledArrival || '';
|
const riskOrder = { missed: 0, critical: 1, warning: 2, ok: 3, none: 4 };
|
||||||
return etaA.localeCompare(etaB);
|
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) => {
|
groups[2].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
|
||||||
const etaA = a.estimatedArrival || a.scheduledArrival || '';
|
groups[3].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
|
||||||
const etaB = b.estimatedArrival || b.scheduledArrival || '';
|
groups[4].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
|
||||||
return etaA.localeCompare(etaB);
|
groups[5].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
|
||||||
});
|
groups[6].journeys.sort((a, b) => {
|
||||||
groups[3].flights.sort((a, b) => {
|
const lastA = a.flights[a.flights.length - 1];
|
||||||
const depA = a.estimatedDeparture || a.scheduledDeparture || '';
|
const lastB = b.flights[b.flights.length - 1];
|
||||||
const depB = b.estimatedDeparture || b.scheduledDeparture || '';
|
const arrA = lastA.actualArrival || lastA.scheduledArrival || '';
|
||||||
return depA.localeCompare(depB);
|
const arrB = lastB.actualArrival || lastB.scheduledArrival || '';
|
||||||
});
|
|
||||||
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 || '';
|
|
||||||
return arrB.localeCompare(arrA); // Most recent first
|
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(() => {
|
const filteredFlights = useMemo(() => {
|
||||||
if (!flights) return [];
|
if (!flights) return [];
|
||||||
|
|
||||||
@@ -223,7 +246,20 @@ export function FlightList() {
|
|||||||
});
|
});
|
||||||
}, [flights, debouncedSearchTerm, selectedStatuses]);
|
}, [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) => {
|
const toggleGroup = (key: string) => {
|
||||||
setCollapsedGroups(prev => {
|
setCollapsedGroups(prev => {
|
||||||
@@ -295,17 +331,23 @@ export function FlightList() {
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stats
|
// Stats based on all journeys (not just filtered)
|
||||||
const stats = useMemo(() => {
|
const allJourneys = useMemo(() => {
|
||||||
if (!flights) return { active: 0, delayed: 0, onTime: 0, landed: 0 };
|
if (!flights) return [];
|
||||||
return {
|
return groupFlightsIntoJourneys(flights);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Flight Tracking</h1>
|
<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">
|
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||||
|
<span>{stats.total} journeys</span>
|
||||||
{stats.active > 0 && (
|
{stats.active > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
||||||
{stats.active} in flight
|
{stats.active} in flight
|
||||||
</span>
|
</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 && (
|
{stats.delayed > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||||
{stats.delayed} delayed
|
{stats.delayed} delayed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>{stats.onTime} scheduled</span>
|
|
||||||
<span>{stats.landed} landed</span>
|
<span>{stats.landed} landed</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -355,7 +403,7 @@ export function FlightList() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<BudgetIndicator />
|
<BudgetIndicator />
|
||||||
{flights && flights.length > 0 && (
|
{allJourneys.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => refreshActiveMutation.mutate()}
|
onClick={() => refreshActiveMutation.mutate()}
|
||||||
disabled={refreshActiveMutation.isPending}
|
disabled={refreshActiveMutation.isPending}
|
||||||
@@ -376,7 +424,7 @@ export function FlightList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* 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="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1 relative">
|
<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="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
{(searchTerm || selectedStatuses.length > 0) && (
|
{(searchTerm || selectedStatuses.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -435,11 +483,11 @@ export function FlightList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flight Groups */}
|
{/* Journey Groups */}
|
||||||
{flights && flights.length > 0 ? (
|
{allJourneys.length > 0 ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{flightGroups.map((group) => {
|
{journeyGroups.map((group) => {
|
||||||
if (group.flights.length === 0) return null;
|
if (group.journeys.length === 0) return null;
|
||||||
const isCollapsed = collapsedGroups.has(group.key);
|
const isCollapsed = collapsedGroups.has(group.key);
|
||||||
const Icon = group.icon;
|
const Icon = group.icon;
|
||||||
|
|
||||||
@@ -460,18 +508,19 @@ export function FlightList() {
|
|||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground font-normal">
|
<span className="text-xs text-muted-foreground font-normal">
|
||||||
({group.flights.length})
|
({group.journeys.length})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 border-t border-border/50 ml-2" />
|
<div className="flex-1 border-t border-border/50 ml-2" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Flight cards */}
|
{/* Journey cards */}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{group.flights.map((flight) => (
|
{group.journeys.map((journey) => (
|
||||||
<FlightCard
|
<FlightCard
|
||||||
key={flight.id}
|
key={journey.vipId}
|
||||||
flight={flight}
|
journey={journey.isMultiSegment ? journey : undefined}
|
||||||
|
flight={journey.isMultiSegment ? undefined : journey.flights[0]}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
useTraccarSetupStatus,
|
useTraccarSetupStatus,
|
||||||
useTraccarSetup,
|
useTraccarSetup,
|
||||||
useOpenTraccarAdmin,
|
useOpenTraccarAdmin,
|
||||||
|
useDeviceQr,
|
||||||
} from '@/hooks/useGps';
|
} from '@/hooks/useGps';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import { ErrorMessage } from '@/components/ErrorMessage';
|
import { ErrorMessage } from '@/components/ErrorMessage';
|
||||||
@@ -113,6 +114,7 @@ export function GpsTracking() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
|
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
|
||||||
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
|
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
|
||||||
|
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Check admin access
|
// Check admin access
|
||||||
if (backendUser?.role !== 'ADMINISTRATOR') {
|
if (backendUser?.role !== 'ADMINISTRATOR') {
|
||||||
@@ -134,6 +136,7 @@ export function GpsTracking() {
|
|||||||
const { data: devices, isLoading: devicesLoading } = useGpsDevices();
|
const { data: devices, isLoading: devicesLoading } = useGpsDevices();
|
||||||
const { data: traccarStatus } = useTraccarSetupStatus();
|
const { data: traccarStatus } = useTraccarSetupStatus();
|
||||||
const { data: driverStats } = useDriverStats(selectedDriverId);
|
const { data: driverStats } = useDriverStats(selectedDriverId);
|
||||||
|
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateSettings = useUpdateGpsSettings();
|
const updateSettings = useUpdateGpsSettings();
|
||||||
@@ -491,7 +494,15 @@ export function GpsTracking() {
|
|||||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
|
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
|
||||||
</td>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
|
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
|
||||||
@@ -603,18 +614,21 @@ export function GpsTracking() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={30}
|
min={10}
|
||||||
max={300}
|
max={300}
|
||||||
defaultValue={settings.updateIntervalSeconds}
|
defaultValue={settings.updateIntervalSeconds}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const value = parseInt(e.target.value);
|
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 });
|
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"
|
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>
|
||||||
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4">
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
@@ -871,6 +885,129 @@ export function GpsTracking() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -44,6 +45,8 @@ interface DriverWithSchedule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MySchedule() {
|
export function MySchedule() {
|
||||||
|
const { formatDate, formatTime } = useFormattedDate();
|
||||||
|
|
||||||
const { data: profile, isLoading, error } = useQuery<DriverWithSchedule>({
|
const { data: profile, isLoading, error } = useQuery<DriverWithSchedule>({
|
||||||
queryKey: ['my-driver-profile'],
|
queryKey: ['my-driver-profile'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -123,31 +126,6 @@ export function MySchedule() {
|
|||||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
||||||
.slice(0, 5); // Last 5 completed
|
.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) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'IN_PROGRESS':
|
case 'IN_PROGRESS':
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { AccountabilityRosterPDF } from '@/components/AccountabilityRosterPDF';
|
||||||
|
import { usePdfSettings } from '@/hooks/useSettings';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
@@ -13,6 +17,10 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
Send,
|
||||||
|
MessageCircle,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface VIP {
|
interface VIP {
|
||||||
@@ -36,6 +44,10 @@ export function Reports() {
|
|||||||
const [activeReport, setActiveReport] = useState<ReportType>('accountability');
|
const [activeReport, setActiveReport] = useState<ReportType>('accountability');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [departmentFilter, setDepartmentFilter] = useState<string>('all');
|
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[]>({
|
const { data: vips, isLoading } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: ['vips'],
|
||||||
@@ -45,6 +57,8 @@ export function Reports() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: pdfSettings } = usePdfSettings();
|
||||||
|
|
||||||
const reports = [
|
const reports = [
|
||||||
{
|
{
|
||||||
id: 'accountability' as const,
|
id: 'accountability' as const,
|
||||||
@@ -52,9 +66,6 @@ export function Reports() {
|
|||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
description: 'Complete list of all personnel for emergency preparedness',
|
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
|
// Filter VIPs based on search and department
|
||||||
@@ -125,6 +136,90 @@ export function Reports() {
|
|||||||
link.click();
|
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) {
|
if (isLoading) {
|
||||||
return <Loading message="Loading report data..." />;
|
return <Loading message="Loading report data..." />;
|
||||||
}
|
}
|
||||||
@@ -237,13 +332,29 @@ export function Reports() {
|
|||||||
<option value="OTHER">Other</option>
|
<option value="OTHER">Other</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={handleExportCSV}
|
<button
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
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" />
|
>
|
||||||
Export CSV
|
<Download className="h-4 w-4" />
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Active VIPs Table */}
|
{/* Active VIPs Table */}
|
||||||
@@ -452,6 +563,101 @@ export function Reports() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
|
import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
@@ -20,6 +21,7 @@ interface User {
|
|||||||
|
|
||||||
export function UserList() {
|
export function UserList() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { formatDate } = useFormattedDate();
|
||||||
const [processingUser, setProcessingUser] = useState<string | null>(null);
|
const [processingUser, setProcessingUser] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: users, isLoading } = useQuery<User[]>({
|
const { data: users, isLoading } = useQuery<User[]>({
|
||||||
@@ -168,7 +170,7 @@ export function UserList() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
{formatDate(user.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EventForm, EventFormData } from '@/components/EventForm';
|
|||||||
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
||||||
import { ScheduleEvent } from '@/types';
|
import { ScheduleEvent } from '@/types';
|
||||||
import { usePdfSettings } from '@/hooks/useSettings';
|
import { usePdfSettings } from '@/hooks/useSettings';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -52,6 +53,7 @@ export function VIPSchedule() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
|
||||||
|
|
||||||
// State for edit modal
|
// State for edit modal
|
||||||
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
||||||
@@ -169,12 +171,7 @@ export function VIPSchedule() {
|
|||||||
|
|
||||||
// Group events by day
|
// Group events by day
|
||||||
const eventsByDay = sortedEvents.reduce((acc, event) => {
|
const eventsByDay = sortedEvents.reduce((acc, event) => {
|
||||||
const date = new Date(event.startTime).toLocaleDateString('en-US', {
|
const date = formatDate(event.startTime);
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
if (!acc[date]) {
|
if (!acc[date]) {
|
||||||
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 () => {
|
const handleExport = async () => {
|
||||||
if (!vip) return;
|
if (!vip) return;
|
||||||
|
|
||||||
@@ -362,13 +352,7 @@ export function VIPSchedule() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-1">Expected Arrival</p>
|
<p className="text-sm text-muted-foreground mb-1">Expected Arrival</p>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{new Date(vip.expectedArrival).toLocaleString('en-US', {
|
{formatDateTime(vip.expectedArrival)}
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -399,12 +383,7 @@ export function VIPSchedule() {
|
|||||||
{flight.scheduledArrival && (
|
{flight.scheduledArrival && (
|
||||||
<p className="text-sm text-blue-900 dark:text-blue-100">
|
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||||
Arrives:{' '}
|
Arrives:{' '}
|
||||||
{new Date(flight.scheduledArrival).toLocaleString('en-US', {
|
{formatDateTime(flight.scheduledArrival)}
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{flight.status && (
|
{flight.status && (
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ export interface EnrollmentResponse {
|
|||||||
signalMessageSent?: boolean;
|
signalMessageSent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceQrInfo {
|
||||||
|
driverName: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
serverUrl: string;
|
||||||
|
qrCodeUrl: string;
|
||||||
|
updateIntervalSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MyGpsStatus {
|
export interface MyGpsStatus {
|
||||||
enrolled: boolean;
|
enrolled: boolean;
|
||||||
driverId?: string;
|
driverId?: string;
|
||||||
|
|||||||
@@ -210,3 +210,27 @@ export interface FlightBudget {
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
month: string;
|
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