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