diff --git a/.env.example b/.env.example index 00f4697c..292145e0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,27 @@ -# To access your Clerk keys, first create a clerk.com account then open dashboard.clerk.com. Create a new Clerk application and copy the Keys from step 2 in the Next.js quickstart tab. -CLERK_SECRET_KEY= -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +# Database -NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/dashboard -NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/dashboard +# Connect to Supabase via connection pooling. +DATABASE_URL="postgresql://username:password@host:6543/database?pgbouncer=true" + +# Direct connection to the database. Used for migrations. +DIRECT_URL="postgresql://username:password@host:5432/database" + +# Clerk Authentication +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key +CLERK_SECRET_KEY=sk_test_your_secret_key +CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret_here +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +# Disable sign-up by redirecting to sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-in +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard +# Not used but kept for reference +NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard +# Disable sign-up completely +CLERK_SIGN_UP_DISABLED=true + +# Clerk Organization settings +NEXT_PUBLIC_CLERK_AFTER_CREATE_ORGANIZATION_URL=/dashboard +NEXT_PUBLIC_CLERK_CREATE_ORGANIZATION_URL=/create-organization + +# App URL +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 00000000..1e0ff349 --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,652 @@ +# Chauffeur Management System - Project Plan + +## Overview +This document outlines the detailed plan for building a comprehensive chauffeur management system with features for booking, ride assignment, organization management, and role-based access control. + +## Tech Stack +- **Framework**: Next.js (App Router) +- **Authentication**: Clerk Auth +- **Database**: Prisma with Supabase (PostgreSQL) +- **Form Handling**: react-hook-form with zod validation +- **Data Fetching**: tRPC + react-query +- **UI Components**: shadcn/ui +- **Animations**: Framer Motion +- **Date Utilities**: date-fns +- **AI Integration**: AI Toolkit +- **URL Search Params**: nuqs +- **Charts**: Recharts +- **State Management**: Zustand + +## User Roles +1. **Admin**: Full system access +2. **Sales**: Manage clients and contracts +3. **Customer**: Organization admin who can book rides +4. **Passenger**: End-user who takes rides +5. **Planning**: Schedule and plan rides +6. **Dispatcher**: Assign chauffeurs to rides +7. **Field Manager**: Oversee chauffeur operations +8. **Field Assistant**: Support field operations +9. **Chauffeur**: Execute rides + +## Database Schema + +### Users +```prisma +model User { + id String @id @default(cuid()) + clerkId String @unique + email String @unique + firstName String + lastName String + phone String? + role Role @default(PASSENGER) + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + chauffeurProfile Chauffeur? + rides Ride[] @relation("PassengerRides") + assignedRides Ride[] @relation("AssignedRides") + bookings Booking[] +} + +enum Role { + ADMIN + SALES + CUSTOMER + PASSENGER + PLANNING + DISPATCHER + FIELD_MANAGER + FIELD_ASSISTANT + CHAUFFEUR +} +``` + +### Organizations +```prisma +model Organization { + id String @id @default(cuid()) + name String + address String? + city String? + country String? + postalCode String? + phone String? + email String? + website String? + logoUrl String? + active Boolean @default(true) + contractStart DateTime? + contractEnd DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] + bookings Booking[] + billingInfo BillingInfo? +} + +model BillingInfo { + id String @id @default(cuid()) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id]) + billingAddress String? + billingCity String? + billingCountry String? + billingPostalCode String? + taxId String? + paymentTerms String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Chauffeurs +```prisma +model Chauffeur { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id]) + licenseNumber String + licenseExpiry DateTime + vehicleId String? + vehicle Vehicle? @relation(fields: [vehicleId], references: [id]) + status ChauffeurStatus @default(AVAILABLE) + rating Float? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum ChauffeurStatus { + AVAILABLE + BUSY + ON_BREAK + OFF_DUTY + ON_LEAVE +} +``` + +### Vehicles +```prisma +model Vehicle { + id String @id @default(cuid()) + make String + model String + year Int + licensePlate String @unique + color String? + capacity Int @default(4) + vehicleType VehicleType @default(SEDAN) + status VehicleStatus @default(AVAILABLE) + lastMaintenance DateTime? + chauffeurs Chauffeur[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum VehicleType { + SEDAN + SUV + VAN + LUXURY + LIMOUSINE +} + +enum VehicleStatus { + AVAILABLE + IN_USE + MAINTENANCE + OUT_OF_SERVICE +} +``` + +### Bookings and Rides +```prisma +model Booking { + id String @id @default(cuid()) + bookingNumber String @unique @default(cuid()) + customerId String + customer User @relation(fields: [customerId], references: [id]) + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id]) + status BookingStatus @default(PENDING) + totalAmount Decimal? @db.Decimal(10, 2) + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + rides Ride[] +} + +enum BookingStatus { + PENDING + CONFIRMED + IN_PROGRESS + COMPLETED + CANCELLED +} + +model Ride { + id String @id @default(cuid()) + rideNumber String @unique @default(cuid()) + bookingId String + booking Booking @relation(fields: [bookingId], references: [id]) + passengerId String + passenger User @relation("PassengerRides", fields: [passengerId], references: [id]) + chauffeurId String? + chauffeur User? @relation("AssignedRides", fields: [chauffeurId], references: [id]) + pickupAddress String + dropoffAddress String + pickupTime DateTime + dropoffTime DateTime? + status RideStatus @default(SCHEDULED) + fare Decimal? @db.Decimal(10, 2) + distance Decimal? @db.Decimal(10, 2) + duration Int? // in minutes + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum RideStatus { + SCHEDULED + ASSIGNED + IN_PROGRESS + COMPLETED + CANCELLED +} +``` + +## Project Structure + +``` +dropnow-admin-dashboard/ +├── app/ +│ ├── (auth)/ +│ │ ├── sign-in/[[...sign-in]]/page.tsx +│ │ ├── sign-up/[[...sign-up]]/page.tsx +│ │ └── layout.tsx +│ ├── (dashboard)/ +│ │ ├── dashboard/ +│ │ │ ├── page.tsx +│ │ │ └── loading.tsx +│ │ ├── bookings/ +│ │ │ ├── page.tsx +│ │ │ ├── [id]/page.tsx +│ │ │ ├── new/page.tsx +│ │ │ └── components/ +│ │ ├── rides/ +│ │ │ ├── page.tsx +│ │ │ ├── [id]/page.tsx +│ │ │ └── components/ +│ │ ├── organizations/ +│ │ │ ├── page.tsx +│ │ │ ├── [id]/page.tsx +│ │ │ ├── new/page.tsx +│ │ │ └── components/ +│ │ ├── users/ +│ │ │ ├── page.tsx +│ │ │ ├── [id]/page.tsx +│ │ │ ├── new/page.tsx +│ │ │ └── components/ +│ │ ├── chauffeurs/ +│ │ │ ├── page.tsx +│ │ │ ├── [id]/page.tsx +│ │ │ └── components/ +│ │ ├── vehicles/ +│ │ │ ├── page.tsx +│ │ │ ├── [id]/page.tsx +│ │ │ ├── new/page.tsx +│ │ │ └── components/ +│ │ ├── settings/ +│ │ │ └── page.tsx +│ │ └── layout.tsx +│ ├── api/ +│ │ ├── trpc/[trpc]/route.ts +│ │ ├── clerk/webhook/route.ts +│ │ └── uploadthing/route.ts +│ ├── globals.css +│ ├── layout.tsx +│ └── page.tsx +├── components/ +│ ├── ui/ +│ │ ├── button.tsx +│ │ ├── input.tsx +│ │ ├── select.tsx +│ │ ├── table.tsx +│ │ └── ... (other shadcn components) +│ ├── forms/ +│ │ ├── booking-form.tsx +│ │ ├── organization-form.tsx +│ │ ├── user-form.tsx +│ │ ├── chauffeur-form.tsx +│ │ └── vehicle-form.tsx +│ ├── tables/ +│ │ ├── bookings-table.tsx +│ │ ├── rides-table.tsx +│ │ ├── users-table.tsx +│ │ └── ... (other tables) +│ ├── dashboard/ +│ │ ├── stats-cards.tsx +│ │ ├── recent-bookings.tsx +│ │ ├── upcoming-rides.tsx +│ │ └── charts/ +│ ├── layout/ +│ │ ├── sidebar.tsx +│ │ ├── header.tsx +│ │ ├── user-nav.tsx +│ │ └── mobile-nav.tsx +│ └── shared/ +│ ├── loading-spinner.tsx +│ ├── error-message.tsx +│ └── empty-state.tsx +├── lib/ +│ ├── utils.ts +│ ├── auth.ts +│ ├── db.ts +│ ├── trpc/ +│ │ ├── client.ts +│ │ ├── server.ts +│ │ └── routers/ +│ │ ├── booking.ts +│ │ ├── ride.ts +│ │ ├── user.ts +│ │ ├── organization.ts +│ │ ├── chauffeur.ts +│ │ └── vehicle.ts +│ └── validations/ +│ ├── booking.ts +│ ├── organization.ts +│ ├── user.ts +│ └── ... (other schemas) +├── hooks/ +│ ├── use-bookings.ts +│ ├── use-rides.ts +│ ├── use-organizations.ts +│ ├── use-users.ts +│ └── ... (other hooks) +├── store/ +│ ├── booking-store.ts +│ ├── ride-store.ts +│ ├── user-store.ts +│ └── ... (other stores) +├── types/ +│ ├── index.ts +│ ├── booking.ts +│ ├── ride.ts +│ └── ... (other type definitions) +├── prisma/ +│ ├── schema.prisma +│ ├── migrations/ +│ └── seed.ts +├── public/ +│ ├── images/ +│ ├── icons/ +│ └── ... (other static assets) +├── middleware.ts +├── next.config.ts +├── package.json +├── tsconfig.json +└── ... (other config files) +``` + +## Implementation Plan + +### Phase 1: Project Setup and Authentication +1. **Setup Next.js Project** + - Install required dependencies + - Configure TypeScript + - Set up ESLint and Prettier + +2. **Setup Clerk Authentication** + - Configure Clerk + - Create sign-in and sign-up pages + - Implement middleware for protected routes + - Set up webhook for user synchronization + +3. **Setup Database** + - Configure Prisma with Supabase + - Create initial schema + - Set up database migrations + +4. **Setup tRPC** + - Configure tRPC server + - Set up API routes + - Create base routers + +### Phase 2: Core Features - Users and Organizations +1. **User Management** + - Implement user CRUD operations + - Create user profile pages + - Implement role-based access control + +2. **Organization Management** + - Implement organization CRUD operations + - Create organization profile pages + - Implement organization member management + +### Phase 3: Chauffeur and Vehicle Management +1. **Chauffeur Management** + - Implement chauffeur profile creation + - Create chauffeur availability system + - Implement chauffeur rating system + +2. **Vehicle Management** + - Implement vehicle CRUD operations + - Create vehicle status tracking + - Implement vehicle maintenance scheduling + +### Phase 4: Booking and Ride Management +1. **Booking System** + - Implement booking creation flow + - Create booking management interface + - Implement booking status tracking + +2. **Ride Assignment** + - Create ride scheduling system + - Implement chauffeur assignment algorithm + - Create ride tracking interface + +3. **Ride Execution** + - Implement ride status updates + - Create ride completion flow + - Implement fare calculation + +### Phase 5: Dashboard and Reporting +1. **Dashboard** + - Create role-specific dashboards + - Implement key metrics and statistics + - Create data visualization with Recharts + +2. **Reporting** + - Implement report generation + - Create export functionality + - Implement analytics features + +### Phase 6: Advanced Features +1. **Notifications** + - Implement email notifications + - Create in-app notification system + - Set up SMS notifications for critical updates + +2. **Mobile Optimization** + - Ensure responsive design + - Optimize for mobile usage + - Implement progressive web app features + +3. **AI Integration** + - Implement intelligent ride matching + - Create predictive analytics + - Implement chatbot for customer support + +## Role-Based Access Control + +### Admin +- Full access to all system features +- User management +- Organization management +- System configuration + +### Sales +- Organization management +- Contract management +- Customer onboarding +- Reporting and analytics + +### Customer +- Booking management +- Organization member management +- Billing and invoices +- Reporting for their organization + +### Passenger +- View and track assigned rides +- Update personal profile +- Rate chauffeurs +- Request new rides + +### Planning +- Ride scheduling +- Resource allocation +- Optimization of routes +- Forecasting and planning + +### Dispatcher +- Chauffeur assignment +- Real-time ride management +- Handling exceptions and changes +- Communication with chauffeurs + +### Field Manager +- Chauffeur management +- Performance monitoring +- Quality assurance +- Training coordination + +### Field Assistant +- Support field operations +- Equipment management +- Documentation +- Chauffeur support + +### Chauffeur +- View assigned rides +- Update ride status +- Navigation assistance +- Communication with passengers + +## API Endpoints (tRPC Routers) + +### User Router +- createUser +- updateUser +- deleteUser +- getUserById +- getUsers +- updateUserRole + +### Organization Router +- createOrganization +- updateOrganization +- deleteOrganization +- getOrganizationById +- getOrganizations +- addUserToOrganization +- removeUserFromOrganization + +### Chauffeur Router +- createChauffeurProfile +- updateChauffeurProfile +- deleteChauffeurProfile +- getChauffeurById +- getChauffeurs +- updateChauffeurStatus +- assignVehicleToChauffeur + +### Vehicle Router +- createVehicle +- updateVehicle +- deleteVehicle +- getVehicleById +- getVehicles +- updateVehicleStatus +- getAvailableVehicles + +### Booking Router +- createBooking +- updateBooking +- deleteBooking +- getBookingById +- getBookings +- getBookingsByOrganization +- getBookingsByUser + +### Ride Router +- createRide +- updateRide +- deleteRide +- getRideById +- getRides +- getRidesByBooking +- getRidesByPassenger +- getRidesByChauffeur +- assignChauffeurToRide +- updateRideStatus +- completeRide + +## UI Components (shadcn/ui) + +1. **Layout Components** + - Dashboard layout + - Sidebar navigation + - Header with user menu + - Mobile navigation + +2. **Form Components** + - Input fields + - Select dropdowns + - Date pickers + - Form validation + +3. **Table Components** + - Data tables with sorting and filtering + - Pagination + - Action menus + - Bulk actions + +4. **Card Components** + - Stat cards + - Info cards + - Action cards + - Profile cards + +5. **Modal Components** + - Confirmation dialogs + - Form modals + - Information modals + - Alert dialogs + +6. **Chart Components** + - Line charts + - Bar charts + - Pie charts + - Area charts + +## State Management (Zustand) + +1. **User Store** + - Current user information + - Authentication state + - User preferences + +2. **Booking Store** + - Active bookings + - Booking form state + - Booking filters + +3. **Ride Store** + - Active rides + - Ride assignment state + - Ride filters + +4. **UI Store** + - Sidebar state + - Theme preferences + - Notification state + +## Next Steps and Timeline + +### Week 1-2: Project Setup +- Set up Next.js project +- Configure authentication +- Set up database and schema +- Implement basic UI components + +### Week 3-4: Core Features +- Implement user management +- Create organization management +- Set up role-based access control + +### Week 5-6: Chauffeur and Vehicle Management +- Implement chauffeur profiles +- Create vehicle management +- Set up availability tracking + +### Week 7-8: Booking and Ride Management +- Implement booking system +- Create ride assignment +- Set up ride tracking + +### Week 9-10: Dashboard and Reporting +- Create dashboards +- Implement reporting +- Set up analytics + +### Week 11-12: Advanced Features and Testing +- Implement notifications +- Optimize for mobile +- Comprehensive testing +- Deployment preparation + +## Conclusion +This plan outlines the comprehensive approach to building a chauffeur management system with all the required features and functionality. The modular architecture and phased implementation will ensure a scalable, maintainable, and user-friendly application. diff --git a/app/api/actions/event-actions.ts b/app/api/actions/event-actions.ts new file mode 100644 index 00000000..72f03b2d --- /dev/null +++ b/app/api/actions/event-actions.ts @@ -0,0 +1,100 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +// Fetch all events +export async function getEventsAction(filter?: { status?: string }) { + try { + console.log('Server action: Fetching events with filter:', filter); + + const events = await prisma.event.findMany({ + where: filter, + include: { + client: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + startDate: 'desc', + }, + }); + + console.log('Server action: Found events:', events.length); + + // Transform the events to a simpler format for the dropdown + const formattedEvents = events.map(event => ({ + id: event.id, + name: event.title, + clientName: event.client?.name || 'No client', + startDate: event.startDate, + endDate: event.endDate, + status: event.status, + location: event.location || 'No location', + })); + + return { success: true, data: formattedEvents }; + } catch (error) { + console.error("Error fetching events:", error); + return { success: false, error: "Failed to fetch events" }; + } +} + +// Create event vehicle assignment +export async function createEventVehicleAssignmentAction(data: { + eventId: string; + vehicleId: string; + startDate?: Date; + endDate?: Date; + notes?: string; +}) { + try { + // Check if the assignment already exists + const existingAssignment = await prisma.eventVehicle.findFirst({ + where: { + eventId: data.eventId, + vehicleId: data.vehicleId, + }, + }); + + if (existingAssignment) { + return { + success: false, + error: "This vehicle is already assigned to this event" + }; + } + + // Create the assignment + const assignment = await prisma.eventVehicle.create({ + data: { + eventId: data.eventId, + vehicleId: data.vehicleId, + assignedAt: new Date(), + status: "ASSIGNED", + notes: data.notes, + }, + }); + + // Update the vehicle status to IN_USE + await prisma.vehicle.update({ + where: { + id: data.vehicleId, + }, + data: { + status: "IN_USE", + }, + }); + + revalidatePath("/cars"); + revalidatePath(`/cars/${data.vehicleId}`); + revalidatePath(`/events/${data.eventId}`); + + return { success: true, data: assignment }; + } catch (error) { + console.error("Error creating event vehicle assignment:", error); + return { success: false, error: "Failed to assign vehicle to event" }; + } +} diff --git a/app/api/chauffeurs/[id]/route.ts b/app/api/chauffeurs/[id]/route.ts new file mode 100644 index 00000000..6fabf7f0 --- /dev/null +++ b/app/api/chauffeurs/[id]/route.ts @@ -0,0 +1,197 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { auth } from "@clerk/nextjs"; + +// GET /api/chauffeurs/[id] - Get a specific chauffeur +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + // Authentication is handled by the middleware + const { userId } = auth(); + + // Just a double-check, but middleware should already handle this + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const chauffeur = await prisma.chauffeur.findUnique({ + where: { id: params.id }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + vehicle: { + select: { + id: true, + make: true, + model: true, + licensePlate: true, + }, + }, + }, + }); + + if (!chauffeur) { + return NextResponse.json( + { error: "Chauffeur not found" }, + { status: 404 } + ); + } + + // Format the chauffeur data + const formattedChauffeur = { + id: chauffeur.id, + userId: chauffeur.userId, + firstName: chauffeur.user.firstName, + lastName: chauffeur.user.lastName, + fullName: `${chauffeur.user.firstName} ${chauffeur.user.lastName}`, + email: chauffeur.user.email, + phone: chauffeur.user.phone, + licenseNumber: chauffeur.licenseNumber, + licenseExpiry: chauffeur.licenseExpiry, + status: chauffeur.status, + rating: chauffeur.rating, + notes: chauffeur.notes, + vehicle: chauffeur.vehicle ? { + id: chauffeur.vehicle.id, + name: `${chauffeur.vehicle.make} ${chauffeur.vehicle.model}`, + licensePlate: chauffeur.vehicle.licensePlate, + } : null, + createdAt: chauffeur.createdAt, + updatedAt: chauffeur.updatedAt, + }; + + return NextResponse.json(formattedChauffeur); + } catch (error) { + console.error("Error fetching chauffeur:", error); + return NextResponse.json( + { error: "Failed to fetch chauffeur" }, + { status: 500 } + ); + } +} + +// PUT /api/chauffeurs/[id] - Update a chauffeur +export async function PUT( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + // Authentication is handled by the middleware + const { userId } = auth(); + + // Just a double-check, but middleware should already handle this + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + + // Check if the chauffeur exists + const existingChauffeur = await prisma.chauffeur.findUnique({ + where: { id: params.id }, + }); + + if (!existingChauffeur) { + return NextResponse.json( + { error: "Chauffeur not found" }, + { status: 404 } + ); + } + + // Update the chauffeur + const updatedChauffeur = await prisma.chauffeur.update({ + where: { id: params.id }, + data: { + licenseNumber: body.licenseNumber, + licenseExpiry: new Date(body.licenseExpiry), + vehicleId: body.vehicleId || null, + status: body.status || existingChauffeur.status, + rating: body.rating || existingChauffeur.rating, + notes: body.notes, + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + vehicle: { + select: { + id: true, + make: true, + model: true, + licensePlate: true, + }, + }, + }, + }); + + return NextResponse.json(updatedChauffeur); + } catch (error) { + console.error("Error updating chauffeur:", error); + return NextResponse.json( + { error: "Failed to update chauffeur" }, + { status: 500 } + ); + } +} + +// DELETE /api/chauffeurs/[id] - Delete a chauffeur +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + // Authentication is handled by the middleware + const { userId } = auth(); + + // Just a double-check, but middleware should already handle this + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check if the chauffeur exists + const chauffeur = await prisma.chauffeur.findUnique({ + where: { id: params.id }, + include: { + user: true, + }, + }); + + if (!chauffeur) { + return NextResponse.json( + { error: "Chauffeur not found" }, + { status: 404 } + ); + } + + // Delete the chauffeur + await prisma.chauffeur.delete({ + where: { id: params.id }, + }); + + // Update the user's role if needed (optional) + // This depends on your business logic - you might want to keep the CHAUFFEUR role + // or change it back to something else + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting chauffeur:", error); + return NextResponse.json( + { error: "Failed to delete chauffeur" }, + { status: 500 } + ); + } +} diff --git a/app/api/chauffeurs/route.ts b/app/api/chauffeurs/route.ts new file mode 100644 index 00000000..54657060 --- /dev/null +++ b/app/api/chauffeurs/route.ts @@ -0,0 +1,170 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { auth } from "@clerk/nextjs"; + +// GET /api/chauffeurs - Get all chauffeurs +export async function GET(req: NextRequest) { + try { + // Authentication is handled by the middleware + const { userId } = auth(); + + // Just a double-check, but middleware should already handle this + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get query parameters + const url = new URL(req.url); + const status = url.searchParams.get("status"); + + // Build the where clause based on filters + const where: any = {}; + if (status) { + where.status = status; + } + + // Fetch chauffeurs with their user information + const chauffeurs = await prisma.chauffeur.findMany({ + where, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + vehicle: { + select: { + id: true, + make: true, + model: true, + licensePlate: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Transform the data to a more convenient format for the frontend + const formattedChauffeurs = chauffeurs.map((chauffeur) => ({ + id: chauffeur.id, + userId: chauffeur.userId, + firstName: chauffeur.user.firstName, + lastName: chauffeur.user.lastName, + fullName: `${chauffeur.user.firstName} ${chauffeur.user.lastName}`, + email: chauffeur.user.email, + phone: chauffeur.user.phone, + licenseNumber: chauffeur.licenseNumber, + licenseExpiry: chauffeur.licenseExpiry, + status: chauffeur.status, + rating: chauffeur.rating, + notes: chauffeur.notes, + vehicle: chauffeur.vehicle ? { + id: chauffeur.vehicle.id, + name: `${chauffeur.vehicle.make} ${chauffeur.vehicle.model}`, + licensePlate: chauffeur.vehicle.licensePlate, + } : null, + createdAt: chauffeur.createdAt, + updatedAt: chauffeur.updatedAt, + })); + + return NextResponse.json(formattedChauffeurs); + } catch (error) { + console.error("Error fetching chauffeurs:", error); + return NextResponse.json( + { error: "Failed to fetch chauffeurs" }, + { status: 500 } + ); + } +} + +// POST /api/chauffeurs - Create a new chauffeur +export async function POST(req: NextRequest) { + try { + // Authentication is handled by the middleware + const { userId } = auth(); + + // Just a double-check, but middleware should already handle this + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + + // Check if the user exists + const user = await prisma.user.findUnique({ + where: { id: body.userId }, + }); + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Check if the user already has a chauffeur profile + const existingChauffeur = await prisma.chauffeur.findUnique({ + where: { userId: body.userId }, + }); + + if (existingChauffeur) { + return NextResponse.json( + { error: "User already has a chauffeur profile" }, + { status: 400 } + ); + } + + // Create the chauffeur profile + const chauffeur = await prisma.chauffeur.create({ + data: { + userId: body.userId, + licenseNumber: body.licenseNumber, + licenseExpiry: new Date(body.licenseExpiry), + vehicleId: body.vehicleId || null, + status: body.status || "AVAILABLE", + rating: body.rating || null, + notes: body.notes || null, + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + vehicle: { + select: { + id: true, + make: true, + model: true, + licensePlate: true, + }, + }, + }, + }); + + // Update the user's role to CHAUFFEUR if it's not already + if (user.role !== "CHAUFFEUR") { + await prisma.user.update({ + where: { id: body.userId }, + data: { role: "CHAUFFEUR" }, + }); + } + + return NextResponse.json(chauffeur, { status: 201 }); + } catch (error) { + console.error("Error creating chauffeur:", error); + return NextResponse.json( + { error: "Failed to create chauffeur" }, + { status: 500 } + ); + } +} diff --git a/app/api/clients/[clientId]/route.ts b/app/api/clients/[clientId]/route.ts new file mode 100644 index 00000000..69d181dc --- /dev/null +++ b/app/api/clients/[clientId]/route.ts @@ -0,0 +1,297 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/clients/[clientId] - Get a specific client +export async function GET( + req: NextRequest, + { params }: { params: { clientId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const clientId = params.clientId as string; + + // Get client with related data + const client = await db.client.findUnique({ + where: { id: clientId }, + include: { + billingInfo: true, + users: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + role: true, + clerkId: true, + }, + }, + bookings: { + take: 5, + orderBy: { createdAt: "desc" }, + }, + events: { + take: 5, + orderBy: { startDate: "desc" }, + }, + _count: { + select: { + users: true, + bookings: true, + events: true, + }, + }, + }, + }); + + if (!client) { + return NextResponse.json({ error: "Client not found" }, { status: 404 }); + } + + return NextResponse.json(client); + } catch (error) { + console.error("Error fetching client:", error); + return NextResponse.json( + { error: "Failed to fetch client" }, + { status: 500 } + ); + } +} + +// PUT /api/clients/[clientId] - Update a specific client +export async function PUT( + req: NextRequest, + { params }: { params: { clientId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const clientId = params.clientId as string; + const data = await req.json(); + + // Validate required fields + if (!data.name || !data.name.trim()) { + return NextResponse.json( + { error: "Client name is required" }, + { status: 400 } + ); + } + + // Check if client exists + const existingClient = await db.client.findUnique({ + where: { id: clientId }, + include: { + users: { + take: 1, + orderBy: { + createdAt: "desc" + }, + }, + }, + }); + + if (!existingClient) { + return NextResponse.json({ error: "Client not found" }, { status: 404 }); + } + + // Check if another client with the same name exists (excluding this client) + const duplicateClient = await db.client.findFirst({ + where: { + name: { + equals: data.name, + mode: "insensitive", + }, + id: { + not: clientId, + }, + }, + }); + + if (duplicateClient) { + return NextResponse.json( + { error: "Another client with this name already exists" }, + { status: 409 } + ); + } + + // Update the client + const updatedClient = await db.client.update({ + where: { id: clientId }, + data: { + name: data.name, + email: data.email, + phone: data.phone, + address: data.address, + city: data.city, + country: data.country, + postalCode: data.postalCode, + website: data.website, + logoUrl: data.logoUrl, + active: data.active ?? true, + contractStart: data.contractStart && data.contractStart !== "" ? new Date(data.contractStart) : null, + contractEnd: data.contractEnd && data.contractEnd !== "" ? new Date(data.contractEnd) : null, + }, + include: { + users: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + role: true, + clerkId: true, + }, + }, + _count: { + select: { + users: true, + bookings: true, + events: true, + }, + }, + }, + }); + + // Handle primary contact information + if ( + data.contactEmail && + data.contactFirstName && + data.contactLastName + ) { + if (existingClient.users && existingClient.users.length > 0) { + // Update existing primary contact + const primaryContact = existingClient.users[0]; + + // Update the user in our database + await db.user.update({ + where: { id: primaryContact.id }, + data: { + firstName: data.contactFirstName, + lastName: data.contactLastName, + email: data.contactEmail, + phone: data.contactPhone || null, + }, + }); + + // Update the user in Clerk if needed + if (primaryContact.clerkId) { + try { + const { clerkClient } = await import("@clerk/nextjs/server"); + await clerkClient.users.updateUser(primaryContact.clerkId, { + firstName: data.contactFirstName, + lastName: data.contactLastName, + emailAddress: [data.contactEmail], + }); + } catch (userError) { + console.error("Error updating user in Clerk:", userError); + // We don't want to fail the client update if Clerk update fails + } + } + } else { + // Since we can't create a user without a clerkId, we'll store the contact info + // in a note in the client's metadata or in a comment to the user + + // For now, we'll just log the contact information and return a message + console.log("Primary contact information provided but not saved as a user:", { + firstName: data.contactFirstName, + lastName: data.contactLastName, + email: data.contactEmail, + phone: data.contactPhone || null, + }); + + // Note: In a complete implementation, we would: + // 1. Create a Clerk user first + // 2. Get the clerkId from the created user + // 3. Then create a user in our database with that clerkId + // 4. Send an invitation email to the user + } + } + + return NextResponse.json(updatedClient); + } catch (error) { + console.error("Error updating client:", error); + return NextResponse.json( + { error: "Failed to update client" }, + { status: 500 } + ); + } +} + +// DELETE /api/clients/[clientId] - Delete a specific client +export async function DELETE( + req: NextRequest, + { params }: { params: { clientId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const clientId = params.clientId as string; + + // Check if client exists + const existingClient = await db.client.findUnique({ + where: { id: clientId }, + }); + + if (!existingClient) { + return NextResponse.json({ error: "Client not found" }, { status: 404 }); + } + + // Check if client has related records + const relatedRecords = await db.client.findUnique({ + where: { id: clientId }, + select: { + _count: { + select: { + users: true, + bookings: true, + events: true, + }, + }, + }, + }); + + if ( + relatedRecords?._count.users > 0 || + relatedRecords?._count.bookings > 0 || + relatedRecords?._count.events > 0 + ) { + return NextResponse.json( + { + error: + "Cannot delete client with related users, bookings, or events. Deactivate the client instead.", + }, + { status: 400 } + ); + } + + // Delete billing info if exists + await db.billingInfo.deleteMany({ + where: { clientId }, + }); + + // Delete the client + await db.client.delete({ + where: { id: clientId }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting client:", error); + return NextResponse.json( + { error: "Failed to delete client" }, + { status: 500 } + ); + } +} diff --git a/app/api/clients/route.ts b/app/api/clients/route.ts new file mode 100644 index 00000000..bb8e2475 --- /dev/null +++ b/app/api/clients/route.ts @@ -0,0 +1,232 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/clients - Get all clients +export async function GET(req: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get query parameters for filtering + const url = new URL(req.url); + const active = url.searchParams.get("active"); + const search = url.searchParams.get("search"); + + // Build filter object + const filter: any = {}; + + if (active === "true") { + filter.active = true; + } else if (active === "false") { + filter.active = false; + } + + if (search) { + // Check for special search parameters like "active:true" + if (search.startsWith("active:")) { + const activeValue = search.split(":")[1]; + if (activeValue === "true") { + filter.active = true; + } else if (activeValue === "false") { + filter.active = false; + } + } else { + // Regular search + filter.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + { phone: { contains: search, mode: "insensitive" } }, + { city: { contains: search, mode: "insensitive" } }, + { country: { contains: search, mode: "insensitive" } }, + ]; + } + } + + // Get clients with filtering + const clients = await db.client.findMany({ + where: filter, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + email: true, + phone: true, + address: true, + city: true, + country: true, + postalCode: true, + website: true, + logoUrl: true, + active: true, + contractStart: true, + contractEnd: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + users: true, + bookings: true, + events: true, + }, + }, + }, + }); + + return NextResponse.json(clients); + } catch (error) { + console.error("Error fetching clients:", error); + return NextResponse.json( + { error: "Failed to fetch clients" }, + { status: 500 } + ); + } +} + +// POST /api/clients - Create a new client +export async function POST(req: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const data = await req.json(); + + // Validate required fields + if (!data.name || !data.name.trim()) { + return NextResponse.json( + { error: "Client name is required" }, + { status: 400 } + ); + } + + // Validate contact information if provided + if (data.contactEmail) { + if (!data.contactFirstName || !data.contactLastName) { + return NextResponse.json( + { error: "Contact first and last name are required" }, + { status: 400 } + ); + } + } + + // Check if a client with the same name already exists + const existingClient = await db.client.findFirst({ + where: { + name: { + equals: data.name, + mode: "insensitive" + } + } + }); + + if (existingClient) { + return NextResponse.json( + { error: "A client with this name already exists" }, + { status: 409 } // 409 Conflict + ); + } + + // Check if a user with the contact email already exists + if (data.contactEmail) { + const existingUser = await db.user.findFirst({ + where: { + email: data.contactEmail + } + }); + + if (existingUser) { + return NextResponse.json( + { error: "A user with this email already exists" }, + { status: 409 } // 409 Conflict + ); + } + } + + // Create the client + const client = await db.client.create({ + data: { + name: data.name, + email: data.email, + phone: data.phone, + address: data.address, + city: data.city, + country: data.country, + postalCode: data.postalCode, + website: data.website, + logoUrl: data.logoUrl, + active: data.active ?? true, + contractStart: data.contractStart && data.contractStart !== "" ? new Date(data.contractStart) : null, + contractEnd: data.contractEnd && data.contractEnd !== "" ? new Date(data.contractEnd) : null, + }, + include: { + _count: { + select: { + users: true, + bookings: true, + events: true, + }, + }, + }, + }); + + // Create a user for the client contact if provided + if (data.contactEmail && data.contactFirstName && data.contactLastName) { + try { + // Import the Clerk SDK + const { clerkClient } = await import("@clerk/nextjs/server"); + + // Create a new user in Clerk + const clerkUser = await clerkClient.users.createUser({ + emailAddress: [data.contactEmail], + firstName: data.contactFirstName, + lastName: data.contactLastName, + password: null, // This will force Clerk to send a magic link + skipPasswordRequirement: true, + }); + + // Create a user in our database linked to the client + if (clerkUser.id) { + await db.user.create({ + data: { + clerkId: clerkUser.id, + email: data.contactEmail, + firstName: data.contactFirstName, + lastName: data.contactLastName, + phone: data.contactPhone || null, + role: "CUSTOMER", // Set the role to CUSTOMER for client contacts + clientId: client.id, // Link to the client + }, + }); + + // Send invitation email if requested + if (data.sendInvitation) { + await clerkClient.invitations.createInvitation({ + emailAddress: data.contactEmail, + redirectUrl: `${process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL}`, + publicMetadata: { + clientId: client.id, + clientName: client.name, + }, + }); + } + } + } catch (userError) { + console.error("Error creating user for client:", userError); + // We don't want to fail the client creation if user creation fails + // But we should log it and maybe notify the admin + } + } + + return NextResponse.json(client); + } catch (error) { + console.error("Error creating client:", error); + return NextResponse.json( + { error: "Failed to create client" }, + { status: 500 } + ); + } +} diff --git a/app/api/event-operations/[eventId]/route.ts b/app/api/event-operations/[eventId]/route.ts new file mode 100644 index 00000000..fc2e7007 --- /dev/null +++ b/app/api/event-operations/[eventId]/route.ts @@ -0,0 +1,274 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// DELETE /api/event-operations/[eventId] - Delete an event and all related data +export async function DELETE( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + + // Check if event exists + const existingEvent = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!existingEvent) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Delete related records first + await db.eventParticipant.deleteMany({ + where: { eventId }, + }); + + await db.eventVehicle.deleteMany({ + where: { eventId }, + }); + + // Delete missions and their rides + const missions = await db.mission.findMany({ + where: { eventId }, + select: { id: true }, + }); + + for (const mission of missions) { + await db.ride.updateMany({ + where: { missionId: mission.id }, + data: { missionId: null }, + }); + } + + await db.mission.deleteMany({ + where: { eventId }, + }); + + // Delete the event + await db.event.delete({ + where: { id: eventId }, + }); + + return NextResponse.json({ message: "Event deleted successfully" }); + } catch (error) { + console.error("Error deleting event:", error); + return NextResponse.json( + { error: "Failed to delete event" }, + { status: 500 } + ); + } +} + +// POST /api/event-operations/[eventId] - Handle various event operations +export async function POST( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + const { operation } = await req.json(); + + // Check if event exists + const existingEvent = await db.event.findUnique({ + where: { id: eventId }, + include: { + missions: true, + participants: true, + eventVehicles: true, + }, + }); + + if (!existingEvent) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Handle different operations + switch (operation) { + case "clone": + return await handleCloneEvent(existingEvent); + case "cancel": + return await handleCancelEvent(eventId); + case "complete": + return await handleCompleteEvent(eventId); + default: + return NextResponse.json( + { error: "Invalid operation" }, + { status: 400 } + ); + } + } catch (error) { + console.error("Error processing event operation:", error); + return NextResponse.json( + { error: "Failed to process event operation" }, + { status: 500 } + ); + } +} + +// Helper function to clone an event +async function handleCloneEvent(existingEvent: any) { + try { + // Create a new event with the same data but a different title + const newEvent = await db.event.create({ + data: { + title: `${existingEvent.title} (Clone)`, + description: existingEvent.description, + clientId: existingEvent.clientId, + startDate: existingEvent.startDate, + endDate: existingEvent.endDate, + status: "PLANNED", // Always set status to PLANNED for cloned events + location: existingEvent.location, + pricingType: existingEvent.pricingType, + fixedPrice: existingEvent.fixedPrice, + notes: existingEvent.notes, + }, + }); + + // Clone missions + for (const mission of existingEvent.missions) { + await db.mission.create({ + data: { + title: mission.title, + description: mission.description, + eventId: newEvent.id, + startDate: mission.startDate, + endDate: mission.endDate, + status: "PLANNED", // Always set status to PLANNED for cloned missions + location: mission.location, + fare: mission.fare, + notes: mission.notes, + }, + }); + } + + // Clone participants + for (const participant of existingEvent.participants) { + await db.eventParticipant.create({ + data: { + eventId: newEvent.id, + userId: participant.userId, + role: participant.role, + status: "PENDING", // Reset status to PENDING for cloned participants + }, + }); + } + + // Clone vehicle assignments + for (const vehicle of existingEvent.eventVehicles) { + await db.eventVehicle.create({ + data: { + eventId: newEvent.id, + vehicleId: vehicle.vehicleId, + status: "ASSIGNED", + notes: vehicle.notes, + }, + }); + } + + return NextResponse.json({ + message: "Event cloned successfully", + event: newEvent, + }); + } catch (error) { + console.error("Error cloning event:", error); + return NextResponse.json( + { error: "Failed to clone event" }, + { status: 500 } + ); + } +} + +// Helper function to cancel an event +async function handleCancelEvent(eventId: string) { + try { + // Update event status to CANCELLED + const updatedEvent = await db.event.update({ + where: { id: eventId }, + data: { status: "CANCELLED" }, + }); + + // Update all missions to CANCELLED + await db.mission.updateMany({ + where: { eventId }, + data: { status: "CANCELLED" }, + }); + + // Get all mission IDs for this event + const missions = await db.mission.findMany({ + where: { eventId }, + select: { id: true }, + }); + + // Update all rides associated with these missions to CANCELLED + for (const mission of missions) { + await db.ride.updateMany({ + where: { missionId: mission.id }, + data: { status: "CANCELLED" }, + }); + } + + return NextResponse.json({ + message: "Event cancelled successfully", + event: updatedEvent, + }); + } catch (error) { + console.error("Error cancelling event:", error); + return NextResponse.json( + { error: "Failed to cancel event" }, + { status: 500 } + ); + } +} + +// Helper function to mark an event as complete +async function handleCompleteEvent(eventId: string) { + try { + // Update event status to COMPLETED + const updatedEvent = await db.event.update({ + where: { id: eventId }, + data: { status: "COMPLETED" }, + }); + + // Update all missions to COMPLETED + await db.mission.updateMany({ + where: { eventId }, + data: { status: "COMPLETED" }, + }); + + // Get all mission IDs for this event + const missions = await db.mission.findMany({ + where: { eventId }, + select: { id: true }, + }); + + // Update all rides associated with these missions to COMPLETED + for (const mission of missions) { + await db.ride.updateMany({ + where: { missionId: mission.id }, + data: { status: "COMPLETED" }, + }); + } + + return NextResponse.json({ + message: "Event marked as completed", + event: updatedEvent, + }); + } catch (error) { + console.error("Error completing event:", error); + return NextResponse.json( + { error: "Failed to complete event" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/events/[eventId]/missions/[missionId]/route.ts b/app/api/events/[eventId]/missions/[missionId]/route.ts new file mode 100644 index 00000000..a09ec460 --- /dev/null +++ b/app/api/events/[eventId]/missions/[missionId]/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/missions/[missionId] - Get a specific mission +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string; missionId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, missionId } = params; + + // Check if mission exists and belongs to the event + const mission = await db.mission.findFirst({ + where: { + id: missionId, + eventId, + }, + include: { + rides: { + include: { + passenger: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + chauffeur: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (!mission) { + return NextResponse.json({ error: "Mission not found" }, { status: 404 }); + } + + return NextResponse.json(mission); + } catch (error) { + console.error("Error fetching mission:", error); + return NextResponse.json( + { error: "Failed to fetch mission" }, + { status: 500 } + ); + } +} + +// PUT /api/events/[eventId]/missions/[missionId] - Update a specific mission +export async function PUT( + req: NextRequest, + { params }: { params: { eventId: string; missionId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, missionId } = params; + const data = await req.json(); + + // Check if mission exists and belongs to the event + const existingMission = await db.mission.findFirst({ + where: { + id: missionId, + eventId, + }, + }); + + if (!existingMission) { + return NextResponse.json({ error: "Mission not found" }, { status: 404 }); + } + + // Update the mission + const updatedMission = await db.mission.update({ + where: { id: missionId }, + data: { + title: data.title, + description: data.description, + startDate: data.startDate ? new Date(data.startDate) : undefined, + endDate: data.endDate ? new Date(data.endDate) : undefined, + status: data.status, + location: data.location, + fare: data.fare ? parseFloat(data.fare) : null, + notes: data.notes, + }, + }); + + // Update event total fare if pricing type is MISSION_BASED + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (event && event.pricingType === "MISSION_BASED") { + const allMissions = await db.mission.findMany({ + where: { eventId }, + select: { fare: true }, + }); + + const totalFare = allMissions.reduce((sum, mission) => { + return sum + (mission.fare ? parseFloat(mission.fare.toString()) : 0); + }, 0); + + await db.event.update({ + where: { id: eventId }, + data: { totalFare }, + }); + } + + return NextResponse.json(updatedMission); + } catch (error) { + console.error("Error updating mission:", error); + return NextResponse.json( + { error: "Failed to update mission" }, + { status: 500 } + ); + } +} + +// DELETE /api/events/[eventId]/missions/[missionId] - Delete a specific mission +export async function DELETE( + req: NextRequest, + { params }: { params: { eventId: string; missionId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, missionId } = params; + + // Check if mission exists and belongs to the event + const existingMission = await db.mission.findFirst({ + where: { + id: missionId, + eventId, + }, + }); + + if (!existingMission) { + return NextResponse.json({ error: "Mission not found" }, { status: 404 }); + } + + // Update rides to remove mission association + await db.ride.updateMany({ + where: { missionId }, + data: { missionId: null }, + }); + + // Delete the mission + await db.mission.delete({ + where: { id: missionId }, + }); + + // Update event total fare if pricing type is MISSION_BASED + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (event && event.pricingType === "MISSION_BASED") { + const allMissions = await db.mission.findMany({ + where: { eventId }, + select: { fare: true }, + }); + + const totalFare = allMissions.reduce((sum, mission) => { + return sum + (mission.fare ? parseFloat(mission.fare.toString()) : 0); + }, 0); + + await db.event.update({ + where: { id: eventId }, + data: { totalFare }, + }); + } + + return NextResponse.json({ message: "Mission deleted successfully" }); + } catch (error) { + console.error("Error deleting mission:", error); + return NextResponse.json( + { error: "Failed to delete mission" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[eventId]/missions/route.ts b/app/api/events/[eventId]/missions/route.ts new file mode 100644 index 00000000..35e8089f --- /dev/null +++ b/app/api/events/[eventId]/missions/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/missions - Get all missions for an event +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Get missions for the event + const missions = await db.mission.findMany({ + where: { eventId }, + include: { + rides: { + include: { + passenger: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + chauffeur: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }, + }, + orderBy: { + startDate: "asc", + }, + }); + + return NextResponse.json(missions); + } catch (error) { + console.error("Error fetching missions:", error); + return NextResponse.json( + { error: "Failed to fetch missions" }, + { status: 500 } + ); + } +} + +// POST /api/events/[eventId]/missions - Create a new mission for an event +export async function POST( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + const data = await req.json(); + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Validate required fields + if (!data.title || !data.startDate || !data.endDate) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + // Create the mission + const mission = await db.mission.create({ + data: { + title: data.title, + description: data.description, + eventId, + startDate: new Date(data.startDate), + endDate: new Date(data.endDate), + status: data.status || "PLANNED", + location: data.location, + fare: data.fare ? parseFloat(data.fare) : null, + notes: data.notes, + }, + }); + + // Create rides if provided + if (data.rides && Array.isArray(data.rides)) { + for (const ride of data.rides) { + await db.ride.create({ + data: { + bookingId: ride.bookingId, + passengerId: ride.passengerId, + chauffeurId: ride.chauffeurId, + pickupAddress: ride.pickupAddress, + dropoffAddress: ride.dropoffAddress, + pickupTime: new Date(ride.pickupTime), + dropoffTime: ride.dropoffTime ? new Date(ride.dropoffTime) : null, + status: ride.status || "SCHEDULED", + fare: ride.fare ? parseFloat(ride.fare) : null, + distance: ride.distance ? parseFloat(ride.distance) : null, + duration: ride.duration, + notes: ride.notes, + missionId: mission.id, + }, + }); + } + } + + // Update event total fare if pricing type is MISSION_BASED + if (event.pricingType === "MISSION_BASED") { + const allMissions = await db.mission.findMany({ + where: { eventId }, + select: { fare: true }, + }); + + const totalFare = allMissions.reduce((sum, mission) => { + return sum + (mission.fare ? parseFloat(mission.fare.toString()) : 0); + }, 0); + + await db.event.update({ + where: { id: eventId }, + data: { totalFare }, + }); + } + + return NextResponse.json(mission, { status: 201 }); + } catch (error) { + console.error("Error creating mission:", error); + return NextResponse.json( + { error: "Failed to create mission" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[eventId]/participants/[participantId]/route.ts b/app/api/events/[eventId]/participants/[participantId]/route.ts new file mode 100644 index 00000000..626636d7 --- /dev/null +++ b/app/api/events/[eventId]/participants/[participantId]/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/participants/[participantId] - Get a specific participant +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string; participantId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, participantId } = params; + + // Check if participant exists and belongs to the event + const participant = await db.eventParticipant.findFirst({ + where: { + id: participantId, + eventId, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + role: true, + }, + }, + }, + }); + + if (!participant) { + return NextResponse.json( + { error: "Participant not found" }, + { status: 404 } + ); + } + + return NextResponse.json(participant); + } catch (error) { + console.error("Error fetching participant:", error); + return NextResponse.json( + { error: "Failed to fetch participant" }, + { status: 500 } + ); + } +} + +// PUT /api/events/[eventId]/participants/[participantId] - Update a specific participant +export async function PUT( + req: NextRequest, + { params }: { params: { eventId: string; participantId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, participantId } = params; + const data = await req.json(); + + // Check if participant exists and belongs to the event + const existingParticipant = await db.eventParticipant.findFirst({ + where: { + id: participantId, + eventId, + }, + }); + + if (!existingParticipant) { + return NextResponse.json( + { error: "Participant not found" }, + { status: 404 } + ); + } + + // Update the participant + const updatedParticipant = await db.eventParticipant.update({ + where: { id: participantId }, + data: { + role: data.role, + status: data.status, + }, + }); + + return NextResponse.json(updatedParticipant); + } catch (error) { + console.error("Error updating participant:", error); + return NextResponse.json( + { error: "Failed to update participant" }, + { status: 500 } + ); + } +} + +// DELETE /api/events/[eventId]/participants/[participantId] - Remove a participant from an event +export async function DELETE( + req: NextRequest, + { params }: { params: { eventId: string; participantId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, participantId } = params; + + // Check if participant exists and belongs to the event + const existingParticipant = await db.eventParticipant.findFirst({ + where: { + id: participantId, + eventId, + }, + }); + + if (!existingParticipant) { + return NextResponse.json( + { error: "Participant not found" }, + { status: 404 } + ); + } + + // Delete the participant + await db.eventParticipant.delete({ + where: { id: participantId }, + }); + + return NextResponse.json({ + message: "Participant removed successfully", + }); + } catch (error) { + console.error("Error removing participant:", error); + return NextResponse.json( + { error: "Failed to remove participant" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[eventId]/participants/route.ts b/app/api/events/[eventId]/participants/route.ts new file mode 100644 index 00000000..a8389339 --- /dev/null +++ b/app/api/events/[eventId]/participants/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/participants - Get all participants for an event +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Get participants for the event + const participants = await db.eventParticipant.findMany({ + where: { eventId }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + role: true, + }, + }, + }, + }); + + return NextResponse.json(participants); + } catch (error) { + console.error("Error fetching participants:", error); + return NextResponse.json( + { error: "Failed to fetch participants" }, + { status: 500 } + ); + } +} + +// POST /api/events/[eventId]/participants - Add a participant to an event +export async function POST( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + const data = await req.json(); + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Validate required fields + if (!data.userId || !data.role) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + // Check if user exists + const user = await db.user.findUnique({ + where: { id: data.userId }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Check if participant already exists + const existingParticipant = await db.eventParticipant.findFirst({ + where: { + eventId, + userId: data.userId, + }, + }); + + if (existingParticipant) { + return NextResponse.json( + { error: "User is already a participant" }, + { status: 400 } + ); + } + + // Create the participant + const participant = await db.eventParticipant.create({ + data: { + eventId, + userId: data.userId, + role: data.role, + status: data.status || "PENDING", + }, + }); + + return NextResponse.json(participant, { status: 201 }); + } catch (error) { + console.error("Error adding participant:", error); + return NextResponse.json( + { error: "Failed to add participant" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[eventId]/route.ts b/app/api/events/[eventId]/route.ts new file mode 100644 index 00000000..2056a153 --- /dev/null +++ b/app/api/events/[eventId]/route.ts @@ -0,0 +1,175 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "../../../../lib/db"; + +// GET /api/events/[eventId] - Get a specific event +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + + const event = await db.event.findUnique({ + where: { id: eventId }, + include: { + client: true, + missions: { + include: { + rides: true, + }, + }, + participants: { + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + }, + }, + }, + }, + eventVehicles: { + include: { + vehicle: true, + }, + }, + }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + return NextResponse.json(event); + } catch (error) { + console.error("Error fetching event:", error); + return NextResponse.json( + { error: "Failed to fetch event" }, + { status: 500 } + ); + } +} + +// PUT /api/events/[eventId] - Update a specific event +export async function PUT( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + const data = await req.json(); + + // Check if event exists + const existingEvent = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!existingEvent) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Update the event + const updatedEvent = await db.event.update({ + where: { id: eventId }, + data: { + title: data.title, + description: data.description, + clientId: data.clientId, + startDate: data.startDate ? new Date(data.startDate) : undefined, + endDate: data.endDate ? new Date(data.endDate) : undefined, + status: data.status, + location: data.location, + pricingType: data.pricingType, + fixedPrice: data.fixedPrice ? parseFloat(data.fixedPrice) : null, + notes: data.notes, + }, + }); + + return NextResponse.json(updatedEvent); + } catch (error) { + console.error("Error updating event:", error); + return NextResponse.json( + { error: "Failed to update event" }, + { status: 500 } + ); + } +} + +// DELETE /api/events/[eventId] - Delete a specific event +export async function DELETE( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + + // Check if event exists + const existingEvent = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!existingEvent) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Delete related records first + await db.eventParticipant.deleteMany({ + where: { eventId }, + }); + + await db.eventVehicle.deleteMany({ + where: { eventId }, + }); + + // Delete missions and their rides + const missions = await db.mission.findMany({ + where: { eventId }, + select: { id: true }, + }); + + for (const mission of missions) { + await db.ride.updateMany({ + where: { missionId: mission.id }, + data: { missionId: null }, + }); + } + + await db.mission.deleteMany({ + where: { eventId }, + }); + + // Delete the event + await db.event.delete({ + where: { id: eventId }, + }); + + return NextResponse.json({ message: "Event deleted successfully" }); + } catch (error) { + console.error("Error deleting event:", error); + return NextResponse.json( + { error: "Failed to delete event" }, + { status: 500 } + ); + } +} + + diff --git a/app/api/events/[eventId]/vehicles/[vehicleId]/route.ts b/app/api/events/[eventId]/vehicles/[vehicleId]/route.ts new file mode 100644 index 00000000..e7b575a8 --- /dev/null +++ b/app/api/events/[eventId]/vehicles/[vehicleId]/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/vehicles/[vehicleId] - Get a specific vehicle assignment +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string; vehicleId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, vehicleId } = params; + + // Check if vehicle assignment exists + const eventVehicle = await db.eventVehicle.findFirst({ + where: { + id: vehicleId, + eventId, + }, + include: { + vehicle: true, + }, + }); + + if (!eventVehicle) { + return NextResponse.json( + { error: "Vehicle assignment not found" }, + { status: 404 } + ); + } + + return NextResponse.json(eventVehicle); + } catch (error) { + console.error("Error fetching vehicle assignment:", error); + return NextResponse.json( + { error: "Failed to fetch vehicle assignment" }, + { status: 500 } + ); + } +} + +// PUT /api/events/[eventId]/vehicles/[vehicleId] - Update a specific vehicle assignment +export async function PUT( + req: NextRequest, + { params }: { params: { eventId: string; vehicleId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, vehicleId } = params; + const data = await req.json(); + + // Check if vehicle assignment exists + const existingAssignment = await db.eventVehicle.findFirst({ + where: { + id: vehicleId, + eventId, + }, + }); + + if (!existingAssignment) { + return NextResponse.json( + { error: "Vehicle assignment not found" }, + { status: 404 } + ); + } + + // Update the vehicle assignment + const updatedAssignment = await db.eventVehicle.update({ + where: { id: vehicleId }, + data: { + status: data.status, + notes: data.notes, + }, + }); + + return NextResponse.json(updatedAssignment); + } catch (error) { + console.error("Error updating vehicle assignment:", error); + return NextResponse.json( + { error: "Failed to update vehicle assignment" }, + { status: 500 } + ); + } +} + +// DELETE /api/events/[eventId]/vehicles/[vehicleId] - Remove a vehicle from an event +export async function DELETE( + req: NextRequest, + { params }: { params: { eventId: string; vehicleId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, vehicleId } = params; + + // Check if vehicle assignment exists + const existingAssignment = await db.eventVehicle.findFirst({ + where: { + id: vehicleId, + eventId, + }, + }); + + if (!existingAssignment) { + return NextResponse.json( + { error: "Vehicle assignment not found" }, + { status: 404 } + ); + } + + // Delete the vehicle assignment + await db.eventVehicle.delete({ + where: { id: vehicleId }, + }); + + return NextResponse.json({ + message: "Vehicle removed from event successfully", + }); + } catch (error) { + console.error("Error removing vehicle from event:", error); + return NextResponse.json( + { error: "Failed to remove vehicle from event" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[eventId]/vehicles/route.ts b/app/api/events/[eventId]/vehicles/route.ts new file mode 100644 index 00000000..801fcdd6 --- /dev/null +++ b/app/api/events/[eventId]/vehicles/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/vehicles - Get all vehicles for an event +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Get vehicles for the event + const vehicles = await db.eventVehicle.findMany({ + where: { eventId }, + include: { + vehicle: true, + }, + }); + + return NextResponse.json(vehicles); + } catch (error) { + console.error("Error fetching vehicles:", error); + return NextResponse.json( + { error: "Failed to fetch vehicles" }, + { status: 500 } + ); + } +} + +// POST /api/events/[eventId]/vehicles - Add a vehicle to an event +export async function POST( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + const data = await req.json(); + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Validate required fields + if (!data.vehicleId) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + // Check if vehicle exists + const vehicle = await db.vehicle.findUnique({ + where: { id: data.vehicleId }, + }); + + if (!vehicle) { + return NextResponse.json({ error: "Vehicle not found" }, { status: 404 }); + } + + // Check if vehicle is already assigned to the event + const existingAssignment = await db.eventVehicle.findFirst({ + where: { + eventId, + vehicleId: data.vehicleId, + }, + }); + + if (existingAssignment) { + return NextResponse.json( + { error: "Vehicle is already assigned to this event" }, + { status: 400 } + ); + } + + // Create the vehicle assignment + const eventVehicle = await db.eventVehicle.create({ + data: { + eventId, + vehicleId: data.vehicleId, + status: data.status || "ASSIGNED", + notes: data.notes, + }, + }); + + return NextResponse.json(eventVehicle, { status: 201 }); + } catch (error) { + console.error("Error assigning vehicle:", error); + return NextResponse.json( + { error: "Failed to assign vehicle" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[eventId]/venues/[venueId]/route.ts b/app/api/events/[eventId]/venues/[venueId]/route.ts new file mode 100644 index 00000000..d2329da1 --- /dev/null +++ b/app/api/events/[eventId]/venues/[venueId]/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/venues/[venueId] - Get a specific venue assignment +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string; venueId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, venueId } = params; + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Since there's no Venue model in the schema yet, we'll return a mock response + // This should be updated once the Venue model is added to the schema + const mockVenue = { + id: venueId, + name: "Grand Ballroom", + address: "123 Main St, City", + capacity: 500, + status: "available", + startTime: new Date(), + conflicts: [], + assigned: false, + }; + + return NextResponse.json(mockVenue); + } catch (error) { + console.error("Error fetching venue:", error); + return NextResponse.json( + { error: "Failed to fetch venue" }, + { status: 500 } + ); + } +} + +// PUT /api/events/[eventId]/venues/[venueId] - Update a specific venue assignment +export async function PUT( + req: NextRequest, + { params }: { params: { eventId: string; venueId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, venueId } = params; + const data = await req.json(); + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Since there's no Venue model in the schema yet, we'll return a mock response + // This should be updated once the Venue model is added to the schema + const updatedVenue = { + id: venueId, + name: data.name || "Grand Ballroom", + address: data.address || "123 Main St, City", + capacity: data.capacity || 500, + status: data.status || "available", + startTime: new Date(), + conflicts: [], + assigned: data.assigned || false, + }; + + return NextResponse.json(updatedVenue); + } catch (error) { + console.error("Error updating venue:", error); + return NextResponse.json( + { error: "Failed to update venue" }, + { status: 500 } + ); + } +} + +// DELETE /api/events/[eventId]/venues/[venueId] - Remove a venue from an event +export async function DELETE( + req: NextRequest, + { params }: { params: { eventId: string; venueId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { eventId, venueId } = params; + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Since there's no Venue model in the schema yet, we'll just return a success message + // This should be updated once the Venue model is added to the schema + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error removing venue:", error); + return NextResponse.json( + { error: "Failed to remove venue" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/[eventId]/venues/route.ts b/app/api/events/[eventId]/venues/route.ts new file mode 100644 index 00000000..31492673 --- /dev/null +++ b/app/api/events/[eventId]/venues/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events/[eventId]/venues - Get all venues for an event +export async function GET( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Since there's no Venue model in the schema yet, we'll return a mock response + // This should be updated once the Venue model is added to the schema + const mockVenues = [ + { + id: "venue1", + name: "Grand Ballroom", + address: "123 Main St, City", + capacity: 500, + status: "available", + startTime: new Date(), + conflicts: [], + assigned: false, + }, + { + id: "venue2", + name: "Conference Room A", + address: "456 Business Ave, City", + capacity: 100, + status: "available", + startTime: new Date(), + conflicts: [], + assigned: false, + }, + ]; + + return NextResponse.json(mockVenues); + } catch (error) { + console.error("Error fetching venues:", error); + return NextResponse.json( + { error: "Failed to fetch venues" }, + { status: 500 } + ); + } +} + +// POST /api/events/[eventId]/venues - Add a venue to an event +export async function POST( + req: NextRequest, + { params }: { params: { eventId: string } } +) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const eventId = params.eventId; + const data = await req.json(); + + // Check if event exists + const event = await db.event.findUnique({ + where: { id: eventId }, + }); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + // Validate required fields + if (!data.name || !data.address) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + // Since there's no Venue model in the schema yet, we'll return a mock response + // This should be updated once the Venue model is added to the schema + const mockVenue = { + id: `venue-${Date.now()}`, + name: data.name, + address: data.address, + capacity: data.capacity || 100, + status: "available", + startTime: new Date(), + conflicts: [], + assigned: false, + }; + + return NextResponse.json(mockVenue); + } catch (error) { + console.error("Error adding venue:", error); + return NextResponse.json( + { error: "Failed to add venue" }, + { status: 500 } + ); + } +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 00000000..3a09f731 --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/lib/db"; + +// GET /api/events - Get all events +export async function GET(req: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get query parameters for filtering + const url = new URL(req.url); + const status = url.searchParams.get("status"); + const clientId = url.searchParams.get("clientId"); + const startDate = url.searchParams.get("startDate"); + const endDate = url.searchParams.get("endDate"); + + // Build filter object + const filter: any = {}; + + if (status) { + filter.status = status; + } + + if (clientId) { + filter.clientId = clientId; + } + + if (startDate && endDate) { + filter.startDate = { + gte: new Date(startDate), + }; + filter.endDate = { + lte: new Date(endDate), + }; + } else if (startDate) { + filter.startDate = { + gte: new Date(startDate), + }; + } else if (endDate) { + filter.endDate = { + lte: new Date(endDate), + }; + } + + // Get events with related data + const events = await db.event.findMany({ + where: filter, + include: { + client: true, + missions: { + select: { + id: true, + title: true, + status: true, + startDate: true, + endDate: true, + }, + }, + participants: { + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + }, + }, + }, + }, + eventVehicles: { + include: { + vehicle: true, + }, + }, + }, + orderBy: { + startDate: "asc", + }, + }); + + return NextResponse.json(events); + } catch (error) { + console.error("Error fetching events:", error); + return NextResponse.json( + { error: "Failed to fetch events" }, + { status: 500 } + ); + } +} + +// POST /api/events - Create a new event +export async function POST(req: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const data = await req.json(); + + // Validate required fields + if (!data.title || !data.clientId || !data.startDate || !data.endDate) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + // Create the event + const event = await db.event.create({ + data: { + title: data.title, + description: data.description, + clientId: data.clientId, + startDate: new Date(data.startDate), + endDate: new Date(data.endDate), + status: data.status || "PLANNED", + location: data.location, + pricingType: data.pricingType || "MISSION_BASED", + fixedPrice: data.fixedPrice ? parseFloat(data.fixedPrice) : null, + notes: data.notes, + }, + }); + + // Add participants if provided + if (data.participants && Array.isArray(data.participants)) { + for (const participant of data.participants) { + await db.eventParticipant.create({ + data: { + eventId: event.id, + userId: participant.userId, + role: participant.role, + status: participant.status || "PENDING", + }, + }); + } + } + + // Add vehicles if provided + if (data.vehicles && Array.isArray(data.vehicles)) { + for (const vehicle of data.vehicles) { + await db.eventVehicle.create({ + data: { + eventId: event.id, + vehicleId: vehicle.vehicleId, + status: vehicle.status || "ASSIGNED", + notes: vehicle.notes, + }, + }); + } + } + + return NextResponse.json(event, { status: 201 }); + } catch (error) { + console.error("Error creating event:", error); + return NextResponse.json( + { error: "Failed to create event" }, + { status: 500 } + ); + } +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 00000000..0f8bf2ff --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; + +// OPTIONS handler for CORS preflight requests +export async function OPTIONS(req: NextRequest) { + return new NextResponse(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + }, + }); +} + +// GET /api/users - Get all users +export async function GET(req: NextRequest) { + try { + // Simple mock data without any authentication checks + const mockUsers = [ + { + id: "user_1", + firstName: "John", + lastName: "Doe", + fullName: "John Doe", + email: "john.doe@example.com", + phone: "+1234567890", + role: "CHAUFFEUR", + }, + { + id: "user_2", + firstName: "Jane", + lastName: "Smith", + fullName: "Jane Smith", + email: "jane.smith@example.com", + phone: "+0987654321", + role: "CHAUFFEUR", + }, + { + id: "user_3", + firstName: "Michael", + lastName: "Johnson", + fullName: "Michael Johnson", + email: "michael.johnson@example.com", + phone: "+1122334455", + role: "CHAUFFEUR", + }, + ]; + + // Set CORS headers to allow requests from any origin + return new NextResponse(JSON.stringify(mockUsers), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); + } catch (error) { + console.error("Error fetching users:", error); + return new NextResponse(JSON.stringify({ error: "Failed to fetch users" }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } +} diff --git a/app/api/vehicles/route.ts b/app/api/vehicles/route.ts new file mode 100644 index 00000000..91069619 --- /dev/null +++ b/app/api/vehicles/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; + +// OPTIONS handler for CORS preflight requests +export async function OPTIONS(req: NextRequest) { + return new NextResponse(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + }, + }); +} + +// GET /api/vehicles - Get all vehicles +export async function GET(req: NextRequest) { + try { + // Simple mock data without any authentication checks + const mockVehicles = [ + { + id: "vehicle_1", + make: "Mercedes", + model: "S-Class", + licensePlate: "ABC123", + year: 2023, + color: "Black", + capacity: 4, + vehicleType: "SEDAN", + status: "AVAILABLE", + }, + { + id: "vehicle_2", + make: "BMW", + model: "7 Series", + licensePlate: "XYZ789", + year: 2022, + color: "Silver", + capacity: 4, + vehicleType: "SEDAN", + status: "AVAILABLE", + }, + { + id: "vehicle_3", + make: "Audi", + model: "A8", + licensePlate: "DEF456", + year: 2023, + color: "White", + capacity: 4, + vehicleType: "SEDAN", + status: "AVAILABLE", + }, + ]; + + // Set CORS headers to allow requests from any origin + return new NextResponse(JSON.stringify(mockVehicles), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); + } catch (error) { + console.error("Error fetching vehicles:", error); + return new NextResponse(JSON.stringify({ error: "Failed to fetch vehicles" }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } +} diff --git a/app/cars/[carId]/page.tsx b/app/cars/[carId]/page.tsx new file mode 100644 index 00000000..dd6b90e7 --- /dev/null +++ b/app/cars/[carId]/page.tsx @@ -0,0 +1,721 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft, Car, Calendar, Tag, Gauge, Edit, Trash2, MoreHorizontal, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { AppSidebar } from "@/components/app-sidebar"; +import { SiteHeader } from "@/components/site-header"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { PlateCarDialog } from "../components/plate-car-dialog"; +import { VehicleAssignmentDialog } from "../components/vehicle-assignment-dialog"; +import { CarFormValues } from "../schemas/car-schema"; +import { deleteVehicle, getVehicleById, updateVehicle } from "../actions"; + +// Mock data for a single car (in a real app, this would come from an API) +const mockCar = { + id: "1", + make: "Mercedes-Benz", + model: "S-Class", + year: 2023, + licensePlate: "AB-123-CD", + color: "Black", + capacity: 4, + vehicleType: "LUXURY", + status: "AVAILABLE", + lastMaintenance: "2023-04-15T00:00:00Z", + isFrenchPlate: true, + createdAt: "2023-01-10T14:30:00Z", + // Additional details for the detail page + vin: "WDDUG8CB7LA456789", + fuelType: "Diesel", + mileage: 15000, + nextMaintenanceDue: "2023-10-15T00:00:00Z", + insuranceExpiryDate: "2024-01-10T00:00:00Z", + registrationExpiryDate: "2024-01-10T00:00:00Z", + notes: "Premium vehicle for VIP clients. Regular maintenance required.", +}; + +// Mock maintenance history +const mockMaintenanceHistory = [ + { + id: "1", + date: "2023-04-15T00:00:00Z", + type: "Regular Service", + description: "Oil change, filter replacement, general inspection", + cost: 350, + mileage: 12000, + }, + { + id: "2", + date: "2023-01-20T00:00:00Z", + type: "Tire Replacement", + description: "Replaced all four tires with premium winter tires", + cost: 1200, + mileage: 10000, + }, + { + id: "3", + date: "2022-10-05T00:00:00Z", + type: "Regular Service", + description: "Oil change, brake inspection, fluid top-up", + cost: 280, + mileage: 7500, + }, +]; + +// Mock ride history +const mockRideHistory = [ + { + id: "1", + date: "2023-06-10T09:00:00Z", + client: "John Smith", + from: "Charles de Gaulle Airport", + to: "Hotel Ritz Paris", + distance: 35, + duration: 45, + }, + { + id: "2", + date: "2023-06-08T14:30:00Z", + client: "Emma Johnson", + from: "Hotel Four Seasons", + to: "Eiffel Tower", + distance: 5, + duration: 15, + }, + { + id: "3", + date: "2023-06-05T19:00:00Z", + client: "Robert Williams", + from: "Le Bourget Airport", + to: "Champs-Élysées", + distance: 20, + duration: 30, + }, +]; + +export default function CarDetailPage() { + const params = useParams(); + const router = useRouter(); + const [car, setCar] = useState(null); + const [loading, setLoading] = useState(true); + const [carDialogOpen, setCarDialogOpen] = useState(false); + const [assignmentDialogOpen, setAssignmentDialogOpen] = useState(false); + const [maintenanceHistory, setMaintenanceHistory] = useState(mockMaintenanceHistory); + const [rideHistory, setRideHistory] = useState(mockRideHistory); + + useEffect(() => { + const fetchCar = async () => { + setLoading(true); + try { + if (!params.carId || typeof params.carId !== 'string') { + toast.error("Invalid vehicle ID"); + return; + } + + const result = await getVehicleById(params.carId); + + if (result.success) { + setCar({ + ...result.data, + // Add additional mock details for the detail page + vin: "WDDUG8CB7LA456789", + fuelType: "Diesel", + mileage: 15000, + nextMaintenanceDue: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), // 90 days from now + insuranceExpiryDate: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(), // 180 days from now + registrationExpiryDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 365 days from now + notes: "Vehicle information fetched from database. Additional details are mock data.", + }); + } else { + toast.error(result.error || "Failed to load vehicle details"); + } + } catch (error) { + console.error("Error fetching car:", error); + toast.error("Failed to load vehicle details"); + } finally { + setLoading(false); + } + }; + + fetchCar(); + }, [params.carId]); + + // Handle car update + const handleCarUpdate = async (data: CarFormValues) => { + try { + if (!car || !car.id) { + toast.error("No vehicle to update"); + return; + } + + const result = await updateVehicle(car.id, data); + + if (result.success) { + // Update the car in the local state, preserving the mock data + setCar({ + ...result.data, + vin: car.vin, + fuelType: car.fuelType, + mileage: car.mileage, + nextMaintenanceDue: car.nextMaintenanceDue, + insuranceExpiryDate: car.insuranceExpiryDate, + registrationExpiryDate: car.registrationExpiryDate, + notes: car.notes, + }); + toast.success("Vehicle updated successfully"); + setCarDialogOpen(false); + } else { + toast.error(result.error || "Failed to update vehicle"); + } + } catch (error) { + console.error("Error updating car:", error); + toast.error("Failed to update vehicle"); + } + }; + + // Handle car deletion + const handleDeleteCar = async () => { + try { + if (!car || !car.id) { + toast.error("No vehicle to delete"); + return; + } + + const result = await deleteVehicle(car.id); + + if (result.success) { + toast.success("Vehicle deleted successfully"); + router.push("/cars"); + } else { + toast.error(result.error || "Failed to delete vehicle"); + } + } catch (error) { + console.error("Error deleting car:", error); + toast.error("Failed to delete vehicle"); + } + }; + + // Get status badge color + const getStatusColor = (status: string) => { + switch (status) { + case "AVAILABLE": return "bg-green-100 text-green-800"; + case "IN_USE": return "bg-blue-100 text-blue-800"; + case "MAINTENANCE": return "bg-yellow-100 text-yellow-800"; + case "OUT_OF_SERVICE": return "bg-red-100 text-red-800"; + default: return "bg-gray-100 text-gray-800"; + } + }; + + // Get vehicle type display name + const getVehicleTypeDisplayName = (type: string) => { + switch (type) { + case "SEDAN": return "Sedan"; + case "SUV": return "SUV"; + case "VAN": return "Van"; + case "LUXURY": return "Luxury"; + case "LIMOUSINE": return "Limousine"; + default: return type; + } + }; + + // Format date + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + if (loading) { + return ( + + + + +
+
+
+
+ +

