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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user