feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps

Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 07:36:51 +01:00
parent 0f0f1cbf38
commit a4d360aae9
33 changed files with 2136 additions and 361 deletions

View File

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