Loading vehicle details...

+
+
+ +
+
+
+
+
+
+ ); + } + + if (!car) { + return ( + + + + +
+
+
+
+ +

Vehicle not found

+
+ + + +

The requested vehicle could not be found.

+ +
+
+
+
+
+
+
+ ); + } + + return ( + + + + +
+
+
+ {/* Header with back button */} +
+
+ +
+

{car.make} {car.model}

+

{car.licensePlate} • {car.year}

+
+
+
+ + + + + + + + router.push(`/cars/${car.id}/maintenance/new`)}> + Add Maintenance Record + + router.push(`/cars/${car.id}/documents`)}> + Manage Documents + + + Delete Vehicle + + + +
+
+ + {/* Current Assignment (if any) */} + {car.status === "IN_USE" && ( + + + + + Current Assignment + + + +
+
+

Assigned to Event: Corporate Meeting

+

April 15, 2025 - April 20, 2025

+

+ Hierarchy: Premier Event: VIP Conference 2025 > Event: Corporate Meeting +

+
+ +
+
+
+ )} + + {/* Car details */} +
+ {/* Main info card */} + + +
+ Vehicle Details + + {car.status.replace("_", " ")} + +
+ {getVehicleTypeDisplayName(car.vehicleType)} +
+ +
+
+
+

