feat: add VIP roster tracking and accountability reports

- Add isRosterOnly flag for VIPs who attend but don't need transportation
- Add VIP contact fields (phone, email) and emergency contact info
- Create Reports page under Admin menu with Accountability Roster
- Report shows all VIPs (active + roster-only) with contact/emergency info
- Export to CSV functionality for emergency preparedness
- VIP list filters roster-only by default with toggle to show
- VIP form includes collapsible contact/emergency section
- Fix first-user race condition with Serializable transaction
- Remove Traccar hardcoded default credentials
- Add feature flags endpoint for optional services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 09:16:32 +01:00
parent 934464bf8e
commit b35c14fddc
14 changed files with 791 additions and 93 deletions

View File

@@ -21,6 +21,7 @@ import {
LogOut,
Phone,
AlertCircle,
FileText,
} from 'lucide-react';
import { UserMenu } from '@/components/UserMenu';
import { AppearanceMenu } from '@/components/AppearanceMenu';
@@ -59,6 +60,22 @@ export function Layout({ children }: LayoutProps) {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Fetch feature flags from backend (which optional services are configured)
const { data: features } = useQuery<{
copilot: boolean;
flightTracking: boolean;
signalMessaging: boolean;
gpsTracking: boolean;
}>({
queryKey: ['features'],
queryFn: async () => {
const { data } = await api.get('/settings/features');
return data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
enabled: !!backendUser, // Only fetch when authenticated
});
// Check if user is a driver (limited access)
const isDriverRole = backendUser?.role === 'DRIVER';
@@ -78,6 +95,7 @@ export function Layout({ children }: LayoutProps) {
// Admin dropdown items (nested under Admin)
const adminItems = [
{ name: 'Users', href: '/users', icon: UserCog },
{ name: 'Reports', href: '/reports', icon: FileText },
{ name: 'GPS Tracking', href: '/gps-tracking', icon: Radio },
{ name: 'Admin Tools', href: '/admin-tools', icon: Settings },
];
@@ -405,8 +423,8 @@ export function Layout({ children }: LayoutProps) {
{children}
</main>
{/* AI Copilot - floating chat (only for Admins and Coordinators) */}
{backendUser && (backendUser.role === 'ADMINISTRATOR' || backendUser.role === 'COORDINATOR') && (
{/* AI Copilot - floating chat (only if backend has API key and user is Admin/Coordinator) */}
{features?.copilot && backendUser && (backendUser.role === 'ADMINISTRATOR' || backendUser.role === 'COORDINATOR') && (
<AICopilot />
)}
</div>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
interface VIPFormProps {
vip?: VIP | null;
@@ -19,6 +19,11 @@ interface VIP {
venueTransport: boolean;
partySize: number;
notes: string | null;
isRosterOnly: boolean;
phone: string | null;
email: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
}
export interface VIPFormData {
@@ -31,6 +36,11 @@ export interface VIPFormData {
venueTransport?: boolean;
partySize?: number;
notes?: string;
isRosterOnly?: boolean;
phone?: string;
email?: string;
emergencyContactName?: string;
emergencyContactPhone?: string;
}
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
@@ -56,8 +66,17 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
venueTransport: vip?.venueTransport ?? false,
partySize: vip?.partySize ?? 1,
notes: vip?.notes || '',
isRosterOnly: vip?.isRosterOnly ?? false,
phone: vip?.phone || '',
email: vip?.email || '',
emergencyContactName: vip?.emergencyContactName || '',
emergencyContactPhone: vip?.emergencyContactPhone || '',
});
const [showRosterFields, setShowRosterFields] = useState(
vip?.isRosterOnly || vip?.phone || vip?.email || vip?.emergencyContactName ? true : false
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -69,6 +88,10 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
? new Date(formData.expectedArrival).toISOString()
: undefined,
notes: formData.notes || undefined,
phone: formData.phone || undefined,
email: formData.email || undefined,
emergencyContactName: formData.emergencyContactName || undefined,
emergencyContactPhone: formData.emergencyContactPhone || undefined,
};
onSubmit(cleanedData);
@@ -238,6 +261,118 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
</label>
</div>
{/* Roster & Contact Info Section */}
<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowRosterFields(!showRosterFields)}
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-foreground">Contact & Emergency Info</span>
{formData.isRosterOnly && (
<span className="px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-300 rounded">
Roster Only
</span>
)}
</div>
{showRosterFields ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showRosterFields && (
<div className="p-4 space-y-4 border-t border-border">
{/* Roster Only Toggle */}
<label className="flex items-center cursor-pointer" style={{ minHeight: '28px' }}>
<input
type="checkbox"
name="isRosterOnly"
checked={formData.isRosterOnly}
onChange={handleChange}
className="h-5 w-5 text-primary border-input rounded focus:ring-primary"
/>
<span className="ml-3 text-base text-foreground">
Roster only (accountability tracking, no active coordination)
</span>
</label>
<p className="text-xs text-muted-foreground -mt-2 ml-8">
Check this for VIPs who are attending but don't need transportation services
</p>
{/* VIP Contact Info */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Phone
</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="555-123-4567"
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
style={{ minHeight: '44px' }}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Email
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="vip@example.com"
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
style={{ minHeight: '44px' }}
/>
</div>
</div>
{/* Emergency Contact */}
<div className="pt-2">
<h4 className="text-sm font-medium text-foreground mb-3">Emergency Contact</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Name
</label>
<input
type="text"
name="emergencyContactName"
value={formData.emergencyContactName}
onChange={handleChange}
placeholder="Jane Doe"
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
style={{ minHeight: '44px' }}
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Phone
</label>
<input
type="tel"
name="emergencyContactPhone"
value={formData.emergencyContactPhone}
onChange={handleChange}
placeholder="555-987-6543"
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
style={{ minHeight: '44px' }}
/>
</div>
</div>
</div>
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">