Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { Button } from "@/components/ui/button"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { ComboboxMulti } from './ComboboxMulti'; | |
| import { cn } from "@/lib/utils"; | |
| import { format } from "date-fns"; | |
| import { useForm } from 'react-hook-form'; | |
| import { zodResolver } from '@hookform/resolvers/zod'; | |
| import * as z from 'zod'; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { CalendarIcon, Clock, AlertCircle } from "lucide-react"; | |
| import { MeetingRoom, Employee, RoomBookingCreateRequest, RoomBooking } from '@/types'; | |
| import { userSessionService, roomBookingService } from '@/lib/api'; | |
| interface BookingFormProps { | |
| room?: MeetingRoom; | |
| employees: Employee[]; | |
| onSuccess?: () => void; | |
| onCancel?: () => void; | |
| defaultValues?: { | |
| startTime?: Date; | |
| endTime?: Date; | |
| }; | |
| existingBooking?: RoomBooking; | |
| isEditing?: boolean; | |
| } | |
| const timeOptions = Array.from({ length: 24 * 4 }, (_, i) => { | |
| const hour = Math.floor(i / 4); | |
| const minute = (i % 4) * 15; | |
| return { | |
| value: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`, | |
| label: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`, | |
| }; | |
| }); | |
| const bookingFormSchema = z.object({ | |
| title: z.string().min(3, { | |
| message: "Title must be at least 3 characters.", | |
| }), | |
| date: z.date({ | |
| required_error: "Please select a date.", | |
| }), | |
| startTime: z.string({ | |
| required_error: "Please select a start time.", | |
| }), | |
| endTime: z.string({ | |
| required_error: "Please select an end time.", | |
| }), | |
| attendees: z.array(z.number()).min(1, { | |
| message: "Please select at least one attendee.", | |
| }), | |
| description: z.string().optional(), | |
| // Status field is not editable by the user, it will be set to 'pending' automatically | |
| }).refine((data) => { | |
| // Convert times to numbers for comparison | |
| const start = data.startTime.replace(':', ''); | |
| const end = data.endTime.replace(':', ''); | |
| return start < end; | |
| }, { | |
| message: "End time must be after start time", | |
| path: ["endTime"], | |
| }); | |
| type FormValues = z.infer<typeof bookingFormSchema>; | |
| const BookingForm: React.FC<BookingFormProps> = ({ | |
| room, | |
| employees, | |
| onSuccess, | |
| onCancel, | |
| defaultValues, | |
| existingBooking, | |
| isEditing | |
| }) => { | |
| const navigate = useNavigate(); | |
| const currentUser = userSessionService.getSession(); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const form = useForm<FormValues>({ | |
| resolver: zodResolver(bookingFormSchema), | |
| defaultValues: { | |
| title: existingBooking?.title || '', | |
| date: existingBooking?.startTime ? new Date(existingBooking.startTime) : defaultValues?.startTime || new Date(), | |
| startTime: existingBooking?.startTime | |
| ? format(new Date(existingBooking.startTime), 'HH:mm') | |
| : defaultValues?.startTime | |
| ? format(defaultValues.startTime, 'HH:mm') | |
| : format(new Date(), 'HH:mm'), | |
| endTime: existingBooking?.endTime | |
| ? format(new Date(existingBooking.endTime), 'HH:mm') | |
| : defaultValues?.endTime | |
| ? format(defaultValues.endTime, 'HH:mm') | |
| : format(new Date(new Date().getTime() + 60 * 60 * 1000), 'HH:mm'), | |
| attendees: existingBooking?.attendees || currentUser ? [...(existingBooking?.attendees || []), currentUser?.employeeId] : [], | |
| description: existingBooking?.description || '', | |
| }, | |
| }); | |
| // Filter out the current user from attendees options and organize by name | |
| const attendeeOptions = React.useMemo(() => { | |
| // Make sure employees is an array | |
| const safeEmployees = Array.isArray(employees) ? employees : []; | |
| const attendees = safeEmployees | |
| .filter(emp => emp.id !== currentUser?.employeeId) | |
| .map(emp => ({ | |
| value: emp.id, | |
| label: `${emp.firstName} ${emp.lastName}`, | |
| })); | |
| // Sort alphabetically by label | |
| return attendees.sort((a, b) => a.label.localeCompare(b.label)); | |
| }, [employees, currentUser]); | |
| const onSubmit = async (data: FormValues) => { | |
| if (!room || !currentUser) { | |
| console.error('Missing room or user data:', { room, currentUser }); | |
| return; | |
| } | |
| setIsSubmitting(true); | |
| console.log('Submitting booking form with data:', data); | |
| try { | |
| // Combine date and time | |
| const startDate = new Date(data.date); | |
| const [startHours, startMinutes] = data.startTime.split(':').map(Number); | |
| startDate.setHours(startHours, startMinutes, 0, 0); | |
| const endDate = new Date(data.date); | |
| const [endHours, endMinutes] = data.endTime.split(':').map(Number); | |
| endDate.setHours(endHours, endMinutes, 0, 0); | |
| // Ensure attendees array is valid | |
| const attendees = Array.isArray(data.attendees) ? data.attendees : []; | |
| // Ensure current user is included | |
| if (!attendees.includes(currentUser.employeeId)) { | |
| attendees.push(currentUser.employeeId); | |
| } | |
| if (isEditing && existingBooking) { | |
| // Update existing booking | |
| const bookingData = { | |
| id: existingBooking.id, | |
| roomId: room.id, | |
| roomName: room.name, | |
| title: data.title, | |
| startTime: startDate.toISOString(), | |
| endTime: endDate.toISOString(), | |
| attendees: attendees, | |
| description: data.description || "", | |
| // Maintain the current status unless admin is changing it | |
| status: existingBooking.status, | |
| employeeId: currentUser.employeeId, | |
| employeeName: `${currentUser.firstName} ${currentUser.lastName}` | |
| }; | |
| console.log('Updating booking data:', bookingData); | |
| await roomBookingService.updateBooking(bookingData); | |
| console.log('Booking updated successfully'); | |
| alert(`Room "${room.name}" booking updated successfully!`); | |
| } else { | |
| // Create new booking | |
| const bookingData: RoomBookingCreateRequest = { | |
| roomId: room.id, | |
| roomName: room.name, | |
| employeeId: currentUser.employeeId, | |
| employeeName: `${currentUser.firstName} ${currentUser.lastName}`, | |
| title: data.title, | |
| startTime: startDate.toISOString(), | |
| endTime: endDate.toISOString(), | |
| attendees: attendees, | |
| description: data.description || "", | |
| status: 'pending', | |
| createdAt: new Date().toISOString(), | |
| updatedAt: new Date().toISOString(), | |
| }; | |
| console.log('Creating new booking with data:', bookingData); | |
| await roomBookingService.createBooking(bookingData); | |
| console.log('Booking created successfully'); | |
| alert(`Room "${room.name}" booked successfully!\n\nStatus: Pending Approval\n\nYour booking will be reviewed by an administrator.`); | |
| } | |
| // Use a timeout to ensure the alert has time to be displayed before navigation | |
| setTimeout(() => { | |
| if (onSuccess) { | |
| onSuccess(); | |
| } else { | |
| // Use relative navigation with hash to ensure page refresh | |
| const baseUrl = window.location.origin; | |
| const bookingsPath = "/meeting-room/bookings"; | |
| // Create a timestamp to force a page refresh | |
| const timestamp = new Date().getTime(); | |
| const fullUrl = `${baseUrl}${bookingsPath}#${timestamp}`; | |
| // Use full URL to navigate | |
| window.location.href = fullUrl; | |
| } | |
| }, 500); // Increased timeout to give more time for alert to be seen | |
| } catch (error) { | |
| console.error('Failed to process booking:', error); | |
| alert(`There was an error ${isEditing ? 'updating' : 'creating'} the booking. Please try again.`); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| return ( | |
| <Card className="w-full max-w-3xl mx-auto"> | |
| <CardHeader> | |
| <CardTitle>{isEditing ? 'Edit Booking' : 'Book a Meeting Room'}</CardTitle> | |
| <CardDescription> | |
| {room ? ( | |
| <div className="flex flex-col gap-1 mt-1"> | |
| <div>Room: <span className="font-medium">{room.name}</span> (Capacity: {room.capacity})</div> | |
| {!isEditing && ( | |
| <div className="flex items-center gap-2"> | |
| <span>Booking Status:</span> | |
| <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200"> | |
| <AlertCircle className="mr-1 h-3 w-3" /> Pending Approval | |
| </Badge> | |
| </div> | |
| )} | |
| {isEditing && existingBooking && ( | |
| <div className="flex items-center gap-2"> | |
| <span>Current Status:</span> | |
| <Badge | |
| variant={ | |
| existingBooking.status === 'approved' ? 'default' : | |
| existingBooking.status === 'rejected' ? 'destructive' : | |
| existingBooking.status === 'cancelled' ? 'outline' : | |
| 'secondary' | |
| } | |
| className="capitalize" | |
| > | |
| {existingBooking.status || 'pending'} | |
| </Badge> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| 'Complete the form to book a meeting room' | |
| )} | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <Form {...form}> | |
| <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> | |
| <FormField | |
| control={form.control} | |
| name="title" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Meeting Title</FormLabel> | |
| <FormControl> | |
| <Input placeholder="Team standup" {...field} /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <FormField | |
| control={form.control} | |
| name="date" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-col"> | |
| <FormLabel>Date</FormLabel> | |
| <Popover> | |
| <PopoverTrigger asChild> | |
| <FormControl> | |
| <Button | |
| variant="outline" | |
| className={cn( | |
| "w-full pl-3 text-left font-normal", | |
| !field.value && "text-muted-foreground" | |
| )} | |
| > | |
| {field.value ? ( | |
| format(field.value, "PPP") | |
| ) : ( | |
| <span>Pick a date</span> | |
| )} | |
| <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> | |
| </Button> | |
| </FormControl> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-auto p-0" align="start"> | |
| <Calendar | |
| mode="single" | |
| selected={field.value} | |
| onSelect={field.onChange} | |
| disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} | |
| initialFocus | |
| /> | |
| </PopoverContent> | |
| </Popover> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <FormField | |
| control={form.control} | |
| name="startTime" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Start Time</FormLabel> | |
| <FormControl> | |
| <div className="flex items-center"> | |
| <Clock className="mr-2 h-4 w-4 text-muted-foreground" /> | |
| <select | |
| className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" | |
| value={field.value} | |
| onChange={field.onChange} | |
| > | |
| {timeOptions.map((option) => ( | |
| <option key={option.value} value={option.value}> | |
| {option.label} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="endTime" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>End Time</FormLabel> | |
| <FormControl> | |
| <div className="flex items-center"> | |
| <Clock className="mr-2 h-4 w-4 text-muted-foreground" /> | |
| <select | |
| className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" | |
| value={field.value} | |
| onChange={field.onChange} | |
| > | |
| {timeOptions.map((option) => ( | |
| <option key={option.value} value={option.value}> | |
| {option.label} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| </div> | |
| <FormField | |
| control={form.control} | |
| name="attendees" | |
| render={({ field }) => { | |
| // Make sure we have a valid array for the field value | |
| const fieldValue = Array.isArray(field.value) ? field.value : []; | |
| // Check if we have any attendee options | |
| const hasOptions = Array.isArray(attendeeOptions) && attendeeOptions.length > 0; | |
| // Get current user name for display | |
| const currentUserName = currentUser ? | |
| `${currentUser.firstName} ${currentUser.lastName}` : | |
| 'You'; | |
| return ( | |
| <FormItem> | |
| <FormLabel>Meeting Attendees</FormLabel> | |
| <FormControl> | |
| <ComboboxMulti | |
| options={hasOptions ? attendeeOptions : []} | |
| selectedValues={fieldValue} | |
| onValuesChange={(values) => { | |
| try { | |
| // Ensure values is an array | |
| const safeValues = Array.isArray(values) ? values : []; | |
| // Ensure current user is always included | |
| if (currentUser && !safeValues.includes(currentUser.employeeId)) { | |
| const updatedValues = [currentUser.employeeId, ...safeValues]; | |
| field.onChange(updatedValues); | |
| } else { | |
| field.onChange(safeValues); | |
| } | |
| } catch (error) { | |
| console.error("Error updating attendees:", error); | |
| // Fallback to empty array if there's an error | |
| field.onChange(currentUser ? [currentUser.employeeId] : []); | |
| } | |
| }} | |
| placeholder={hasOptions ? "Select meeting attendees..." : "No other attendees available"} | |
| emptyText={hasOptions ? "No matching employees found" : "No other employees available"} | |
| label="Attendees" | |
| /> | |
| </FormControl> | |
| <FormDescription> | |
| {currentUserName} will be automatically added as an attendee. {hasOptions ? 'Search for additional attendees by name.' : ''} | |
| </FormDescription> | |
| <FormMessage /> | |
| </FormItem> | |
| ); | |
| }} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="description" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Description (Optional)</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder="Meeting agenda, preparation details, etc." | |
| className="resize-none" | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <CardFooter className="flex justify-between px-0 pb-0"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={onCancel} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| type="submit" | |
| disabled={isSubmitting} | |
| > | |
| {isSubmitting | |
| ? (isEditing ? "Updating..." : "Booking...") | |
| : (isEditing ? "Update Booking" : "Book Room") | |
| } | |
| </Button> | |
| </CardFooter> | |
| </form> | |
| </Form> | |
| </CardContent> | |
| </Card> | |
| ); | |
| }; | |
| export default BookingForm; |