Basic Information

+ +
+
Brand:
+
{car.make}
+
Model:
+
{car.model}
+
Year:
+
{car.year}
+
Color:
+
{car.color}
+
Capacity:
+
{car.capacity} seats
+
VIN:
+
{car.vin}
+
+
+
+

Registration

+ +
+
License Plate:
+
{car.licensePlate}
+
French Plate:
+
{car.isFrenchPlate ? "Yes" : "No"}
+
Registration Expires:
+
{formatDate(car.registrationExpiryDate)}
+
Insurance Expires:
+
{formatDate(car.insuranceExpiryDate)}
+
+
+
+
+
+

Technical Information

+ +
+
Fuel Type:
+
{car.fuelType}
+
Current Mileage:
+
{car.mileage.toLocaleString()} km
+
+
+
+

Maintenance

+ +
+
Last Maintenance:
+
{formatDate(car.lastMaintenance)}
+
Next Maintenance Due:
+
{formatDate(car.nextMaintenanceDue)}
+
+
+
+

Notes

+ +

{car.notes || "No notes available."}

+
+
+
+
+
+ + {/* Sidebar info card */} + + + Vehicle Status + + +
+

Current Status

+
+ + {car.status.replace("_", " ")} + +
+
+ + + +
+

Upcoming Maintenance

