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

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "vips" ADD COLUMN "partySize" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "schedule_events" ADD COLUMN "masterEventId" TEXT;
-- AddForeignKey
ALTER TABLE "schedule_events" ADD CONSTRAINT "schedule_events_masterEventId_fkey" FOREIGN KEY ("masterEventId") REFERENCES "schedule_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -50,6 +50,7 @@ model VIP {
expectedArrival DateTime? // For self-driving arrivals expectedArrival DateTime? // For self-driving arrivals
airportPickup Boolean @default(false) airportPickup Boolean @default(false)
venueTransport Boolean @default(false) venueTransport Boolean @default(false)
partySize Int @default(1) // Total people: VIP + entourage
notes String? @db.Text notes String? @db.Text
flights Flight[] flights Flight[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -197,6 +198,11 @@ model ScheduleEvent {
vehicleId String? vehicleId String?
vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull) vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
// Master/child event hierarchy (shared activity → transport legs)
masterEventId String?
masterEvent ScheduleEvent? @relation("EventHierarchy", fields: [masterEventId], references: [id], onDelete: SetNull)
childEvents ScheduleEvent[] @relation("EventHierarchy")
// Metadata // Metadata
notes String? @db.Text notes String? @db.Text

View File

@@ -55,4 +55,8 @@ export class CreateEventDto {
@IsUUID() @IsUUID()
@IsOptional() @IsOptional()
vehicleId?: string; vehicleId?: string;
@IsUUID()
@IsOptional()
masterEventId?: string;
} }

View File

@@ -34,7 +34,7 @@ export class EventsService {
if (createEventDto.vehicleId && createEventDto.vipIds) { if (createEventDto.vehicleId && createEventDto.vipIds) {
await this.checkVehicleCapacity( await this.checkVehicleCapacity(
createEventDto.vehicleId, createEventDto.vehicleId,
createEventDto.vipIds.length, createEventDto.vipIds,
); );
} }
@@ -71,6 +71,13 @@ export class EventsService {
include: { include: {
driver: true, driver: true,
vehicle: true, vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
}, },
}); });
@@ -83,6 +90,13 @@ export class EventsService {
include: { include: {
driver: true, driver: true,
vehicle: true, vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
}, },
orderBy: { startTime: 'asc' }, orderBy: { startTime: 'asc' },
}); });
@@ -96,6 +110,13 @@ export class EventsService {
include: { include: {
driver: true, driver: true,
vehicle: true, vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
}, },
}); });
@@ -125,12 +146,10 @@ export class EventsService {
// Check vehicle capacity if vehicle or VIPs are being updated // Check vehicle capacity if vehicle or VIPs are being updated
const vehicleId = updateEventDto.vehicleId || event.vehicleId; const vehicleId = updateEventDto.vehicleId || event.vehicleId;
const vipCount = updateEventDto.vipIds const vipIds = updateEventDto.vipIds || event.vipIds;
? updateEventDto.vipIds.length
: event.vipIds.length;
if (vehicleId && vipCount > 0 && !updateEventDto.forceAssign) { if (vehicleId && vipIds.length > 0 && !updateEventDto.forceAssign) {
await this.checkVehicleCapacity(vehicleId, vipCount); await this.checkVehicleCapacity(vehicleId, vipIds);
} }
// Check for conflicts if driver or times are being updated (unless forceAssign is true) // Check for conflicts if driver or times are being updated (unless forceAssign is true)
@@ -190,6 +209,13 @@ export class EventsService {
include: { include: {
driver: true, driver: true,
vehicle: true, vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
}, },
}); });
@@ -209,6 +235,13 @@ export class EventsService {
include: { include: {
driver: true, driver: true,
vehicle: true, vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
}, },
}); });
@@ -233,9 +266,9 @@ export class EventsService {
} }
/** /**
* Check vehicle capacity * Check vehicle capacity using sum of VIP party sizes
*/ */
private async checkVehicleCapacity(vehicleId: string, vipCount: number) { private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
const vehicle = await this.prisma.vehicle.findFirst({ const vehicle = await this.prisma.vehicle.findFirst({
where: { id: vehicleId, deletedAt: null }, where: { id: vehicleId, deletedAt: null },
}); });
@@ -244,14 +277,20 @@ export class EventsService {
throw new NotFoundException('Vehicle not found'); throw new NotFoundException('Vehicle not found');
} }
if (vipCount > vehicle.seatCapacity) { const vips = await this.prisma.vIP.findMany({
where: { id: { in: vipIds }, deletedAt: null },
select: { partySize: true },
});
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
if (totalPeople > vehicle.seatCapacity) {
this.logger.warn( this.logger.warn(
`Vehicle capacity exceeded: ${vipCount} VIPs > ${vehicle.seatCapacity} seats`, `Vehicle capacity exceeded: ${totalPeople} people > ${vehicle.seatCapacity} seats`,
); );
throw new BadRequestException({ throw new BadRequestException({
message: `Vehicle capacity exceeded: ${vipCount} VIPs require more than ${vehicle.seatCapacity} available seats`, message: `Vehicle capacity exceeded: ${totalPeople} people require more than ${vehicle.seatCapacity} available seats`,
capacity: vehicle.seatCapacity, capacity: vehicle.seatCapacity,
requested: vipCount, requested: totalPeople,
exceeded: true, exceeded: true,
}); });
} }

