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