+
+ +
+

Due: {formatDate(car.nextMaintenanceDue)}

+

Regular service

+
+
+
+ + + +
+

Documents

+
+ + +
+
+
+
+
+ + {/* Tabs for additional information */} + + + Maintenance History + Ride History + Documents + + + + +
+ Maintenance History + +
+ View all maintenance records for this vehicle +
+ + {maintenanceHistory.length === 0 ? ( +
+

No maintenance records found.

+
+ ) : ( +
+ {maintenanceHistory.map((record) => ( +
+
+

{formatDate(record.date)}

+

{record.type}

+
+
+

{record.description}

+

Mileage: {record.mileage.toLocaleString()} km

+
+
+

€{record.cost.toLocaleString()}

+
+
+ ))} +
+ )} +
+
+
+ + + + Ride History + Recent rides using this vehicle + + + {rideHistory.length === 0 ? ( +
+

No ride history found.

+
+ ) : ( +
+ {rideHistory.map((ride) => ( +
+
+

{new Date(ride.date).toLocaleDateString()}

+

{new Date(ride.date).toLocaleTimeString()}

+
+
+

{ride.client}

+

From: {ride.from}

+

To: {ride.to}

+
+
+

{ride.distance} km

+

{ride.duration} min

+
+
+ ))} +
+ )} +
+
+
+ + + +
+ Documents + +
+ Vehicle documents and certificates +
+ +
+ + + Registration Certificate + + +

