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

@@ -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>
);
}