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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -42,7 +42,13 @@ interface Event {
|
||||
vip: {
|
||||
id: string;
|
||||
name: string;
|
||||
partySize?: number;
|
||||
} | null;
|
||||
vips?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
partySize: number;
|
||||
}>;
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -371,29 +377,30 @@ export function CommandCenter() {
|
||||
if (minutesLate >= 5) {
|
||||
alerts.push({
|
||||
type: 'critical',
|
||||
message: `${event.vip?.name || 'Unknown VIP'}: Trip ${minutesLate}min overdue - awaiting driver confirmation`,
|
||||
message: `${formatVipNames(event)}: Trip ${minutesLate}min overdue - awaiting driver confirmation`,
|
||||
link: `/events`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
unassignedEvents.forEach((event) => {
|
||||
const vipLabel = formatVipNames(event);
|
||||
if (!event.driver && !event.vehicle) {
|
||||
alerts.push({
|
||||
type: 'critical',
|
||||
message: `${event.vip?.name || 'Unknown VIP'}: No driver or vehicle assigned (${getTimeUntil(event.startTime)})`,
|
||||
message: `${vipLabel}: No driver or vehicle assigned (${getTimeUntil(event.startTime)})`,
|
||||
link: `/events`,
|
||||
});
|
||||
} else if (!event.driver) {
|
||||
alerts.push({
|
||||
type: 'critical',
|
||||
message: `${event.vip?.name || 'Unknown VIP'}: No driver assigned (${getTimeUntil(event.startTime)})`,
|
||||
message: `${vipLabel}: No driver assigned (${getTimeUntil(event.startTime)})`,
|
||||
link: `/events`,
|
||||
});
|
||||
} else if (!event.vehicle) {
|
||||
alerts.push({
|
||||
type: 'warning',
|
||||
message: `${event.vip?.name || 'Unknown VIP'}: No vehicle assigned (${getTimeUntil(event.startTime)})`,
|
||||
message: `${vipLabel}: No vehicle assigned (${getTimeUntil(event.startTime)})`,
|
||||
link: `/events`,
|
||||
});
|
||||
}
|
||||
@@ -442,6 +449,16 @@ export function CommandCenter() {
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
}
|
||||
|
||||
// Format VIP names for trip cards (show all, with party size indicators)
|
||||
function formatVipNames(event: Event): string {
|
||||
if (event.vips && event.vips.length > 0) {
|
||||
return event.vips.map(v =>
|
||||
v.partySize > 1 ? `${v.name} (+${v.partySize - 1})` : v.name
|
||||
).join(', ');
|
||||
}
|
||||
return event.vip?.name || 'Unknown VIP';
|
||||
}
|
||||
|
||||
const secondsSinceRefresh = Math.floor((now.getTime() - lastRefresh.getTime()) / 1000);
|
||||
|
||||
// System status
|
||||
@@ -642,7 +659,7 @@ export function CommandCenter() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-foreground truncate text-sm">
|
||||
{trip.vip?.name || 'Unknown VIP'}
|
||||
{formatVipNames(trip)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs truncate max-w-[150px]">
|
||||
{trip.title}
|
||||
@@ -739,7 +756,7 @@ export function CommandCenter() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{isImminent && <AlertTriangle className="h-3 w-3 text-red-600 flex-shrink-0" />}
|
||||
<span className="font-semibold text-foreground truncate text-sm">
|
||||
{trip.vip?.name || 'Unknown VIP'}
|
||||
{formatVipNames(trip)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs truncate max-w-[150px]">
|
||||
{trip.title}
|
||||
|
||||
@@ -390,8 +390,18 @@ export function EventList() {
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{filteredEvents?.map((event) => (
|
||||
<tr key={event.id} className="hover:bg-muted/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||
{event.title}
|
||||
<td className="px-6 py-4 text-sm font-medium text-foreground">
|
||||
<div>{event.title}</div>
|
||||
{event.masterEvent && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
↳ {event.masterEvent.title}
|
||||
</div>
|
||||
)}
|
||||
{event.childEvents && event.childEvents.length > 0 && (
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
{event.childEvents.length} linked transport{event.childEvents.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
@@ -406,7 +416,9 @@ export function EventList() {
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-muted-foreground">
|
||||
{event.vips && event.vips.length > 0
|
||||
? event.vips.map(vip => vip.name).join(', ')
|
||||
? event.vips.map(vip =>
|
||||
vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name
|
||||
).join(', ')
|
||||
: 'No VIPs assigned'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
@@ -414,7 +426,7 @@ export function EventList() {
|
||||
<div>
|
||||
<div className="text-foreground">{event.vehicle.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{event.vips?.length || 0}/{event.vehicle.seatCapacity} seats
|
||||
{event.vips?.reduce((sum, v) => sum + (v.partySize || 1), 0) || 0}/{event.vehicle.seatCapacity} seats
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface VIP {
|
||||
expectedArrival: string | null;
|
||||
airportPickup: boolean;
|
||||
venueTransport: boolean;
|
||||
partySize: number;
|
||||
notes: string | null;
|
||||
flights?: Flight[];
|
||||
events?: ScheduleEvent[];
|
||||
@@ -127,6 +128,9 @@ export interface ScheduleEvent {
|
||||
driver?: Driver | null;
|
||||
vehicleId: string | null;
|
||||
vehicle?: Vehicle | null;
|
||||
masterEventId: string | null;
|
||||
masterEvent?: { id: string; title: string; type: EventType; startTime: string; endTime: string } | null;
|
||||
childEvents?: { id: string; title: string; type: EventType }[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
|
||||
Reference in New Issue
Block a user