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

@@ -39,6 +39,7 @@ import {
useTraccarSetupStatus,
useTraccarSetup,
useOpenTraccarAdmin,
useDeviceQr,
} from '@/hooks/useGps';
import { Loading } from '@/components/Loading';
import { ErrorMessage } from '@/components/ErrorMessage';
@@ -113,6 +114,7 @@ export function GpsTracking() {
const [searchTerm, setSearchTerm] = useState('');
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
// Check admin access
if (backendUser?.role !== 'ADMINISTRATOR') {
@@ -134,6 +136,7 @@ export function GpsTracking() {
const { data: devices, isLoading: devicesLoading } = useGpsDevices();
const { data: traccarStatus } = useTraccarSetupStatus();
const { data: driverStats } = useDriverStats(selectedDriverId);
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
// Mutations
const updateSettings = useUpdateGpsSettings();
@@ -491,7 +494,15 @@ export function GpsTracking() {
<td className="px-4 py-3 text-sm text-muted-foreground">
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3 flex items-center gap-2">
<button
onClick={() => setShowQrDriverId(device.driverId)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
title="Show QR code"
>
<QrCode className="h-4 w-4 mr-1" />
QR
</button>
<button
onClick={() => {
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
@@ -603,18 +614,21 @@ export function GpsTracking() {
</label>
<input
type="number"
min={30}
min={10}
max={300}
defaultValue={settings.updateIntervalSeconds}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value >= 30 && value <= 300 && value !== settings.updateIntervalSeconds) {
if (value >= 10 && value <= 300 && value !== settings.updateIntervalSeconds) {
updateSettings.mutate({ updateIntervalSeconds: value });
}
}}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary"
/>
<p className="mt-1 text-xs text-muted-foreground">How often drivers report their location (30-300 seconds)</p>
<p className="mt-1 text-xs text-muted-foreground">How often drivers report their location (10-300 seconds)</p>
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-800 dark:text-amber-200">
<strong>Tip:</strong> 15-30s recommended for active events (smooth routes), 60s for routine use (saves battery). Changing this only affects new QR code enrollments.
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
@@ -871,6 +885,129 @@ export function GpsTracking() {
</div>
</div>
)}
{/* Device QR Code Modal */}
{showQrDriverId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center p-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">
{qrInfo ? `${qrInfo.driverName} - Setup QR` : 'Device QR Code'}
</h3>
<button
onClick={() => setShowQrDriverId(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
{qrLoading ? (
<Loading message="Loading QR code..." />
) : qrInfo ? (
<>
{/* QR Code */}
<div className="bg-white dark:bg-gray-900 p-4 rounded-lg border border-border text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<QrCode className="h-5 w-5 text-primary" />
<span className="text-sm font-medium">Scan with Traccar Client</span>
</div>
<div className="flex justify-center mb-3">
<QRCodeSVG
value={qrInfo.qrCodeUrl}
size={200}
level="M"
includeMargin
/>
</div>
<p className="text-xs text-muted-foreground">
Open Traccar Client app {''} tap the QR icon {''} scan this code
</p>
</div>
{/* Download links */}
<div className="bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">Download Traccar Client</span>
</div>
<div className="flex gap-2 justify-center">
<a
href="https://apps.apple.com/app/traccar-client/id843156974"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
iOS App Store
</a>
<a
href="https://play.google.com/store/apps/details?id=org.traccar.client"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
>
Google Play
</a>
</div>
</div>
{/* Manual fallback */}
<details className="bg-muted/30 rounded-lg border border-border">
<summary className="p-3 text-sm font-medium cursor-pointer hover:bg-muted/50 transition-colors">
Manual Setup (if QR doesn't work)
</summary>
<div className="px-3 pb-3 space-y-2">
<div>
<label className="text-xs text-muted-foreground">Device ID</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono">
{qrInfo.deviceIdentifier}
</code>
<button
onClick={() => copyToClipboard(qrInfo.deviceIdentifier)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Server URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono text-xs">
{qrInfo.serverUrl}
</code>
<button
onClick={() => copyToClipboard(qrInfo.serverUrl)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground mt-2">
<li>Open Traccar Client and enter Device ID and Server URL above</li>
<li>Set frequency to {qrInfo.updateIntervalSeconds} seconds</li>
<li>Tap "Service Status" to start tracking</li>
</ol>
</div>
</details>
</>
) : (
<ErrorMessage message="Failed to load QR code info" />
)}
<button
onClick={() => setShowQrDriverId(null)}
className="w-full px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
>
Done
</button>
</div>
</div>
</div>
)}
</div>
);
}