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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user