feat: add party size tracking and master event linking

Add partySize field to VIP model (default 1) to track total people
traveling with each VIP including entourage/handlers/spouses. Vehicle
capacity checks now sum party sizes instead of just counting VIPs.

Add masterEventId self-reference to ScheduleEvent for linking transport
legs to shared itinerary items (events, meetings, meals). When creating
a transport event, users can link it to a shared activity and VIPs
auto-populate from the linked event.

Changes:
- Schema: partySize on VIP, masterEventId on ScheduleEvent
- Backend: party-size-aware capacity checks, master/child event includes
- VIP Form: party size input with helper text
- Event Form: party-size capacity display, master event selector
- Event List: party size in capacity and VIP names, master event badges
- Command Center: all VIP names shown with party size indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 23:40:44 +01:00
parent 714cac5d10
commit 8e8bbad3fc
10 changed files with 214 additions and 29 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import { X, AlertTriangle, Users, Car } from 'lucide-react';
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
import { api } from '@/lib/api';
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
import { formatDateTime } from '@/lib/utils';
@@ -27,6 +27,7 @@ export interface EventFormData {
status: string;
driverId?: string;
vehicleId?: string;
masterEventId?: string;
forceAssign?: boolean;
}
@@ -63,6 +64,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
status: event?.status || 'SCHEDULED',
driverId: event?.driverId || '',
vehicleId: event?.vehicleId || '',
masterEventId: event?.masterEventId || '',
});
const [showConflictDialog, setShowConflictDialog] = useState(false);
@@ -98,9 +100,30 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
},
});
// Get selected vehicle capacity
// Fetch all events (for master event selector)
const { data: allEvents } = useQuery<ScheduleEvent[]>({
queryKey: ['events'],
queryFn: async () => {
const { data } = await api.get('/events');
return data;
},
});
// Filter to itinerary items (non-transport events) for master event dropdown
const masterEventOptions = useMemo(() => {
if (!allEvents) return [];
return allEvents.filter(e =>
e.type !== 'TRANSPORT' &&
e.status !== 'CANCELLED' &&
e.id !== event?.id // Exclude self
);
}, [allEvents, event?.id]);
// Get selected vehicle capacity (using party sizes)
const selectedVehicle = vehicles?.find(v => v.id === formData.vehicleId);
const seatsUsed = formData.vipIds.length;
const seatsUsed = vips
?.filter(v => formData.vipIds.includes(v.id))
.reduce((sum, v) => sum + (v.partySize || 1), 0) || 0;
const seatsAvailable = selectedVehicle ? selectedVehicle.seatCapacity : 0;
const handleSubmit = (e: React.FormEvent) => {
@@ -117,6 +140,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
description: formData.description || undefined,
driverId: formData.driverId || undefined,
vehicleId: formData.vehicleId || undefined,
masterEventId: formData.masterEventId || undefined,
};
// Store for potential retry
@@ -195,7 +219,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
const selectedVipNames = vips
?.filter(vip => formData.vipIds.includes(vip.id))
.map(vip => vip.name)
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
.join(', ') || 'None selected';
return (
@@ -216,6 +240,42 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Master Event Selector (link transport to itinerary item) */}
{formData.type === 'TRANSPORT' && masterEventOptions.length > 0 && (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
<Link2 className="inline h-4 w-4 mr-1" />
Linked Itinerary Item (optional)
</label>
<select
name="masterEventId"
value={formData.masterEventId}
onChange={(e) => {
const selectedId = e.target.value;
setFormData(prev => ({ ...prev, masterEventId: selectedId }));
// Auto-fill VIPs from the selected master event
if (selectedId) {
const masterEvent = allEvents?.find(ev => ev.id === selectedId);
if (masterEvent?.vipIds && masterEvent.vipIds.length > 0) {
setFormData(prev => ({ ...prev, masterEventId: selectedId, vipIds: masterEvent.vipIds }));
}
}
}}
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">No linked event</option>
{masterEventOptions.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title} ({ev.type}) {formatDateTime(ev.startTime)}
</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
Link this transport to a shared activity. VIPs will auto-populate from the linked event.
</p>
</div>
)}
{/* VIP Multi-Select */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
@@ -238,6 +298,9 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
/>
<span className="ml-3 text-base text-foreground">
{vip.name}
{vip.partySize > 1 && (
<span className="text-xs text-blue-600 dark:text-blue-400 ml-1.5 font-medium">+{vip.partySize - 1}</span>
)}
{vip.organization && (
<span className="text-sm text-muted-foreground ml-2">({vip.organization})</span>
)}
@@ -348,8 +411,8 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
</select>
{selectedVehicle && (
<div className={`mt-2 text-sm ${seatsUsed > seatsAvailable ? 'text-red-600 font-medium' : 'text-muted-foreground'}`}>
Capacity: {seatsUsed}/{seatsAvailable} seats used
{seatsUsed > seatsAvailable && ' ⚠️ OVER CAPACITY'}
Capacity: {seatsUsed}/{seatsAvailable} seats (incl. entourage)
{seatsUsed > seatsAvailable && ' OVER CAPACITY'}
</div>
)}
</div>

View File

@@ -17,6 +17,7 @@ interface VIP {
expectedArrival: string | null;
airportPickup: boolean;
venueTransport: boolean;
partySize: number;
notes: string | null;
}
@@ -28,6 +29,7 @@ export interface VIPFormData {
expectedArrival?: string;
airportPickup?: boolean;
venueTransport?: boolean;
partySize?: number;
notes?: string;
}
@@ -52,6 +54,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
expectedArrival: toDatetimeLocal(vip?.expectedArrival || null),
airportPickup: vip?.airportPickup ?? false,
venueTransport: vip?.venueTransport ?? false,
partySize: vip?.partySize ?? 1,
notes: vip?.notes || '',
});
@@ -80,6 +83,8 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
[name]:
type === 'checkbox'
? (e.target as HTMLInputElement).checked
: type === 'number'
? parseInt(value) || 1
: value,
}));
};
@@ -133,6 +138,26 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
/>
</div>
{/* Party Size */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Party Size (VIP + companions)
</label>
<input
type="number"
name="partySize"
min={1}
max={50}
value={formData.partySize}
onChange={handleChange}
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
style={{ minHeight: '44px' }}
/>
<p className="mt-1 text-xs text-muted-foreground">
Total seats needed: the VIP plus any handlers, spouses, or entourage
</p>
</div>
{/* Department */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">