View File

@@ -4,6 +4,8 @@ import {
IsOptional, IsOptional,
IsBoolean, IsBoolean,
IsDateString, IsDateString,
IsInt,
Min,
} from 'class-validator'; } from 'class-validator';
import { Department, ArrivalMode } from '@prisma/client'; import { Department, ArrivalMode } from '@prisma/client';
@@ -33,6 +35,11 @@ export class CreateVipDto {
@IsOptional() @IsOptional()
venueTransport?: boolean; venueTransport?: boolean;
@IsInt()
@IsOptional()
@Min(1)
partySize?: number;
@IsString() @IsString()
@IsOptional() @IsOptional()
notes?: string; notes?: string;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query'; 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 { api } from '@/lib/api';
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types'; import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
@@ -27,6 +27,7 @@ export interface EventFormData {
status: string; status: string;
driverId?: string; driverId?: string;
vehicleId?: string; vehicleId?: string;
masterEventId?: string;
forceAssign?: boolean; forceAssign?: boolean;
} }
@@ -63,6 +64,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
status: event?.status || 'SCHEDULED', status: event?.status || 'SCHEDULED',
driverId: event?.driverId || '', driverId: event?.driverId || '',
vehicleId: event?.vehicleId || '', vehicleId: event?.vehicleId || '',
masterEventId: event?.masterEventId || '',
}); });
const [showConflictDialog, setShowConflictDialog] = useState(false); 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 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 seatsAvailable = selectedVehicle ? selectedVehicle.seatCapacity : 0;
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -117,6 +140,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
description: formData.description || undefined, description: formData.description || undefined,
driverId: formData.driverId || undefined, driverId: formData.driverId || undefined,
vehicleId: formData.vehicleId || undefined, vehicleId: formData.vehicleId || undefined,
masterEventId: formData.masterEventId || undefined,
}; };
// Store for potential retry // Store for potential retry
@@ -195,7 +219,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
const selectedVipNames = vips const selectedVipNames = vips
?.filter(vip => formData.vipIds.includes(vip.id)) ?.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'; .join(', ') || 'None selected';
return ( return (
@@ -216,6 +240,42 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-4"> <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 */} {/* VIP Multi-Select */}
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2"> <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"> <span className="ml-3 text-base text-foreground">
{vip.name} {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 && ( {vip.organization && (
<span className="text-sm text-muted-foreground ml-2">({vip.organization})</span> <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> </select>
{selectedVehicle && ( {selectedVehicle && (
<div className={`mt-2 text-sm ${seatsUsed > seatsAvailable ? 'text-red-600 font-medium' : 'text-muted-foreground'}`}> <div className={`mt-2 text-sm ${seatsUsed > seatsAvailable ? 'text-red-600 font-medium' : 'text-muted-foreground'}`}>
Capacity: {seatsUsed}/{seatsAvailable} seats used Capacity: {seatsUsed}/{seatsAvailable} seats (incl. entourage)
{seatsUsed > seatsAvailable && ' ⚠️ OVER CAPACITY'} {seatsUsed > seatsAvailable && ' OVER CAPACITY'}
</div> </div>
)} )}
</div> </div>

View File

@@ -17,6 +17,7 @@ interface VIP {
expectedArrival: string | null; expectedArrival: string | null;
airportPickup: boolean; airportPickup: boolean;
venueTransport: boolean; venueTransport: boolean;
partySize: number;
notes: string | null; notes: string | null;
} }
@@ -28,6 +29,7 @@ export interface VIPFormData {
expectedArrival?: string; expectedArrival?: string;
airportPickup?: boolean; airportPickup?: boolean;
venueTransport?: boolean; venueTransport?: boolean;
partySize?: number;
notes?: string; notes?: string;
} }
@@ -52,6 +54,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
expectedArrival: toDatetimeLocal(vip?.expectedArrival || null), expectedArrival: toDatetimeLocal(vip?.expectedArrival || null),
airportPickup: vip?.airportPickup ?? false, airportPickup: vip?.airportPickup ?? false,
venueTransport: vip?.venueTransport ?? false, venueTransport: vip?.venueTransport ?? false,
partySize: vip?.partySize ?? 1,
notes: vip?.notes || '', notes: vip?.notes || '',
}); });
@@ -80,6 +83,8 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
[name]: [name]:
type === 'checkbox' type === 'checkbox'
? (e.target as HTMLInputElement).checked ? (e.target as HTMLInputElement).checked
: type === 'number'
? parseInt(value) || 1
: value, : value,
})); }));
}; };
@@ -133,6 +138,26 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
/> />
</div> </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 */} {/* Department */}
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2"> <label className="block text-sm font-medium text-foreground mb-2">

