feat: add VIP roster tracking and accountability reports
- Add isRosterOnly flag for VIPs who attend but don't need transportation - Add VIP contact fields (phone, email) and emergency contact info - Create Reports page under Admin menu with Accountability Roster - Report shows all VIPs (active + roster-only) with contact/emergency info - Export to CSV functionality for emergency preparedness - VIP list filters roster-only by default with toggle to show - VIP form includes collapsible contact/emergency section - Fix first-user race condition with Serializable transaction - Remove Traccar hardcoded default credentials - Add feature flags endpoint for optional services Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
40
backend/.env
40
backend/.env
@@ -1,40 +0,0 @@
|
||||
# ============================================
|
||||
# Application Configuration
|
||||
# ============================================
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# ============================================
|
||||
DATABASE_URL="postgresql://postgres:changeme@localhost:5433/vip_coordinator"
|
||||
|
||||
# ============================================
|
||||
# Redis Configuration (Optional)
|
||||
# ============================================
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# ============================================
|
||||
# Auth0 Configuration
|
||||
# ============================================
|
||||
# Get these from your Auth0 dashboard:
|
||||
# 1. Create Application (Single Page Application)
|
||||
# 2. Create API
|
||||
# 3. Configure callback URLs: http://localhost:5173/callback
|
||||
AUTH0_DOMAIN="dev-s855cy3bvjjbkljt.us.auth0.com"
|
||||
AUTH0_AUDIENCE="https://vip-coordinator-api"
|
||||
AUTH0_ISSUER="https://dev-s855cy3bvjjbkljt.us.auth0.com/"
|
||||
|
||||
# ============================================
|
||||
# Flight Tracking API (Optional)
|
||||
# ============================================
|
||||
# Get API key from: https://aviationstack.com/
|
||||
AVIATIONSTACK_API_KEY="your-aviationstack-api-key"
|
||||
|
||||
# ============================================
|
||||
# AI Copilot Configuration (Optional)
|
||||
# ============================================
|
||||
# Get API key from: https://console.anthropic.com/
|
||||
# Cost: ~$3 per million tokens
|
||||
ANTHROPIC_API_KEY="your-anthropic-api-key"
|
||||
@@ -6,19 +6,19 @@ NODE_ENV=development
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# Database Configuration (required)
|
||||
# ============================================
|
||||
# Port 5433 is used to avoid conflicts with local PostgreSQL
|
||||
DATABASE_URL="postgresql://postgres:changeme@localhost:5433/vip_coordinator"
|
||||
|
||||
# ============================================
|
||||
# Redis Configuration (Optional)
|
||||
# Redis Configuration (required)
|
||||
# ============================================
|
||||
# Port 6380 is used to avoid conflicts with local Redis
|
||||
REDIS_URL="redis://localhost:6380"
|
||||
|
||||
# ============================================
|
||||
# Auth0 Configuration
|
||||
# Auth0 Configuration (required)
|
||||
# ============================================
|
||||
# Get these from your Auth0 dashboard:
|
||||
# 1. Create Application (Single Page Application)
|
||||
@@ -29,6 +29,16 @@ AUTH0_AUDIENCE="https://your-api-identifier"
|
||||
AUTH0_ISSUER="https://your-tenant.us.auth0.com/"
|
||||
|
||||
# ============================================
|
||||
# Flight Tracking API (Optional)
|
||||
# Optional Services
|
||||
# ============================================
|
||||
AVIATIONSTACK_API_KEY="your-aviationstack-api-key"
|
||||
# Leave empty or remove to disable the feature.
|
||||
# The app auto-detects which features are available.
|
||||
|
||||
# Flight tracking API (https://aviationstack.com/)
|
||||
AVIATIONSTACK_API_KEY=
|
||||
|
||||
# AI Copilot (https://console.anthropic.com/)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Signal webhook authentication (recommended in production)
|
||||
SIGNAL_WEBHOOK_SECRET=
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "vips" ADD COLUMN "email" TEXT,
|
||||
ADD COLUMN "emergencyContactName" TEXT,
|
||||
ADD COLUMN "emergencyContactPhone" TEXT,
|
||||
ADD COLUMN "isRosterOnly" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "phone" TEXT;
|
||||
@@ -52,6 +52,16 @@ model VIP {
|
||||
venueTransport Boolean @default(false)
|
||||
partySize Int @default(1) // Total people: VIP + entourage
|
||||
notes String? @db.Text
|
||||
|
||||
// Roster-only flag: true = just tracking for accountability, not active coordination
|
||||
isRosterOnly Boolean @default(false)
|
||||
|
||||
// Emergency contact info (for accountability reports)
|
||||
phone String?
|
||||
email String?
|
||||
emergencyContactName String?
|
||||
emergencyContactPhone String?
|
||||
|
||||
flights Flight[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -18,15 +18,17 @@ export class AuthService {
|
||||
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
|
||||
const picture = payload[`${namespace}/picture`] || payload.picture;
|
||||
|
||||
// Check if user exists
|
||||
let user = await this.prisma.user.findUnique({
|
||||
where: { auth0Id },
|
||||
// Check if user exists (exclude soft-deleted users)
|
||||
let user = await this.prisma.user.findFirst({
|
||||
where: { auth0Id, deletedAt: null },
|
||||
include: { driver: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Check if this is the first user (auto-approve as admin)
|
||||
const approvedUserCount = await this.prisma.user.count({
|
||||
// Use serializable transaction to prevent race condition
|
||||
// where two simultaneous registrations both become admin
|
||||
user = await this.prisma.$transaction(async (tx) => {
|
||||
const approvedUserCount = await tx.user.count({
|
||||
where: { isApproved: true, deletedAt: null },
|
||||
});
|
||||
const isFirstUser = approvedUserCount === 0;
|
||||
@@ -35,35 +37,37 @@ export class AuthService {
|
||||
`Creating new user: ${email} (approvedUserCount: ${approvedUserCount}, isFirstUser: ${isFirstUser})`,
|
||||
);
|
||||
|
||||
// Create new user
|
||||
// First user is auto-approved as ADMINISTRATOR
|
||||
// Subsequent users default to DRIVER and require approval
|
||||
user = await this.prisma.user.create({
|
||||
const newUser = await tx.user.create({
|
||||
data: {
|
||||
auth0Id,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
role: isFirstUser ? Role.ADMINISTRATOR : Role.DRIVER,
|
||||
isApproved: isFirstUser, // Auto-approve first user only
|
||||
isApproved: isFirstUser,
|
||||
},
|
||||
include: { driver: true },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`User created: ${user.email} with role ${user.role} (approved: ${user.isApproved})`,
|
||||
`User created: ${newUser.email} with role ${newUser.role} (approved: ${newUser.isApproved})`,
|
||||
);
|
||||
|
||||
return newUser;
|
||||
}, { isolationLevel: 'Serializable' });
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* Get current user profile (excludes soft-deleted users)
|
||||
*/
|
||||
async getCurrentUser(auth0Id: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { auth0Id },
|
||||
return this.prisma.user.findFirst({
|
||||
where: { auth0Id, deletedAt: null },
|
||||
include: { driver: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ export class TraccarClientService implements OnModuleInit {
|
||||
private client: AxiosInstance;
|
||||
private readonly baseUrl: string;
|
||||
private sessionCookie: string | null = null;
|
||||
private adminUser: string = 'admin';
|
||||
private adminPassword: string = 'admin';
|
||||
private adminUser: string = '';
|
||||
private adminPassword: string = '';
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>('TRACCAR_API_URL') || 'http://localhost:8082';
|
||||
@@ -86,6 +86,11 @@ export class TraccarClientService implements OnModuleInit {
|
||||
* Authenticate with Traccar and get session cookie
|
||||
*/
|
||||
async authenticate(): Promise<boolean> {
|
||||
if (!this.adminUser || !this.adminPassword) {
|
||||
this.logger.warn('Traccar credentials not configured - skipping authentication');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.post(
|
||||
'/api/session',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
|
||||
@@ -22,7 +23,24 @@ import { CanUpdate } from '../auth/decorators/check-ability.decorator';
|
||||
@Controller('settings')
|
||||
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
||||
export class SettingsController {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
constructor(
|
||||
private readonly settingsService: SettingsService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Feature flags - tells the frontend which optional services are configured.
|
||||
* No ability decorator = any authenticated user can access.
|
||||
*/
|
||||
@Get('features')
|
||||
getFeatureFlags() {
|
||||
return {
|
||||
copilot: !!this.configService.get('ANTHROPIC_API_KEY'),
|
||||
flightTracking: !!this.configService.get('AVIATIONSTACK_API_KEY'),
|
||||
signalMessaging: !!this.configService.get('SIGNAL_API_URL'),
|
||||
gpsTracking: !!this.configService.get('TRACCAR_API_URL'),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('pdf')
|
||||
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsEmail,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Department, ArrivalMode } from '@prisma/client';
|
||||
@@ -43,4 +44,27 @@ export class CreateVipDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
// Roster-only flag: true = just tracking for accountability, not active coordination
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRosterOnly?: boolean;
|
||||
|
||||
// VIP contact info
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
// Emergency contact info (for accountability reports)
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
emergencyContactName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
emergencyContactPhone?: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { AdminTools } from '@/pages/AdminTools';
|
||||
import { DriverProfile } from '@/pages/DriverProfile';
|
||||
import { MySchedule } from '@/pages/MySchedule';
|
||||
import { GpsTracking } from '@/pages/GpsTracking';
|
||||
import { Reports } from '@/pages/Reports';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
// Smart redirect based on user role
|
||||
@@ -122,6 +123,7 @@ function App() {
|
||||
<Route path="/users" element={<UserList />} />
|
||||
<Route path="/admin-tools" element={<AdminTools />} />
|
||||
<Route path="/gps-tracking" element={<GpsTracking />} />
|
||||
<Route path="/reports" element={<Reports />} />
|
||||
<Route path="/profile" element={<DriverProfile />} />
|
||||
<Route path="/my-schedule" element={<MySchedule />} />
|
||||
<Route path="/" element={<HomeRedirect />} />
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
LogOut,
|
||||
Phone,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
import { AppearanceMenu } from '@/components/AppearanceMenu';
|
||||
@@ -59,6 +60,22 @@ export function Layout({ children }: LayoutProps) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Fetch feature flags from backend (which optional services are configured)
|
||||
const { data: features } = useQuery<{
|
||||
copilot: boolean;
|
||||
flightTracking: boolean;
|
||||
signalMessaging: boolean;
|
||||
gpsTracking: boolean;
|
||||
}>({
|
||||
queryKey: ['features'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/settings/features');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
enabled: !!backendUser, // Only fetch when authenticated
|
||||
});
|
||||
|
||||
// Check if user is a driver (limited access)
|
||||
const isDriverRole = backendUser?.role === 'DRIVER';
|
||||
|
||||
@@ -78,6 +95,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
// Admin dropdown items (nested under Admin)
|
||||
const adminItems = [
|
||||
{ name: 'Users', href: '/users', icon: UserCog },
|
||||
{ name: 'Reports', href: '/reports', icon: FileText },
|
||||
{ name: 'GPS Tracking', href: '/gps-tracking', icon: Radio },
|
||||
{ name: 'Admin Tools', href: '/admin-tools', icon: Settings },
|
||||
];
|
||||
@@ -405,8 +423,8 @@ export function Layout({ children }: LayoutProps) {
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* AI Copilot - floating chat (only for Admins and Coordinators) */}
|
||||
{backendUser && (backendUser.role === 'ADMINISTRATOR' || backendUser.role === 'COORDINATOR') && (
|
||||
{/* AI Copilot - floating chat (only if backend has API key and user is Admin/Coordinator) */}
|
||||
{features?.copilot && backendUser && (backendUser.role === 'ADMINISTRATOR' || backendUser.role === 'COORDINATOR') && (
|
||||
<AICopilot />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
|
||||
|
||||
interface VIPFormProps {
|
||||
vip?: VIP | null;
|
||||
@@ -19,6 +19,11 @@ interface VIP {
|
||||
venueTransport: boolean;
|
||||
partySize: number;
|
||||
notes: string | null;
|
||||
isRosterOnly: boolean;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
}
|
||||
|
||||
export interface VIPFormData {
|
||||
@@ -31,6 +36,11 @@ export interface VIPFormData {
|
||||
venueTransport?: boolean;
|
||||
partySize?: number;
|
||||
notes?: string;
|
||||
isRosterOnly?: boolean;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
emergencyContactName?: string;
|
||||
emergencyContactPhone?: string;
|
||||
}
|
||||
|
||||
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
|
||||
@@ -56,8 +66,17 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
||||
venueTransport: vip?.venueTransport ?? false,
|
||||
partySize: vip?.partySize ?? 1,
|
||||
notes: vip?.notes || '',
|
||||
isRosterOnly: vip?.isRosterOnly ?? false,
|
||||
phone: vip?.phone || '',
|
||||
email: vip?.email || '',
|
||||
emergencyContactName: vip?.emergencyContactName || '',
|
||||
emergencyContactPhone: vip?.emergencyContactPhone || '',
|
||||
});
|
||||
|
||||
const [showRosterFields, setShowRosterFields] = useState(
|
||||
vip?.isRosterOnly || vip?.phone || vip?.email || vip?.emergencyContactName ? true : false
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -69,6 +88,10 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
||||
? new Date(formData.expectedArrival).toISOString()
|
||||
: undefined,
|
||||
notes: formData.notes || undefined,
|
||||
phone: formData.phone || undefined,
|
||||
email: formData.email || undefined,
|
||||
emergencyContactName: formData.emergencyContactName || undefined,
|
||||
emergencyContactPhone: formData.emergencyContactPhone || undefined,
|
||||
};
|
||||
|
||||
onSubmit(cleanedData);
|
||||
@@ -238,6 +261,118 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Roster & Contact Info Section */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRosterFields(!showRosterFields)}
|
||||
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">Contact & Emergency Info</span>
|
||||
{formData.isRosterOnly && (
|
||||
<span className="px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-300 rounded">
|
||||
Roster Only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showRosterFields ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showRosterFields && (
|
||||
<div className="p-4 space-y-4 border-t border-border">
|
||||
{/* Roster Only Toggle */}
|
||||
<label className="flex items-center cursor-pointer" style={{ minHeight: '28px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isRosterOnly"
|
||||
checked={formData.isRosterOnly}
|
||||
onChange={handleChange}
|
||||
className="h-5 w-5 text-primary border-input rounded focus:ring-primary"
|
||||
/>
|
||||
<span className="ml-3 text-base text-foreground">
|
||||
Roster only (accountability tracking, no active coordination)
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground -mt-2 ml-8">
|
||||
Check this for VIPs who are attending but don't need transportation services
|
||||
</p>
|
||||
|
||||
{/* VIP Contact Info */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="555-123-4567"
|
||||
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="vip@example.com"
|
||||
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency Contact */}
|
||||
<div className="pt-2">
|
||||
<h4 className="text-sm font-medium text-foreground mb-3">Emergency Contact</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="emergencyContactName"
|
||||
value={formData.emergencyContactName}
|
||||
onChange={handleChange}
|
||||
placeholder="Jane Doe"
|
||||
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="emergencyContactPhone"
|
||||
value={formData.emergencyContactPhone}
|
||||
onChange={handleChange}
|
||||
placeholder="555-987-6543"
|
||||
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
|
||||
456
frontend/src/pages/Reports.tsx
Normal file
456
frontend/src/pages/Reports.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Loading } from '@/components/Loading';
|
||||
import {
|
||||
FileText,
|
||||
Users,
|
||||
Phone,
|
||||
Mail,
|
||||
Building2,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Download,
|
||||
UserCheck,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface VIP {
|
||||
id: string;
|
||||
name: string;
|
||||
organization: string | null;
|
||||
department: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
isRosterOnly: boolean;
|
||||
partySize: number;
|
||||
arrivalMode: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
type ReportType = 'accountability';
|
||||
|
||||
export function Reports() {
|
||||
const [activeReport, setActiveReport] = useState<ReportType>('accountability');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [departmentFilter, setDepartmentFilter] = useState<string>('all');
|
||||
|
||||
const { data: vips, isLoading } = useQuery<VIP[]>({
|
||||
queryKey: ['vips'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/vips');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const reports = [
|
||||
{
|
||||
id: 'accountability' as const,
|
||||
name: 'Accountability Roster',
|
||||
icon: ClipboardList,
|
||||
description: 'Complete list of all personnel for emergency preparedness',
|
||||
},
|
||||
// Future reports can be added here:
|
||||
// { id: 'schedule-summary', name: 'Schedule Summary', icon: Calendar, description: '...' },
|
||||
// { id: 'driver-assignments', name: 'Driver Assignments', icon: Car, description: '...' },
|
||||
];
|
||||
|
||||
// Filter VIPs based on search and department
|
||||
const filteredVips = vips?.filter((vip) => {
|
||||
const matchesSearch =
|
||||
searchTerm === '' ||
|
||||
vip.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
vip.organization?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
vip.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesDepartment =
|
||||
departmentFilter === 'all' || vip.department === departmentFilter;
|
||||
|
||||
return matchesSearch && matchesDepartment;
|
||||
});
|
||||
|
||||
// Separate active VIPs and roster-only
|
||||
const activeVips = filteredVips?.filter((v) => !v.isRosterOnly) || [];
|
||||
const rosterOnlyVips = filteredVips?.filter((v) => v.isRosterOnly) || [];
|
||||
|
||||
// Count totals (including party size)
|
||||
const totalPeople =
|
||||
(vips?.reduce((sum, v) => sum + (v.partySize || 1), 0) || 0);
|
||||
const activeCount = activeVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
const rosterOnlyCount = rosterOnlyVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
|
||||
// Export to CSV
|
||||
const handleExportCSV = () => {
|
||||
if (!filteredVips) return;
|
||||
|
||||
const headers = [
|
||||
'Name',
|
||||
'Organization',
|
||||
'Department',
|
||||
'Phone',
|
||||
'Email',
|
||||
'Emergency Contact',
|
||||
'Emergency Phone',
|
||||
'Party Size',
|
||||
'Status',
|
||||
'Notes',
|
||||
];
|
||||
|
||||
const rows = filteredVips.map((vip) => [
|
||||
vip.name,
|
||||
vip.organization || '',
|
||||
vip.department,
|
||||
vip.phone || '',
|
||||
vip.email || '',
|
||||
vip.emergencyContactName || '',
|
||||
vip.emergencyContactPhone || '',
|
||||
vip.partySize.toString(),
|
||||
vip.isRosterOnly ? 'Roster Only' : 'Active VIP',
|
||||
vip.notes?.replace(/\n/g, ' ') || '',
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map((row) =>
|
||||
row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(',')
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `accountability-roster-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message="Loading report data..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Reports</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Generate and view operational reports
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report Type Selector */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{reports.map((report) => {
|
||||
const Icon = report.icon;
|
||||
const isActive = activeReport === report.id;
|
||||
return (
|
||||
<button
|
||||
key={report.id}
|
||||
onClick={() => setActiveReport(report.id)}
|
||||
className={`p-4 rounded-lg border text-left transition-all ${
|
||||
isActive
|
||||
? 'border-primary bg-primary/5 ring-2 ring-primary/20'
|
||||
: 'border-border bg-card hover:border-primary/50 hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
isActive ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{report.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{report.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Accountability Report */}
|
||||
{activeReport === 'accountability' && (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-950 rounded-lg">
|
||||
<Users className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{totalPeople}</div>
|
||||
<div className="text-sm text-muted-foreground">Total People</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-950 rounded-lg">
|
||||
<UserCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{activeCount}</div>
|
||||
<div className="text-sm text-muted-foreground">Active VIPs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-950 rounded-lg">
|
||||
<ClipboardList className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{rosterOnlyCount}</div>
|
||||
<div className="text-sm text-muted-foreground">Roster Only</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Export */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-3 flex-1">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, organization, or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-input rounded-lg bg-background focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={departmentFilter}
|
||||
onChange={(e) => setDepartmentFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-input rounded-lg bg-background focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Departments</option>
|
||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active VIPs Table */}
|
||||
{activeVips.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<UserCheck className="h-5 w-5 text-green-600" />
|
||||
Active VIPs ({activeVips.length} entries, {activeCount} people)
|
||||
</h2>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Organization
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Contact
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Emergency Contact
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||
Party
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{activeVips.map((vip) => (
|
||||
<tr key={vip.id} className="hover:bg-accent/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{vip.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{vip.department === 'OFFICE_OF_DEVELOPMENT'
|
||||
? 'Office of Development'
|
||||
: vip.department}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{vip.organization && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{vip.organization}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{vip.phone && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Phone className="h-3 w-3" />
|
||||
{vip.phone}
|
||||
</div>
|
||||
)}
|
||||
{vip.email && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{vip.email}
|
||||
</div>
|
||||
)}
|
||||
{!vip.phone && !vip.email && (
|
||||
<span className="text-muted-foreground/50 italic">No contact info</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{vip.emergencyContactName ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 text-foreground">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||
{vip.emergencyContactName}
|
||||
</div>
|
||||
{vip.emergencyContactPhone && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{vip.emergencyContactPhone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 italic">Not provided</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-muted text-sm font-medium">
|
||||
{vip.partySize}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Roster-Only Table */}
|
||||
{rosterOnlyVips.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-amber-600" />
|
||||
Roster Only ({rosterOnlyVips.length} entries, {rosterOnlyCount} people)
|
||||
</h2>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Organization
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Contact
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Emergency Contact
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||
Party
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rosterOnlyVips.map((vip) => (
|
||||
<tr key={vip.id} className="hover:bg-accent/50 transition-colors bg-amber-50/30 dark:bg-amber-950/10">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{vip.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{vip.department === 'OFFICE_OF_DEVELOPMENT'
|
||||
? 'Office of Development'
|
||||
: vip.department}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{vip.organization && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{vip.organization}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{vip.phone && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Phone className="h-3 w-3" />
|
||||
{vip.phone}
|
||||
</div>
|
||||
)}
|
||||
{vip.email && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{vip.email}
|
||||
</div>
|
||||
)}
|
||||
{!vip.phone && !vip.email && (
|
||||
<span className="text-muted-foreground/50 italic">No contact info</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{vip.emergencyContactName ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 text-foreground">
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||
{vip.emergencyContactName}
|
||||
</div>
|
||||
{vip.emergencyContactPhone && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{vip.emergencyContactPhone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 italic">Not provided</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-muted text-sm font-medium">
|
||||
{vip.partySize}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredVips?.length === 0 && (
|
||||
<div className="text-center py-12 bg-card border border-border rounded-lg">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">No entries found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{searchTerm || departmentFilter !== 'all'
|
||||
? 'Try adjusting your search or filter criteria.'
|
||||
: 'Add VIPs to the system to see them in this report.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { VIP } from '@/types';
|
||||
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ArrowUpDown } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ArrowUpDown, ClipboardList } from 'lucide-react';
|
||||
import { VIPForm, VIPFormData } from '@/components/VIPForm';
|
||||
import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton';
|
||||
import { FilterModal } from '@/components/FilterModal';
|
||||
@@ -28,6 +28,9 @@ export function VIPList() {
|
||||
const [sortColumn, setSortColumn] = useState<'name' | 'organization' | 'department' | 'arrivalMode'>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// Roster-only toggle (hidden by default)
|
||||
const [showRosterOnly, setShowRosterOnly] = useState(false);
|
||||
|
||||
// Debounce search term for better performance
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
@@ -100,6 +103,11 @@ export function VIPList() {
|
||||
|
||||
// First filter
|
||||
let filtered = vips.filter((vip) => {
|
||||
// Hide roster-only VIPs unless toggle is on
|
||||
if (!showRosterOnly && vip.isRosterOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search by name (using debounced term)
|
||||
const matchesSearch = debouncedSearchTerm === '' ||
|
||||
vip.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase());
|
||||
@@ -135,7 +143,10 @@ export function VIPList() {
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [vips, debouncedSearchTerm, selectedDepartments, selectedArrivalModes, sortColumn, sortDirection]);
|
||||
}, [vips, debouncedSearchTerm, selectedDepartments, selectedArrivalModes, sortColumn, sortDirection, showRosterOnly]);
|
||||
|
||||
// Count roster-only VIPs
|
||||
const rosterOnlyCount = vips?.filter((v) => v.isRosterOnly).length || 0;
|
||||
|
||||
const handleDepartmentToggle = (department: string) => {
|
||||
setSelectedDepartments((prev) =>
|
||||
@@ -312,12 +323,28 @@ export function VIPList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results count */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||
{/* Results count and Roster-Only Toggle */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-3 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{filteredVIPs.length}</span> of <span className="font-medium text-foreground">{vips?.length || 0}</span> VIPs
|
||||
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-muted-foreground/70">(searching...)</span>}
|
||||
</div>
|
||||
{rosterOnlyCount > 0 && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRosterOnly}
|
||||
onChange={(e) => setShowRosterOnly(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<ClipboardList className="h-3.5 w-3.5" />
|
||||
Show roster-only ({rosterOnlyCount})
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{(searchTerm || selectedDepartments.length > 0 || selectedArrivalModes.length > 0) && (
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
@@ -382,9 +409,17 @@ export function VIPList() {
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{filteredVIPs.map((vip) => (
|
||||
<tr key={vip.id} className="hover:bg-accent transition-colors">
|
||||
<tr key={vip.id} className={`hover:bg-accent transition-colors ${vip.isRosterOnly ? 'bg-amber-50/50 dark:bg-amber-950/20' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
{vip.name}
|
||||
{vip.isRosterOnly && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-300 rounded" title="Roster only - no active coordination">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
Roster
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{vip.organization || '-'}
|
||||
@@ -433,10 +468,18 @@ export function VIPList() {
|
||||
{/* Mobile/Tablet Card View - shows on small and medium screens */}
|
||||
<div className="lg:hidden space-y-4">
|
||||
{filteredVIPs.map((vip) => (
|
||||
<div key={vip.id} className="bg-card shadow-soft border border-border rounded-lg p-4">
|
||||
<div key={vip.id} className={`bg-card shadow-soft border border-border rounded-lg p-4 ${vip.isRosterOnly ? 'border-amber-300 dark:border-amber-700 bg-amber-50/30 dark:bg-amber-950/20' : ''}`}>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">{vip.name}</h3>
|
||||
{vip.isRosterOnly && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-300 rounded">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
Roster
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{vip.organization && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{vip.organization}</p>
|
||||
)}
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface VIP {
|
||||
venueTransport: boolean;
|
||||
partySize: number;
|
||||
notes: string | null;
|
||||
// Roster and contact info
|
||||
isRosterOnly: boolean;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
// Relations
|
||||
flights?: Flight[];
|
||||
events?: ScheduleEvent[];
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user