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>
525 lines
14 KiB
TypeScript
525 lines
14 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|