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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -55,4 +55,8 @@ export class CreateEventDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
masterEventId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user