feat: Implement calendar view to manage orders and events

This commit is contained in:
dhinesh-m24
2026-02-09 12:55:44 +05:30
parent 444c1234c0
commit 651700348d
2 changed files with 492 additions and 0 deletions

View File

@@ -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<any>(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 (
<DashboardLayout title="Schedule" subtitle="Loading shifts...">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Schedule"
subtitle="Plan and manage staff shifts"
actions={
<Button onClick={() => navigate('/orders/new')}>
<Plus className="w-4 h-4 mr-2" />
New Order
</Button>
}
>
<div className="space-y-6">
{/* Metrics Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-white border-border/50 shadow-sm">
<CardContent className="p-6 flex items-center justify-between">
<div>
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Total Hours</p>
<p className="text-2xl font-bold text-primary-text">{metrics.totalHours.toFixed(1)}</p>
</div>
<div className="p-3 bg-blue-50 rounded-xl text-blue-600">
<Clock className="w-5 h-5" />
</div>
</CardContent>
</Card>
<Card className="bg-white border-border/50 shadow-sm">
<CardContent className="p-6 flex items-center justify-between">
<div>
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Labor Cost</p>
<p className="text-2xl font-bold text-primary-text">${metrics.totalCost.toLocaleString()}</p>
</div>
<div className="p-3 bg-emerald-50 rounded-xl text-emerald-600">
<DollarSign className="w-5 h-5" />
</div>
</CardContent>
</Card>
<Card className="bg-white border-border/50 shadow-sm">
<CardContent className="p-6 flex items-center justify-between">
<div>
<p className="text-secondary-text text-xs font-bold uppercase tracking-wider mb-1">Total Shifts</p>
<p className="text-2xl font-bold text-primary-text">{metrics.totalShifts}</p>
</div>
<div className="p-3 bg-teal-50 rounded-xl text-teal-600">
<CalendarIcon className="w-5 h-5" />
</div>
</CardContent>
</Card>
</div>
{/* Calendar Controls */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-4 rounded-xl border border-border/50 shadow-sm">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={handlePrev} className="h-9 w-9">
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" onClick={handleToday} className="h-9 px-4 text-xs font-bold uppercase tracking-wider">
Today
</Button>
<Button variant="outline" size="icon" onClick={handleNext} className="h-9 w-9">
<ChevronRight className="w-4 h-4" />
</Button>
<h2 className="ml-4 text-lg font-bold text-primary-text">
{format(currentDate, viewMode === 'month' ? 'MMMM yyyy' : 'MMMM d, yyyy')}
</h2>
</div>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)} className="w-full md:w-auto">
<TabsList className="grid grid-cols-3 w-full md:w-[240px]">
<TabsTrigger value="day" className="text-xs font-bold uppercase tracking-wider">Day</TabsTrigger>
<TabsTrigger value="week" className="text-xs font-bold uppercase tracking-wider">Week</TabsTrigger>
<TabsTrigger value="month" className="text-xs font-bold uppercase tracking-wider">Month</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Calendar Grid */}
<div className={`grid gap-4 ${
viewMode === 'month' ? 'grid-cols-7' :
viewMode === 'week' ? 'grid-cols-1 md:grid-cols-7' :
'grid-cols-1'
}`}>
{/* Day Names */}
{(viewMode === 'month' || (viewMode === 'week' && window.innerWidth > 768)) &&
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="text-center py-2 text-[10px] font-bold uppercase tracking-widest text-secondary-text">
{day}
</div>
))
}
{calendarDays.map((day) => {
const dayOrders = getOrdersForDay(day);
const isTodayDay = isToday(day);
const isDifferentMonth = viewMode === 'month' && day.getMonth() !== currentDate.getMonth();
return (
<div
key={day.toISOString()}
onDragOver={(e) => 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]' : ''}`}
>
<div className="flex items-center justify-between mb-2 px-1">
<span className={`text-sm font-bold ${isTodayDay ? 'text-primary' : 'text-primary-text'}`}>
{format(day, viewMode === 'day' ? 'EEEE, MMMM d' : 'd')}
</span>
{dayOrders.length > 0 && (
<span className="text-[10px] font-bold text-secondary-text bg-slate-100 px-1.5 py-0.5 rounded">
{dayOrders.length}
</span>
)}
</div>
<div className="flex-1 space-y-1 overflow-y-auto max-h-[300px] scrollbar-hide">
{dayOrders.map((order: any) => (
<div
key={order.id}
draggable
onDragStart={(e) => 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)}`}
>
<p className="text-[11px] font-bold truncate leading-tight mb-1">
{order.eventName || "Unnamed Order"}
</p>
<div className="flex items-center gap-1 opacity-80">
<Clock className="w-3 h-3" />
<span className="text-[9px] font-medium">
{order.shifts?.[0]?.roles?.[0]?.start_time || "00:00"}
</span>
</div>
</div>
))}
{dayOrders.length === 0 && viewMode !== 'month' && (
<div className="flex-1 flex items-center justify-center opacity-30 border border-dashed rounded-lg py-8">
<p className="text-[10px] font-bold uppercase tracking-wider">No Orders</p>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Order Detail Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle className="text-xl font-bold">{selectedOrder?.eventName || "Order Details"}</DialogTitle>
<Badge className={getStatusColor(selectedOrder?.status)}>
{selectedOrder?.status}
</Badge>
</div>
</DialogHeader>
{selectedOrder && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Date</p>
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">{format(safeParseDate(selectedOrder.date)!, 'PPPP')}</span>
</div>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Total Value</p>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-emerald-600" />
<span className="text-sm font-bold text-emerald-700">${selectedOrder.total?.toLocaleString() || '0.00'}</span>
</div>
</div>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Business</p>
<div className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg border border-slate-100">
<Users className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium">{selectedOrder.business?.businessName || "Krow Workforce"}</span>
</div>
</div>
<div className="space-y-3">
<p className="text-[10px] font-bold text-secondary-text uppercase tracking-wider">Shifts & Staffing</p>
<div className="space-y-2">
{Array.isArray(selectedOrder.shifts) && selectedOrder.shifts.map((shift: any, i: number) => (
<div key={i} className="p-3 rounded-lg border border-border/60 bg-white shadow-sm flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-xs font-bold">
{i + 1}
</div>
<div>
<p className="text-xs font-bold text-primary-text">{shift.shiftName || `Shift ${i+1}`}</p>
<p className="text-[10px] text-secondary-text font-medium">
{shift.roles?.[0]?.start_time} - {shift.roles?.[0]?.end_time}
</p>
</div>
</div>
<Badge variant="outline" className="text-[9px] font-bold uppercase tracking-wider">
{Array.isArray(shift.assignedStaff) ? shift.assignedStaff.length : 0} Assigned
</Badge>
</div>
))}
</div>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => navigate(`/orders/${selectedOrder?.id}`)} className="w-full sm:w-auto">
View Full Details
</Button>
<Button onClick={() => navigate(`/orders/${selectedOrder?.id}/edit`)} className="w-full sm:w-auto">
Edit Order
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reschedule Confirmation Dialog */}
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Confirm Reschedule</DialogTitle>
<DialogDescription>
Are you sure you want to move this order to a different date?
</DialogDescription>
</DialogHeader>
{rescheduleData && (
<div className="py-4">
<Alert variant="default" className="bg-amber-50 border-amber-200">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertTitle className="text-amber-800">Confirm Date Change</AlertTitle>
<AlertDescription className="text-amber-700">
Moving <strong>{rescheduleData.order.eventName}</strong> to <strong>{format(rescheduleData.newDate, 'PPPP')}</strong>.
</AlertDescription>
</Alert>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setIsConfirmOpen(false)}>
Cancel
</Button>
<Button
onClick={confirmReschedule}
disabled={updateOrderMutation.isPending}
>
{updateOrderMutation.isPending ? "Updating..." : "Confirm Move"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -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 = () => {
<Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/orders/vendor" element={<VendorOrderList />} />
<Route path="/orders/:id/edit" element={<EditOrder />} />
<Route path="/schedule" element={<Schedule />} />
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>