diff --git a/apps/web/src/features/operations/schedule/Schedule.tsx b/apps/web/src/features/operations/schedule/Schedule.tsx new file mode 100644 index 00000000..0deb571b --- /dev/null +++ b/apps/web/src/features/operations/schedule/Schedule.tsx @@ -0,0 +1,487 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { + addDays, + addWeeks, + addMonths, + format, + isSameDay, + isToday, + isValid, + parseISO, + startOfWeek, + startOfMonth, + endOfMonth, + endOfWeek, + eachDayOfInterval, + subWeeks, + subMonths, + subDays +} from "date-fns"; +import { + Calendar as CalendarIcon, + ChevronLeft, + ChevronRight, + Clock, + DollarSign, + Plus, + CalendarDays, + Users, + AlertTriangle +} from "lucide-react"; +import { useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { Badge } from "@/common/components/ui/badge"; +import { Button } from "@/common/components/ui/button"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription +} from "@/common/components/ui/dialog"; +import { Alert, AlertDescription, AlertTitle } from "@/common/components/ui/alert"; +import { Tabs, TabsList, TabsTrigger } from "@/common/components/ui/tabs"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { useListOrders, useUpdateOrder } from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import { OrderStatus } from "@/dataconnect-generated"; +import { useToast } from "@/common/components/ui/use-toast"; + +/** + * Maps OrderStatus to appropriate Tailwind color classes + */ +const getStatusColor = (status: OrderStatus | undefined) => { + switch (status) { + case OrderStatus.DRAFT: + case OrderStatus.CANCELLED: + return "bg-slate-100 text-slate-600 border-slate-200"; + case OrderStatus.FILLED: + case OrderStatus.FULLY_STAFFED: + case OrderStatus.COMPLETED: + return "bg-emerald-50 text-emerald-700 border-emerald-200"; + case OrderStatus.POSTED: + case OrderStatus.PENDING: + case OrderStatus.PARTIAL_STAFFED: + return "bg-blue-50 text-blue-700 border-blue-200"; + default: + return "bg-slate-50 text-slate-600 border-slate-200"; + } +}; + +/** + * Safely parses various date formats into a Date object + */ +const safeParseDate = (dateString: any) => { + if (!dateString) return null; + try { + const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString); + return isValid(date) ? date : null; + } catch { + return null; + } +}; + +export default function Schedule() { + const navigate = useNavigate(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [currentDate, setCurrentDate] = useState(new Date()); + const [viewMode, setViewMode] = useState<'day' | 'week' | 'month'>('week'); + const [selectedOrder, setSelectedOrder] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + // State for rescheduling confirmation + const [rescheduleData, setRescheduleData] = useState<{ order: any; newDate: Date } | null>(null); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + + // Fetch real data from Data Connect + const { data, isLoading } = useListOrders(dataConnect); + const orders = data?.orders || []; + + // Mutation for drag-and-drop rescheduling + const updateOrderMutation = useUpdateOrder(dataConnect, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listOrders"] }); + toast({ + title: "Order Rescheduled", + description: "The order date has been updated successfully.", + }); + setIsConfirmOpen(false); + setRescheduleData(null); + }, + onError: () => { + toast({ + title: "Update Failed", + description: "Could not reschedule the order. Please try again.", + variant: "destructive", + }); + } + }); + + // Calculate days to display based on current view mode + const calendarDays = useMemo(() => { + if (viewMode === 'month') { + const start = startOfWeek(startOfMonth(currentDate)); + const end = endOfWeek(endOfMonth(currentDate)); + return eachDayOfInterval({ start, end }); + } else if (viewMode === 'week') { + const start = startOfWeek(currentDate); + const end = endOfWeek(currentDate); + return eachDayOfInterval({ start, end }); + } else { + return [currentDate]; + } + }, [currentDate, viewMode]); + + const getOrdersForDay = (date: Date) => { + return orders.filter((order) => { + const orderDate = safeParseDate(order.date); + return orderDate && isSameDay(orderDate, date); + }); + }; + + // Calculate metrics for the current visible range + const metrics = useMemo(() => { + const visibleOrders = orders.filter(order => { + const orderDate = safeParseDate(order.date); + if (!orderDate) return false; + return orderDate >= calendarDays[0] && orderDate <= calendarDays[calendarDays.length - 1]; + }); + + const totalHours = visibleOrders.reduce((sum, order) => { + const shifts = Array.isArray(order.shifts) ? order.shifts : []; + const orderHours = shifts.reduce((shiftSum: number, shift: any) => { + const roles = Array.isArray(shift.roles) ? shift.roles : []; + return shiftSum + roles.reduce((roleSum: number, role: any) => roleSum + (Number(role.hours) || 0), 0); + }, 0); + return sum + orderHours; + }, 0); + + const totalCost = visibleOrders.reduce((sum, order) => sum + (order.total || 0), 0); + const totalShifts = visibleOrders.reduce((sum, order) => sum + (Array.isArray(order.shifts) ? order.shifts.length : 0), 0); + + return { totalHours, totalCost, totalShifts }; + }, [orders, calendarDays]); + + // Navigation handlers + const handlePrev = () => { + if (viewMode === 'month') setCurrentDate(subMonths(currentDate, 1)); + else if (viewMode === 'week') setCurrentDate(subWeeks(currentDate, 1)); + else setCurrentDate(subDays(currentDate, 1)); + }; + + const handleNext = () => { + if (viewMode === 'month') setCurrentDate(addMonths(currentDate, 1)); + else if (viewMode === 'week') setCurrentDate(addWeeks(currentDate, 1)); + else setCurrentDate(addDays(currentDate, 1)); + }; + + const handleToday = () => setCurrentDate(new Date()); + + const handleEventClick = (order: any, e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedOrder(order); + setIsModalOpen(true); + }; + + // Drag and Drop Logic + const onDragStart = (e: React.DragEvent, order: any) => { + e.dataTransfer.setData("orderId", order.id); + }; + + const onDrop = async (e: React.DragEvent, date: Date) => { + e.preventDefault(); + const orderId = e.dataTransfer.getData("orderId"); + const order = orders.find(o => o.id === orderId); + + if (order && !isSameDay(safeParseDate(order.date)!, date)) { + setRescheduleData({ order, newDate: date }); + setIsConfirmOpen(true); + } + }; + + const confirmReschedule = () => { + if (rescheduleData) { + updateOrderMutation.mutate({ + id: rescheduleData.order.id, + date: rescheduleData.newDate.toISOString() + }); + } + }; + + if (isLoading) { + return ( + +
+
+
+
+ ); + } + + return ( + navigate('/orders/new')}> + + New Order + + } + > +
+ {/* Metrics Section */} +
+ + +
+