Uploaded on {formatDate("2023-01-15T00:00:00Z")}

+

Expires on {formatDate(car.registrationExpiryDate)}

+
+ + + +
+ + + Insurance Policy + + +

Uploaded on {formatDate("2023-01-15T00:00:00Z")}

+

Expires on {formatDate(car.insuranceExpiryDate)}

+
+ + + +
+ + + Technical Inspection + + +

Uploaded on {formatDate("2023-02-20T00:00:00Z")}

+

Expires on {formatDate("2024-02-20T00:00:00Z")}

+
+ + + +
+
+
+
+
+
+
+
+
+
+ + +
+ ); +} diff --git a/app/cars/actions.ts b/app/cars/actions.ts new file mode 100644 index 00000000..2cc0944e --- /dev/null +++ b/app/cars/actions.ts @@ -0,0 +1,196 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +// Fetch all vehicles +export async function getVehicles() { + try { + const vehicles = await prisma.vehicle.findMany({ + orderBy: { + createdAt: "desc", + }, + }); + + return { success: true, data: vehicles }; + } catch (error) { + console.error("Error fetching vehicles:", error); + return { success: false, error: "Failed to fetch vehicles" }; + } +} + +// Fetch a single vehicle by ID +export async function getVehicleById(id: string) { + try { + const vehicle = await prisma.vehicle.findUnique({ + where: { + id, + }, + }); + + if (!vehicle) { + return { success: false, error: "Vehicle not found" }; + } + + return { success: true, data: vehicle }; + } catch (error) { + console.error("Error fetching vehicle:", error); + return { success: false, error: "Failed to fetch vehicle" }; + } +} + +// Create a new vehicle +export async function createVehicle(data: any) { + try { + const vehicle = await prisma.vehicle.create({ + data: { + make: data.make, + model: data.model, + year: data.year, + licensePlate: data.licensePlate, + color: data.color || "", + capacity: data.capacity, + vehicleType: data.vehicleType, + status: data.status, + isFrenchPlate: data.isFrenchPlate, + }, + }); + + revalidatePath("/cars"); + return { success: true, data: vehicle }; + } catch (error) { + console.error("Error creating vehicle:", error); + return { success: false, error: "Failed to create vehicle" }; + } +} + +// Update an existing vehicle +export async function updateVehicle(id: string, data: any) { + try { + const vehicle = await prisma.vehicle.update({ + where: { + id, + }, + data: { + make: data.make, + model: data.model, + year: data.year, + licensePlate: data.licensePlate, + color: data.color || "", + capacity: data.capacity, + vehicleType: data.vehicleType, + status: data.status, + isFrenchPlate: data.isFrenchPlate, + }, + }); + + revalidatePath("/cars"); + revalidatePath(`/cars/${id}`); + return { success: true, data: vehicle }; + } catch (error) { + console.error("Error updating vehicle:", error); + return { success: false, error: "Failed to update vehicle" }; + } +} + +// Delete a vehicle +export async function deleteVehicle(id: string) { + try { + await prisma.vehicle.delete({ + where: { + id, + }, + }); + + revalidatePath("/cars"); + return { success: true }; + } catch (error) { + console.error("Error deleting vehicle:", error); + return { success: false, error: "Failed to delete vehicle" }; + } +} + +// Assign a vehicle to an entity following the hierarchical order: +// Premier Event > Event > Mission > Ride > Chauffeur +export async function assignVehicle(data: { + vehicleId: string; + // The assignment type follows the hierarchical order (from highest to lowest): + // PREMIER_EVENT > EVENT > MISSION > RIDE > CHAUFFEUR + assignmentType: string; + entityId: string; + startDate?: Date; + endDate?: Date; + notes?: string; +}) { + try { + // Update the vehicle status to IN_USE + const vehicle = await prisma.vehicle.update({ + where: { + id: data.vehicleId, + }, + data: { + status: "IN_USE", + }, + }); + + // Create the appropriate assignment record based on the assignment type + if (data.assignmentType === "PREMIER_EVENT") { + // Highest level assignment - Premier Event + // This is a placeholder for future implementation + console.log("Premier Event assignment not implemented yet"); + + } else if (data.assignmentType === "EVENT") { + // Second level assignment - Event + // Check if the assignment already exists + const existingAssignment = await prisma.eventVehicle.findFirst({ + where: { + eventId: data.entityId, + vehicleId: data.vehicleId, + }, + }); + + if (existingAssignment) { + return { + success: false, + error: "This vehicle is already assigned to this event" + }; + } + + // Create the assignment + await prisma.eventVehicle.create({ + data: { + eventId: data.entityId, + vehicleId: data.vehicleId, + assignedAt: new Date(), + status: "ASSIGNED", + notes: data.notes, + }, + }); + + } else if (data.assignmentType === "MISSION") { + // Third level assignment - Mission + console.log("Mission assignment not implemented yet"); + + } else if (data.assignmentType === "RIDE") { + // Fourth level assignment - Ride + console.log("Ride assignment not implemented yet"); + + } else if (data.assignmentType === "CHAUFFEUR") { + // Fifth level assignment - Chauffeur + console.log("Chauffeur assignment not implemented yet"); + } + + revalidatePath("/cars"); + revalidatePath(`/cars/${data.vehicleId}`); + + // If the assignment is for an event, also revalidate the event page + if (data.assignmentType === "EVENT") { + revalidatePath(`/events/${data.entityId}`); + } + + return { success: true, data: vehicle }; + } catch (error) { + console.error("Error assigning vehicle:", error); + return { success: false, error: "Failed to assign vehicle" }; + } +} diff --git a/app/cars/actions/event-actions.ts b/app/cars/actions/event-actions.ts new file mode 100644 index 00000000..6e5a80e9 --- /dev/null +++ b/app/cars/actions/event-actions.ts @@ -0,0 +1,41 @@ +// This file contains client-side functions that call server actions + +import { getEventsAction, createEventVehicleAssignmentAction } from "@/app/api/actions/event-actions"; + +// Fetch all events +export async function getEvents(filter?: { status?: string }) { + try { + console.log('Client: Fetching events with filter:', filter); + + // Call the server action + const result = await getEventsAction(filter); + console.log('Client: Received events result:', result); + + return result; + } catch (error) { + console.error("Client: Error fetching events:", error); + return { success: false, error: "Failed to fetch events" }; + } +} + +// Create event vehicle assignment +export async function createEventVehicleAssignment(data: { + eventId: string; + vehicleId: string; + startDate?: Date; + endDate?: Date; + notes?: string; +}) { + try { + console.log('Client: Creating event vehicle assignment:', data); + + // Call the server action + const result = await createEventVehicleAssignmentAction(data); + console.log('Client: Event vehicle assignment result:', result); + + return result; + } catch (error) { + console.error("Client: Error creating event vehicle assignment:", error); + return { success: false, error: "Failed to assign vehicle to event" }; + } +} diff --git a/app/cars/components/car-dialog.tsx b/app/cars/components/car-dialog.tsx new file mode 100644 index 00000000..53afa6fb --- /dev/null +++ b/app/cars/components/car-dialog.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CarForm } from "./car-form"; +import { CarFormValues } from "../schemas/car-schema"; + +interface CarDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: CarFormValues) => Promise; + defaultValues?: Partial; +} + +export function CarDialog({ + open, + onOpenChange, + onSubmit, + defaultValues, +}: CarDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (data: CarFormValues) => { + try { + setIsSubmitting(true); + await onSubmit(data); + onOpenChange(false); + toast.success(defaultValues ? "Vehicle updated successfully" : "Vehicle created successfully"); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("Failed to save vehicle"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + {defaultValues ? "Edit Vehicle" : "Add New Vehicle"} + + + {defaultValues + ? "Edit the vehicle details below." + : "Fill in the details to add a new vehicle."} + + + onOpenChange(false)} + /> + + + ); +} diff --git a/app/cars/components/car-form.tsx b/app/cars/components/car-form.tsx new file mode 100644 index 00000000..93c1ebda --- /dev/null +++ b/app/cars/components/car-form.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { CalendarIcon, Loader2 } from "lucide-react"; +import { format } from "date-fns"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { carSchema, CarFormValues } from "../schemas/car-schema"; + +interface CarFormProps { + defaultValues?: Partial; + onSubmit: (data: CarFormValues) => void; + onCancel?: () => void; +} + +export function CarForm({ defaultValues, onSubmit, onCancel }: CarFormProps) { + const [isLoading, setIsLoading] = useState(false); + const [isCheckingPlate, setIsCheckingPlate] = useState(false); + + const form = useForm({ + resolver: zodResolver(carSchema), + defaultValues: { + make: defaultValues?.make || "", + model: defaultValues?.model || "", + year: defaultValues?.year || new Date().getFullYear(), + licensePlate: defaultValues?.licensePlate || "", + color: defaultValues?.color || "", + capacity: defaultValues?.capacity || 4, + vehicleType: defaultValues?.vehicleType || "SEDAN", + status: defaultValues?.status || "AVAILABLE", + lastMaintenance: defaultValues?.lastMaintenance || null, + isFrenchPlate: defaultValues?.isFrenchPlate !== false, + }, + }); + + const isFrenchPlate = form.watch("isFrenchPlate"); + const licensePlate = form.watch("licensePlate"); + + // Function to check if a license plate is in the French format (XX-123-XX) + const isFrenchPlateFormat = (plate: string) => { + const regex = /^[A-Z]{2}-\d{3}-[A-Z]{2}$/; + return regex.test(plate); + }; + + // Function to fetch vehicle data from the API + const fetchVehicleData = async (plate: string) => { + try { + setIsCheckingPlate(true); + const response = await fetch(`https://api-immat.vercel.app/getDataImmatriculation?plate=${plate}`); + + if (!response.ok) { + throw new Error("Failed to fetch vehicle data"); + } + + const data = await response.json(); + + if (data && data.success) { + // Update form with fetched data + form.setValue("make", data.marque || ""); + form.setValue("model", data.modele || ""); + form.setValue("year", parseInt(data.date_mise_circulation?.split("/")[2] || new Date().getFullYear().toString())); + + toast.success("Vehicle information retrieved successfully"); + } else { + toast.error("Could not find vehicle information"); + } + } catch (error) { + console.error("Error fetching vehicle data:", error); + toast.error("Failed to fetch vehicle data"); + } finally { + setIsCheckingPlate(false); + } + }; + + // Debounce function for license plate check + useEffect(() => { + if (isFrenchPlate && licensePlate && isFrenchPlateFormat(licensePlate)) { + const timer = setTimeout(() => { + fetchVehicleData(licensePlate); + }, 1000); + + return () => clearTimeout(timer); + } + }, [licensePlate, isFrenchPlate]); + + const handleSubmit = async (data: CarFormValues) => { + try { + setIsLoading(true); + await onSubmit(data); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("Failed to save vehicle"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+ ( + +
+ French License Plate + + Is this a French vehicle? + +
+ + + +
+ )} + /> +
+ +
+ ( + + License Plate * + +
+ + {isCheckingPlate && ( +
+ +
+ )} +
+
+ + {isFrenchPlate ? "Enter the license plate in format XX-123-XX" : "Enter the license plate"} + + +
+ )} + /> + + ( + + Vehicle Type * + + + + )} + /> +
+ +
+ ( + + Brand * + + + + + + )} + /> + + ( + + Model * + + + + + + )} + /> +
+ +
+ ( + + Year * + + field.onChange(parseInt(e.target.value) || new Date().getFullYear())} + /> + + + + )} + /> + + ( + + Color + + + + + + )} + /> + + ( + + Capacity * + + field.onChange(parseInt(e.target.value) || 4)} + /> + + + + )} + /> +
+ +
+ ( + + Status * + + + + )} + /> + + ( + + Last Maintenance + + + + + + + + + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> +
+ +
+ {onCancel && ( + + )} + +
+
+ + ); +} diff --git a/app/cars/components/plate-car-dialog.tsx b/app/cars/components/plate-car-dialog.tsx new file mode 100644 index 00000000..650811ab --- /dev/null +++ b/app/cars/components/plate-car-dialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { PlateCarForm } from "./plate-car-form"; + +interface PlateCarDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: any) => Promise; + defaultValues?: any; +} + +export function PlateCarDialog({ + open, + onOpenChange, + onSubmit, + defaultValues, +}: PlateCarDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (data: any) => { + try { + setIsSubmitting(true); + await onSubmit(data); + onOpenChange(false); + toast.success(defaultValues ? "Vehicle updated successfully" : "Vehicle created successfully"); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("Failed to save vehicle"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + {defaultValues ? "Edit Vehicle" : "Add New Vehicle"} + + + {defaultValues + ? "Edit the vehicle details below." + : "Enter the license plate to automatically retrieve vehicle information."} + + + onOpenChange(false)} + /> + + + ); +} diff --git a/app/cars/components/plate-car-form.tsx b/app/cars/components/plate-car-form.tsx new file mode 100644 index 00000000..793b3cff --- /dev/null +++ b/app/cars/components/plate-car-form.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import * as z from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +// Simple schema for the car form +const carSchema = z.object({ + make: z.string().min(1, "Brand is required"), + model: z.string().min(1, "Model is required"), + year: z.coerce.number().min(1900, "Year must be at least 1900").max(new Date().getFullYear() + 1, `Year must be at most ${new Date().getFullYear() + 1}`), + licensePlate: z.string().min(1, "License plate is required"), + color: z.string().optional(), + capacity: z.coerce.number().min(1, "Capacity must be at least 1").default(4), + vehicleType: z.enum(["SEDAN", "SUV", "VAN", "LUXURY", "LIMOUSINE"]).default("SEDAN"), + status: z.enum(["AVAILABLE", "IN_USE", "MAINTENANCE", "OUT_OF_SERVICE"]).default("AVAILABLE"), + isFrenchPlate: z.boolean().default(true), +}); + +type CarFormValues = z.infer; + +interface PlateCarFormProps { + defaultValues?: Partial; + onSubmit: (data: CarFormValues) => void; + onCancel?: () => void; +} + +export function PlateCarForm({ defaultValues, onSubmit, onCancel }: PlateCarFormProps) { + const [isLoading, setIsLoading] = useState(false); + const [isCheckingPlate, setIsCheckingPlate] = useState(false); + + const form = useForm({ + resolver: zodResolver(carSchema), + defaultValues: { + make: defaultValues?.make || "", + model: defaultValues?.model || "", + year: defaultValues?.year || new Date().getFullYear(), + licensePlate: defaultValues?.licensePlate || "", + color: defaultValues?.color || "", + capacity: defaultValues?.capacity || 4, + vehicleType: defaultValues?.vehicleType || "SEDAN", + status: defaultValues?.status || "AVAILABLE", + isFrenchPlate: defaultValues?.isFrenchPlate !== false, // Default to true unless explicitly set to false + }, + }); + + const licensePlate = form.watch("licensePlate"); + const isFrenchPlate = form.watch("isFrenchPlate"); + + // Function to check if a license plate is in the French format (XX-123-XX or XX123XX) + const isFrenchPlateFormat = (plate: string) => { + if (!plate) return false; + + // Convert to uppercase and remove any spaces + const cleanPlate = plate.toUpperCase().replace(/\s/g, ''); + + // Match format like DV-412-HL (2 letters, 3 digits, 2 letters with hyphens) + const regexWithHyphens = /^[A-Z]{2}-\d{3}-[A-Z]{2}$/; + // Match format like AB123CD (2 letters, 3 digits, 2 letters without hyphens) + const regexWithoutHyphens = /^[A-Z]{2}\d{3}[A-Z]{2}$/; + + return regexWithHyphens.test(cleanPlate) || regexWithoutHyphens.test(cleanPlate); + }; + + // Function to format license plate with hyphens if needed + const formatLicensePlate = (plate: string) => { + if (!plate) return ''; + + // Convert to uppercase and remove any spaces + const cleanPlate = plate.toUpperCase().replace(/\s/g, ''); + + // If the plate already has hyphens, return it as is + if (cleanPlate.includes('-')) { + return cleanPlate; + } + + // If the plate is in the format AB123CD, convert it to AB-123-CD + const regexWithoutHyphens = /^([A-Z]{2})(\d{3})([A-Z]{2})$/; + const match = cleanPlate.match(regexWithoutHyphens); + + if (match) { + return `${match[1]}-${match[2]}-${match[3]}`; + } + + // Return the original plate if it doesn't match the expected format + return cleanPlate; + }; + + // Function to fetch vehicle data from the API + const fetchVehicleData = async (plate: string) => { + try { + setIsCheckingPlate(true); + + // Format the license plate to ensure it has hyphens + const formattedPlate = formatLicensePlate(plate); + + // Log the API request URL for debugging + // The correct parameter is 'plaque' not 'plate' + const apiUrl = `https://api-immat.vercel.app/getDataImmatriculation?plaque=${formattedPlate}®ion=`; + console.log("API request URL:", apiUrl); + + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch vehicle data"); + } + + const data = await response.json(); + console.log("API response:", data); + + // Check if we have valid data in the response + if (data && (data.info || data.data)) { + console.log("Processing API response data:", data); + + // Update form with fetched data from the info object + if (data.info) { + form.setValue("make", data.info.marque || ""); + form.setValue("model", data.info.modele || ""); + + // Extract year from dateMiseEnCirculation (format: YYYY-MM-DD) + if (data.info.dateMiseEnCirculation) { + const dateParts = data.info.dateMiseEnCirculation.split('-'); + if (dateParts.length === 3) { + form.setValue("year", parseInt(dateParts[0])); + } + } + + // Set vehicle type based on energy type + if (data.info.energy) { + // Set luxury type for high-end brands + const luxuryBrands = ["MERCEDES", "BMW", "AUDI", "LEXUS", "INFINITI", "PORSCHE", "BENTLEY", "ROLLS-ROYCE", "FERRARI", "LAMBORGHINI", "MASERATI"]; + if (luxuryBrands.includes(data.info.marque.toUpperCase())) { + form.setValue("vehicleType", "LUXURY"); + } + } + } + + // Set additional data if available + if (data.data) { + // If info object didn't have the make/model, try to get it from data object + if (!form.getValues("make") && data.data.marque) { + form.setValue("make", data.data.marque || ""); + } + + if (!form.getValues("model") && data.data.modele) { + form.setValue("model", data.data.modele || ""); + } + + // Extract year from date1erCir_us (format: YYYY-MM-DD) if not already set + if (!form.getValues("year") && data.data.date1erCir_us) { + const dateParts = data.data.date1erCir_us.split('-'); + if (dateParts.length === 3) { + form.setValue("year", parseInt(dateParts[0])); + } + } + + // Set color if available (not in the example response) + if (data.data.couleur) { + form.setValue("color", data.data.couleur); + } + + // Set capacity based on vehicle type if available + const vehicleType = data.data.genreVCGNGC; + if (vehicleType === "VP") { // VP = Véhicule Particulier (passenger car) + if (form.getValues("vehicleType") !== "LUXURY") { + form.setValue("vehicleType", "SEDAN"); + } + form.setValue("capacity", 4); // Default for passenger cars + } else if (vehicleType === "CTTE") { // CTTE = Camionnette (van) + form.setValue("vehicleType", "VAN"); + form.setValue("capacity", 2); // Default for vans + } + } + + toast.success("Vehicle information retrieved successfully"); + } else { + toast.error("Could not find vehicle information"); + } + } catch (error) { + console.error("Error fetching vehicle data:", error); + toast.error("Failed to fetch vehicle data"); + } finally { + setIsCheckingPlate(false); + } + }; + + // Check if we're in edit mode (defaultValues has an id) + const isEditMode = !!defaultValues?.id; + + // Debounce function for license plate check + useEffect(() => { + // Skip API call if we're in edit mode - use the existing data from the database + if (isEditMode) { + return; + } + + // Only fetch data if the French plate toggle is on and the license plate is in the correct format + if (isFrenchPlate && licensePlate && isFrenchPlateFormat(licensePlate)) { + const timer = setTimeout(() => { + fetchVehicleData(licensePlate); + }, 1000); + + return () => clearTimeout(timer); + } + }, [licensePlate, isFrenchPlate, isEditMode]); + + const handleSubmit = async (data: CarFormValues) => { + try { + setIsLoading(true); + await onSubmit(data); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("Failed to save vehicle"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {isEditMode && ( +
+

+ Edit Mode: You are editing an existing vehicle. The data is loaded from the database. +

+
+ )} + +
+ ( + +
+ French License Plate + + Is this a French vehicle? Toggle on to auto-fetch vehicle information. + +
+ + + +
+ )} + /> +
+ +
+ ( + + License Plate * + +
+ { + // Convert to uppercase as the user types + field.onChange(e.target.value.toUpperCase()); + }} + /> + {isCheckingPlate && ( +
+ +
+ )} +
+
+ + {isEditMode + ? "License plate from database. Changing it will not trigger auto-fetch." + : isFrenchPlate + ? "Enter the license plate in format XX-123-XX (e.g., DV-412-HL) or without hyphens (e.g., DV412HL)" + : "Enter the license plate number"} + + +
+ )} + /> +
+ +
+ ( + + Brand * + + + + + + )} + /> + + ( + + Model * + + + + + + )} + /> +
+ +
+ ( + + Year * + + field.onChange(parseInt(e.target.value) || new Date().getFullYear())} + /> + + + + )} + /> + + ( + + Color + + + + + + )} + /> + + ( + + Capacity * + + field.onChange(parseInt(e.target.value) || 4)} + /> + + + + )} + /> +
+ +
+ ( + + Vehicle Type * + + + + )} + /> + + ( + + Status * + + + + )} + /> +
+ +
+ {onCancel && ( + + )} + +
+
+ + ); +} diff --git a/app/cars/components/simple-car-dialog.tsx b/app/cars/components/simple-car-dialog.tsx new file mode 100644 index 00000000..d5ff0bc9 --- /dev/null +++ b/app/cars/components/simple-car-dialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { SimpleCarForm } from "./simple-car-form"; + +interface SimpleCarDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: any) => Promise; + defaultValues?: any; +} + +export function SimpleCarDialog({ + open, + onOpenChange, + onSubmit, + defaultValues, +}: SimpleCarDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (data: any) => { + try { + setIsSubmitting(true); + await onSubmit(data); + onOpenChange(false); + toast.success(defaultValues ? "Vehicle updated successfully" : "Vehicle created successfully"); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("Failed to save vehicle"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + {defaultValues ? "Edit Vehicle" : "Add New Vehicle"} + + + {defaultValues + ? "Edit the vehicle details below." + : "Fill in the details to add a new vehicle."} + + + onOpenChange(false)} + /> + + + ); +} diff --git a/app/cars/components/simple-car-form.tsx b/app/cars/components/simple-car-form.tsx new file mode 100644 index 00000000..0fe1baee --- /dev/null +++ b/app/cars/components/simple-car-form.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import * as z from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +// Simple schema for the car form +const carSchema = z.object({ + make: z.string().min(1, "Brand is required"), + model: z.string().min(1, "Model is required"), + year: z.coerce.number().min(1900, "Year must be at least 1900").max(new Date().getFullYear() + 1, `Year must be at most ${new Date().getFullYear() + 1}`), + licensePlate: z.string().min(1, "License plate is required"), + color: z.string().optional(), + capacity: z.coerce.number().min(1, "Capacity must be at least 1").default(4), + vehicleType: z.enum(["SEDAN", "SUV", "VAN", "LUXURY", "LIMOUSINE"]).default("SEDAN"), + status: z.enum(["AVAILABLE", "IN_USE", "MAINTENANCE", "OUT_OF_SERVICE"]).default("AVAILABLE"), + isFrenchPlate: z.boolean().default(true), +}); + +type CarFormValues = z.infer; + +interface SimpleCarFormProps { + defaultValues?: Partial; + onSubmit: (data: CarFormValues) => void; + onCancel?: () => void; +} + +export function SimpleCarForm({ defaultValues, onSubmit, onCancel }: SimpleCarFormProps) { + const [isLoading, setIsLoading] = useState(false); + const [isCheckingPlate, setIsCheckingPlate] = useState(false); + + const form = useForm({ + resolver: zodResolver(carSchema), + defaultValues: { + make: defaultValues?.make || "", + model: defaultValues?.model || "", + year: defaultValues?.year || new Date().getFullYear(), + licensePlate: defaultValues?.licensePlate || "", + color: defaultValues?.color || "", + capacity: defaultValues?.capacity || 4, + vehicleType: defaultValues?.vehicleType || "SEDAN", + status: defaultValues?.status || "AVAILABLE", + isFrenchPlate: defaultValues?.isFrenchPlate !== false, + }, + }); + + const isFrenchPlate = form.watch("isFrenchPlate"); + const licensePlate = form.watch("licensePlate"); + + // Function to check if a license plate is in the French format (XX-123-XX) + const isFrenchPlateFormat = (plate: string) => { + const regex = /^[A-Z]{2}-\d{3}-[A-Z]{2}$/; + return regex.test(plate); + }; + + // Function to fetch vehicle data from the API + const fetchVehicleData = async (plate: string) => { + try { + setIsCheckingPlate(true); + const response = await fetch(`https://api-immat.vercel.app/getDataImmatriculation?plate=${plate}`); + + if (!response.ok) { + throw new Error("Failed to fetch vehicle data"); + } + + const data = await response.json(); + + if (data && data.success) { + // Update form with fetched data + form.setValue("make", data.marque || ""); + form.setValue("model", data.modele || ""); + form.setValue("year", parseInt(data.date_mise_circulation?.split("/")[2] || new Date().getFullYear().toString())); + + toast.success("Vehicle information retrieved successfully"); + } else { + toast.error("Could not find vehicle information"); + } + } catch (error) { + console.error("Error fetching vehicle data:", error); + toast.error("Failed to fetch vehicle data"); + } finally { + setIsCheckingPlate(false); + } + }; + + // Debounce function for license plate check + useEffect(() => { + if (isFrenchPlate && licensePlate && isFrenchPlateFormat(licensePlate)) { + const timer = setTimeout(() => { + fetchVehicleData(licensePlate); + }, 1000); + + return () => clearTimeout(timer); + } + }, [licensePlate, isFrenchPlate]); + + const handleSubmit = async (data: CarFormValues) => { + try { + setIsLoading(true); + await onSubmit(data); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("Failed to save vehicle"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+ ( + +
+ French License Plate + + Is this a French vehicle? + +
+ + + +
+ )} + /> +
+ +
+ ( + + License Plate * + +
+ + {isCheckingPlate && ( +
+ +
+ )} +
+
+ + {isFrenchPlate ? "Enter the license plate in format XX-123-XX" : "Enter the license plate"} + + +
+ )} + /> + + ( + + Vehicle Type * + + + + )} + /> +
+ +
+ ( + + Brand * + + + + + + )} + /> + + ( + + Model * + + + + + + )} + /> +
+ +
+ ( + + Year * + + field.onChange(parseInt(e.target.value) || new Date().getFullYear())} + /> + + + + )} + /> + + ( + + Color + + + + + + )} + /> + + ( + + Capacity * + + field.onChange(parseInt(e.target.value) || 4)} + /> + + + + )} + /> +
+ + ( + + Status * + + + + )} + /> + +
+ {onCancel && ( + + )} + +
+ + + ); +} diff --git a/app/cars/components/vehicle-assignment-dialog.tsx b/app/cars/components/vehicle-assignment-dialog.tsx new file mode 100644 index 00000000..d5217dd8 --- /dev/null +++ b/app/cars/components/vehicle-assignment-dialog.tsx @@ -0,0 +1,583 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { assignVehicle } from "../actions"; +import { getEvents, createEventVehicleAssignment } from "../actions/event-actions"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { CalendarIcon, CheckIcon, ChevronsUpDown, Loader2, PlusCircle } from "lucide-react"; +import { EventDialog } from "@/components/forms/event-form/event-dialog"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Calendar } from "@/components/ui/calendar"; +import { format } from "date-fns"; + +// Event type definition +type Event = { + id: string; + name: string; + clientName: string; + startDate: Date; + endDate: Date; + status: string; + location: string; +}; + +// Mission type definition +type Mission = { + id: string; + name: string; + eventId: string; +}; + +// Ride type definition +type Ride = { + id: string; + name: string; + missionId: string; +}; + +// Chauffeur type definition +type Chauffeur = { + id: string; + name: string; +}; + +// Mock data for types that aren't implemented yet +const mockPremierEvents = [ + { id: "pe1", name: "VIP Conference 2025" }, + { id: "pe2", name: "International Summit 2025" }, +]; + +const mockMissions = [ + { id: "m1", name: "Day 1 Transportation", eventId: "e1" }, + { id: "m2", name: "Day 2 Transportation", eventId: "e1" }, + { id: "m3", name: "VIP Transfers", eventId: "e2" }, +]; + +const mockRides = [ + { id: "r1", name: "Airport to Hotel", missionId: "m1" }, + { id: "r2", name: "Hotel to Conference", missionId: "m1" }, + { id: "r3", name: "Conference to Restaurant", missionId: "m2" }, +]; + +const mockChauffeurs = [ + { id: "c1", name: "John Smith" }, + { id: "c2", name: "Emma Johnson" }, + { id: "c3", name: "Michael Brown" }, +]; + +// Define the form schema +// The assignmentType follows the hierarchical order: Premier Event > Event > Mission > Ride > Chauffeur +const assignmentSchema = z.object({ + // Assignment type in hierarchical order (highest to lowest) + assignmentType: z.enum(["PREMIER_EVENT", "EVENT", "MISSION", "RIDE", "CHAUFFEUR"]), + entityId: z.string().min(1, "Please select an entity"), + startDate: z.date().optional(), + endDate: z.date().optional(), + notes: z.string().optional(), +}); + +type AssignmentFormValues = z.infer; + +interface VehicleAssignmentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + vehicleId: string; + vehicleName: string; +} + +export function VehicleAssignmentDialog({ + open, + onOpenChange, + vehicleId, + vehicleName, +}: VehicleAssignmentDialogProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [selectedType, setSelectedType] = useState("EVENT"); + const [entities, setEntities] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [openCombobox, setOpenCombobox] = useState(false); + const [isLoadingEntities, setIsLoadingEntities] = useState(false); + const [eventDialogOpen, setEventDialogOpen] = useState(false); + const [events, setEvents] = useState([]); + + // Initialize the form + const form = useForm({ + resolver: zodResolver(assignmentSchema), + defaultValues: { + assignmentType: "EVENT", + entityId: "", + notes: "", + }, + }); + + // Fetch events when the dialog opens + useEffect(() => { + if (open) { + fetchEvents(); + } + }, [open]); + + // Fetch events from the server + const fetchEvents = async () => { + try { + setIsLoadingEntities(true); + console.log('Calling getEvents() from component'); + const result = await getEvents(); + console.log('getEvents result:', result); + + if (result.success) { + setEvents(result.data); + setEntities(result.data); + console.log('Set entities to:', result.data); + } else { + console.error('Failed to fetch events:', result.error); + toast.error(result.error || "Failed to fetch events"); + setEntities([]); + } + } catch (error) { + console.error("Error fetching events:", error); + toast.error("Failed to fetch events"); + setEntities([]); + } finally { + setIsLoadingEntities(false); + } + }; + + // Handle assignment type change + const handleAssignmentTypeChange = (value: string) => { + form.setValue("assignmentType", value as any); + form.setValue("entityId", ""); + setSelectedType(value); + + // Load entities based on the selected type + switch (value) { + case "PREMIER_EVENT": + setEntities(mockPremierEvents); + break; + case "EVENT": + setIsLoadingEntities(true); + fetchEvents(); + break; + case "MISSION": + setEntities(mockMissions); + break; + case "RIDE": + setEntities(mockRides); + break; + case "CHAUFFEUR": + setEntities(mockChauffeurs); + break; + default: + setEntities([]); + } + }; + + // Handle event creation + const handleEventCreated = async (eventData: any) => { + // Close the event dialog + setEventDialogOpen(false); + + // Refresh the events list + await fetchEvents(); + + // Show success message + toast.success("Event created successfully"); + }; + + // Filter entities based on search term + const filteredEntities = entities.filter((entity) => + entity.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Handle form submission + const onSubmit = async (data: AssignmentFormValues) => { + try { + setIsLoading(true); + + // If the assignment type is EVENT, use the event-specific assignment function + if (data.assignmentType === "EVENT") { + const result = await createEventVehicleAssignment({ + eventId: data.entityId, + vehicleId, + startDate: data.startDate, + endDate: data.endDate, + notes: data.notes, + }); + + if (result.success) { + toast.success(`Vehicle assigned successfully to event`); + onOpenChange(false); + router.refresh(); + } else { + toast.error(result.error || "Failed to assign vehicle to event"); + } + } else { + // For other assignment types, use the generic function + const result = await assignVehicle({ + vehicleId, + assignmentType: data.assignmentType, + entityId: data.entityId, + startDate: data.startDate, + endDate: data.endDate, + notes: data.notes, + }); + + if (result.success) { + toast.success(`Vehicle assigned successfully to ${selectedType}`); + onOpenChange(false); + router.refresh(); + } else { + toast.error(result.error || "Failed to assign vehicle"); + } + } + } catch (error) { + console.error("Error assigning vehicle:", error); + toast.error("Failed to assign vehicle"); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Assign Vehicle + + Assign {vehicleName} to a Premier Event, Event, Mission, Ride, or Chauffeur. + +
+ +
+
+
+ + ( + + Assignment Type + + + Select the type of entity to assign this vehicle to. + + Hierarchy: Premier Event > Event > Mission > Ride > Chauffeur + + + + + )} + /> + + {selectedType && ( + ( + + Select {selectedType.replace("_", " ").toLowerCase()} + { + setOpenCombobox(open); + // If opening the dropdown and we have events, show them + if (open && selectedType === "EVENT" && entities.length === 0) { + fetchEvents(); + } + }}> + + + + + + + + + No {selectedType.replace("_", " ").toLowerCase()} found. + {isLoadingEntities ? ( +
+ +
+ ) : ( + <> + + {filteredEntities.map((entity) => ( + { + form.setValue("entityId", entity.id); + setOpenCombobox(false); + }} + > + + {entity.name} + {selectedType === "EVENT" && ( + + {entity.clientName} + + )} + + ))} + + + {selectedType === "EVENT" && ( + + { + setOpenCombobox(false); + setEventDialogOpen(true); + }} + className="text-blue-600 hover:text-blue-800 hover:bg-blue-50 font-medium" + > + + Create New Event + + + )} + + )} +
+
+
+ + Select the specific {selectedType.replace("_", " ").toLowerCase()} to assign this vehicle to. + + +
+ )} + /> + )} + +
+ ( + + Start Date + + + + + + + + + + + + When the assignment starts. + + + + )} + /> + + ( + + End Date + + + + + + + + { + const startDate = form.getValues("startDate"); + return startDate ? date < startDate : false; + }} + /> + + + + When the assignment ends. + + + + )} + /> +
+ + ( + + Notes + +