From 8e8bbad3fc58124f2822beceed7b723dfd35f97f Mon Sep 17 00:00:00 2001 From: kyle Date: Tue, 3 Feb 2026 23:40:44 +0100 Subject: [PATCH] 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 --- .../migration.sql | 8 ++ backend/prisma/schema.prisma | 6 ++ backend/src/events/dto/create-event.dto.ts | 4 + backend/src/events/events.service.ts | 63 ++++++++++++--- backend/src/vips/dto/create-vip.dto.ts | 7 ++ frontend/src/components/EventForm.tsx | 77 +++++++++++++++++-- frontend/src/components/VIPForm.tsx | 25 ++++++ frontend/src/pages/CommandCenter.tsx | 29 +++++-- frontend/src/pages/EventList.tsx | 20 ++++- frontend/src/types/index.ts | 4 + 10 files changed, 214 insertions(+), 29 deletions(-) create mode 100644 backend/prisma/migrations/20260203100000_add_party_size_and_master_events/migration.sql diff --git a/backend/prisma/migrations/20260203100000_add_party_size_and_master_events/migration.sql b/backend/prisma/migrations/20260203100000_add_party_size_and_master_events/migration.sql new file mode 100644 index 0000000..8a9560d --- /dev/null +++ b/backend/prisma/migrations/20260203100000_add_party_size_and_master_events/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index dbf7f2b..1e0221b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/events/dto/create-event.dto.ts b/backend/src/events/dto/create-event.dto.ts index bcec358..ad89511 100644 --- a/backend/src/events/dto/create-event.dto.ts +++ b/backend/src/events/dto/create-event.dto.ts @@ -55,4 +55,8 @@ export class CreateEventDto { @IsUUID() @IsOptional() vehicleId?: string; + + @IsUUID() + @IsOptional() + masterEventId?: string; } diff --git a/backend/src/events/events.service.ts b/backend/src/events/events.service.ts index 99a5e47..32b9a1f 100644 --- a/backend/src/events/events.service.ts +++ b/backend/src/events/events.service.ts @@ -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, }); } diff --git a/backend/src/vips/dto/create-vip.dto.ts b/backend/src/vips/dto/create-vip.dto.ts index 8034ce2..f9bba80 100644 --- a/backend/src/vips/dto/create-vip.dto.ts +++ b/backend/src/vips/dto/create-vip.dto.ts @@ -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; diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index b9c96a7..65d05c0 100644 --- a/frontend/src/components/EventForm.tsx +++ b/frontend/src/components/EventForm.tsx @@ -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({ + 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
+ {/* Master Event Selector (link transport to itinerary item) */} + {formData.type === 'TRANSPORT' && masterEventOptions.length > 0 && ( +
+ + +

+ Link this transport to a shared activity. VIPs will auto-populate from the linked event. +

+
+ )} + {/* VIP Multi-Select */}
diff --git a/frontend/src/components/VIPForm.tsx b/frontend/src/components/VIPForm.tsx index 15a1f3e..8435783 100644 --- a/frontend/src/components/VIPForm.tsx +++ b/frontend/src/components/VIPForm.tsx @@ -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) /> + {/* Party Size */} +
+ + +

+ Total seats needed: the VIP plus any handlers, spouses, or entourage +

+
+ {/* Department */}