View File

@@ -42,7 +42,13 @@ interface Event {
vip: { vip: {
id: string; id: string;
name: string; name: string;
partySize?: number;
} | null; } | null;
vips?: Array<{
id: string;
name: string;
partySize: number;
}>;
driver: { driver: {
id: string; id: string;
name: string; name: string;
@@ -371,29 +377,30 @@ export function CommandCenter() {
if (minutesLate >= 5) { if (minutesLate >= 5) {
alerts.push({ alerts.push({
type: 'critical', 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`, link: `/events`,
}); });
} }
}); });
unassignedEvents.forEach((event) => { unassignedEvents.forEach((event) => {
const vipLabel = formatVipNames(event);
if (!event.driver && !event.vehicle) { if (!event.driver && !event.vehicle) {
alerts.push({ alerts.push({
type: 'critical', 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`, link: `/events`,
}); });
} else if (!event.driver) { } else if (!event.driver) {
alerts.push({ alerts.push({
type: 'critical', type: 'critical',
message: `${event.vip?.name || 'Unknown VIP'}: No driver assigned (${getTimeUntil(event.startTime)})`, message: `${vipLabel}: No driver assigned (${getTimeUntil(event.startTime)})`,
link: `/events`, link: `/events`,
}); });
} else if (!event.vehicle) { } else if (!event.vehicle) {
alerts.push({ alerts.push({
type: 'warning', type: 'warning',
message: `${event.vip?.name || 'Unknown VIP'}: No vehicle assigned (${getTimeUntil(event.startTime)})`, message: `${vipLabel}: No vehicle assigned (${getTimeUntil(event.startTime)})`,
link: `/events`, link: `/events`,
}); });
} }
@@ -442,6 +449,16 @@ export function CommandCenter() {
return 'text-blue-600 dark:text-blue-400'; 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); const secondsSinceRefresh = Math.floor((now.getTime() - lastRefresh.getTime()) / 1000);
// System status // System status
@@ -642,7 +659,7 @@ export function CommandCenter() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-foreground truncate text-sm"> <span className="font-semibold text-foreground truncate text-sm">
{trip.vip?.name || 'Unknown VIP'} {formatVipNames(trip)}
</span> </span>
<span className="text-muted-foreground text-xs truncate max-w-[150px]"> <span className="text-muted-foreground text-xs truncate max-w-[150px]">
{trip.title} {trip.title}
@@ -739,7 +756,7 @@ export function CommandCenter() {
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
{isImminent && <AlertTriangle className="h-3 w-3 text-red-600 flex-shrink-0" />} {isImminent && <AlertTriangle className="h-3 w-3 text-red-600 flex-shrink-0" />}
<span className="font-semibold text-foreground truncate text-sm"> <span className="font-semibold text-foreground truncate text-sm">
{trip.vip?.name || 'Unknown VIP'} {formatVipNames(trip)}
</span> </span>
<span className="text-muted-foreground text-xs truncate max-w-[150px]"> <span className="text-muted-foreground text-xs truncate max-w-[150px]">
{trip.title} {trip.title}

View File

@@ -390,8 +390,18 @@ export function EventList() {
<tbody className="bg-card divide-y divide-border"> <tbody className="bg-card divide-y divide-border">
{filteredEvents?.map((event) => ( {filteredEvents?.map((event) => (
<tr key={event.id} className="hover:bg-muted/50 transition-colors"> <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"> <td className="px-6 py-4 text-sm font-medium text-foreground">
{event.title} <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>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs rounded-full ${ <span className={`px-2 py-1 text-xs rounded-full ${
@@ -406,7 +416,9 @@ export function EventList() {
</td> </td>
<td className="px-6 py-4 text-sm text-muted-foreground"> <td className="px-6 py-4 text-sm text-muted-foreground">
{event.vips && event.vips.length > 0 {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'} : 'No VIPs assigned'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground"> <td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
@@ -414,7 +426,7 @@ export function EventList() {
<div> <div>
<div className="text-foreground">{event.vehicle.name}</div> <div className="text-foreground">{event.vehicle.name}</div>
<div className="text-xs text-muted-foreground"> <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>
</div> </div>
) : ( ) : (

View File

@@ -40,6 +40,7 @@ export interface VIP {
expectedArrival: string | null; expectedArrival: string | null;
airportPickup: boolean; airportPickup: boolean;
venueTransport: boolean; venueTransport: boolean;
partySize: number;
notes: string | null; notes: string | null;
flights?: Flight[]; flights?: Flight[];
events?: ScheduleEvent[]; events?: ScheduleEvent[];
@@ -127,6 +128,9 @@ export interface ScheduleEvent {
driver?: Driver | null; driver?: Driver | null;
vehicleId: string | null; vehicleId: string | null;
vehicle?: Vehicle | 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; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;