Total Hours

+

{metrics.totalHours.toFixed(1)}

+
+
+ +
+
+
+ + +
+

Labor Cost

+

${metrics.totalCost.toLocaleString()}

+
+
+ +
+
+
+ + +
+

Total Shifts

+

{metrics.totalShifts}

+
+
+ +
+
+
+
+ + {/* Calendar Controls */} +
+
+ + + +

+ {format(currentDate, viewMode === 'month' ? 'MMMM yyyy' : 'MMMM d, yyyy')} +

+
+ + setViewMode(v as any)} className="w-full md:w-auto"> + + Day + Week + Month + + +
+ + {/* Calendar Grid */} +
+ {/* Day Names */} + {(viewMode === 'month' || (viewMode === 'week' && window.innerWidth > 768)) && + ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => ( +
+ {day} +
+ )) + } + + {calendarDays.map((day) => { + const dayOrders = getOrdersForDay(day); + const isTodayDay = isToday(day); + const isDifferentMonth = viewMode === 'month' && day.getMonth() !== currentDate.getMonth(); + + return ( +
e.preventDefault()} + onDrop={(e) => onDrop(e, day)} + className={`min-h-[140px] flex flex-col p-2 rounded-xl border transition-all ${ + isTodayDay ? 'bg-primary/5 border-primary ring-1 ring-primary/20' : + isDifferentMonth ? 'bg-slate-50/50 border-border/30 opacity-60' : + 'bg-white border-border/50 hover:border-border hover:shadow-sm' + } ${viewMode === 'day' ? 'min-h-[500px]' : ''}`} + > +
+ + {format(day, viewMode === 'day' ? 'EEEE, MMMM d' : 'd')} + + {dayOrders.length > 0 && ( + + {dayOrders.length} + + )} +
+ +
+ {dayOrders.map((order: any) => ( +
onDragStart(e, order)} + onClick={(e) => handleEventClick(order, e)} + className={`p-2 rounded-lg border text-left cursor-pointer transition-all hover:scale-[1.02] active:scale-95 shadow-sm ${getStatusColor(order.status)}`} + > +

+ {order.eventName || "Unnamed Order"} +

+
+ + + {order.shifts?.[0]?.roles?.[0]?.start_time || "00:00"} + +
+
+ ))} + + {dayOrders.length === 0 && viewMode !== 'month' && ( +
+

No Orders

+
+ )} +
+
+ ); + })} +
+
+ + {/* Order Detail Modal */} + + + +
+ {selectedOrder?.eventName || "Order Details"} + + {selectedOrder?.status} + +
+
+ + {selectedOrder && ( +
+
+
+

Date

+
+ + {format(safeParseDate(selectedOrder.date)!, 'PPPP')} +
+
+
+

Total Value

+
+ + ${selectedOrder.total?.toLocaleString() || '0.00'} +
+
+
+ +
+

Business

+
+ + {selectedOrder.business?.businessName || "Krow Workforce"} +
+
+ +
+

Shifts & Staffing

+
+ {Array.isArray(selectedOrder.shifts) && selectedOrder.shifts.map((shift: any, i: number) => ( +
+
+
+ {i + 1} +
+
+

{shift.shiftName || `Shift ${i+1}`}

+

+ {shift.roles?.[0]?.start_time} - {shift.roles?.[0]?.end_time} +

+
+
+ + {Array.isArray(shift.assignedStaff) ? shift.assignedStaff.length : 0} Assigned + +
+ ))} +
+
+
+ )} + + + + + +
+
+ + {/* Reschedule Confirmation Dialog */} + + + + Confirm Reschedule + + Are you sure you want to move this order to a different date? + + + + {rescheduleData && ( +
+ + + Confirm Date Change + + Moving {rescheduleData.order.eventName} to {format(rescheduleData.newDate, 'PPPP')}. + + +
+ )} + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 4bc38849..9db84937 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -21,6 +21,7 @@ import OrderDetail from './features/operations/orders/OrderDetail'; import ClientOrderList from './features/operations/orders/ClientOrderList'; import VendorOrderList from './features/operations/orders/VendorOrderList'; import EditOrder from './features/operations/orders/EditOrder'; +import Schedule from './features/operations/schedule/Schedule'; /** * AppRoutes Component @@ -101,6 +102,10 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + + } /> + + } />