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:
@@ -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;
|
||||
@@ -50,6 +50,7 @@ model VIP {
|
||||
expectedArrival DateTime? // For self-driving arrivals
|
||||
airportPickup Boolean @default(false)
|
||||
venueTransport Boolean @default(false)
|
||||
partySize Int @default(1) // Total people: VIP + entourage
|
||||
notes String? @db.Text
|
||||
flights Flight[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -197,6 +198,11 @@ model ScheduleEvent {
|
||||
vehicleId String?
|
||||
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
|
||||
notes String? @db.Text
|
||||
|
||||
|
||||
@@ -55,4 +55,8 @@ export class CreateEventDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
vehicleId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
masterEventId?: string;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class EventsService {
|
||||
if (createEventDto.vehicleId && createEventDto.vipIds) {
|
||||
await this.checkVehicleCapacity(
|
||||
createEventDto.vehicleId,
|
||||
createEventDto.vipIds.length,
|
||||
createEventDto.vipIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ export class EventsService {
|
||||
include: {
|
||||
driver: 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: {
|
||||
driver: 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' },
|
||||
});
|
||||
@@ -96,6 +110,13 @@ export class EventsService {
|
||||
include: {
|
||||
driver: 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
|
||||
const vehicleId = updateEventDto.vehicleId || event.vehicleId;
|
||||
const vipCount = updateEventDto.vipIds
|
||||
? updateEventDto.vipIds.length
|
||||
: event.vipIds.length;
|
||||
const vipIds = updateEventDto.vipIds || event.vipIds;
|
||||
|
||||
if (vehicleId && vipCount > 0 && !updateEventDto.forceAssign) {
|
||||
await this.checkVehicleCapacity(vehicleId, vipCount);
|
||||
if (vehicleId && vipIds.length > 0 && !updateEventDto.forceAssign) {
|
||||
await this.checkVehicleCapacity(vehicleId, vipIds);
|
||||
}
|
||||
|
||||
// Check for conflicts if driver or times are being updated (unless forceAssign is true)
|
||||
@@ -190,6 +209,13 @@ export class EventsService {
|
||||
include: {
|
||||
driver: 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: {
|
||||
driver: 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({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
});
|
||||
@@ -244,14 +277,20 @@ export class EventsService {
|
||||
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(
|
||||
`Vehicle capacity exceeded: ${vipCount} VIPs > ${vehicle.seatCapacity} seats`,
|
||||
`Vehicle capacity exceeded: ${totalPeople} people > ${vehicle.seatCapacity} seats`,
|
||||
);
|
||||
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,
|
||||
requested: vipCount,
|
||||
requested: totalPeople,
|
||||
exceeded: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Department, ArrivalMode } from '@prisma/client';
|
||||
|
||||
@@ -33,6 +35,11 @@ export class CreateVipDto {
|
||||
@IsOptional()
|
||||
venueTransport?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(1)
|
||||
partySize?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@@ -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