diff --git a/apps/web/src/features/operations/orders/OrderDetail.tsx b/apps/web/src/features/operations/orders/OrderDetail.tsx index 92d416bc..2cb4918f 100644 --- a/apps/web/src/features/operations/orders/OrderDetail.tsx +++ b/apps/web/src/features/operations/orders/OrderDetail.tsx @@ -1,17 +1,18 @@ -import React, { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { format } from "date-fns"; import { useSelector } from "react-redux"; -import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock } from "lucide-react"; +import { Calendar, MapPin, Users, DollarSign, Edit3, X, Copy, Clock, FileText, UserPlus } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/common/components/ui/card"; import { Button } from "@/common/components/ui/button"; import { Badge } from "@/common/components/ui/badge"; import DashboardLayout from "@/features/layouts/DashboardLayout"; -import { useGetOrderById, useUpdateOrder } from "@/dataconnect-generated/react"; +import { useGetOrderById, useUpdateOrder, useListShiftRolesByBusinessAndOrder } from "@/dataconnect-generated/react"; import { OrderStatus } from "@/dataconnect-generated"; import { dataConnect } from "@/features/auth/firebase"; import { useToast } from "@/common/components/ui/use-toast"; +import AssignStaffModal from "./components/AssignStaffModal"; import type { RootState } from "@/store/store"; const safeFormatDate = (value?: string | null): string => { @@ -85,6 +86,8 @@ export default function OrderDetail() { const { id } = useParams<{ id: string }>(); const { toast } = useToast(); const { user } = useSelector((state: RootState) => state.auth); + const [selectedShift, setSelectedShift] = useState(null); + const [isAssignModalOpen, setIsAssignModalOpen] = useState(false); const { data, @@ -99,6 +102,22 @@ export default function OrderDetail() { const order = data?.order; + // Fetch real shift roles to get IDs and accurate counts + const { + data: shiftRolesData, + isLoading: isLoadingShifts, + refetch: refetchShifts + } = useListShiftRolesByBusinessAndOrder( + dataConnect, + { + orderId: id || "", + businessId: order?.businessId || "" + }, + { + enabled: !!id && !!order?.businessId, + } + ); + const cancelMutation = useUpdateOrder(dataConnect, { onSuccess: () => { toast({ @@ -140,7 +159,12 @@ export default function OrderDetail() { navigate(`/orders/create?duplicate=${id}`); }; - const shifts: any[] = Array.isArray(order?.shifts) ? (order!.shifts as any[]) : []; + const shifts: any[] = useMemo(() => { + if (shiftRolesData?.shiftRoles && shiftRolesData.shiftRoles.length > 0) { + return shiftRolesData.shiftRoles; + } + return Array.isArray(order?.shifts) ? (order!.shifts as any[]) : []; + }, [shiftRolesData, order?.shifts]); const totalRequested = order?.requested ?? 0; const totalAssigned = Array.isArray(order?.assignedStaff) ? order!.assignedStaff.length : 0; @@ -238,7 +262,7 @@ export default function OrderDetail() {
- +

@@ -352,6 +376,21 @@ export default function OrderDetail() { {vacancies}

+ + {!isClient && canModify && ( + + )}
); @@ -431,20 +470,20 @@ export default function OrderDetail() { + + {selectedShift && ( + { + setIsAssignModalOpen(false); + setSelectedShift(null); + }} + shift={selectedShift} + onSuccess={() => { + refetchShifts(); + }} + /> + )} ); } - -const FileTextIcon: React.FC = () => ( - -); diff --git a/apps/web/src/features/operations/orders/components/AssignStaffModal.tsx b/apps/web/src/features/operations/orders/components/AssignStaffModal.tsx new file mode 100644 index 00000000..f622884d --- /dev/null +++ b/apps/web/src/features/operations/orders/components/AssignStaffModal.tsx @@ -0,0 +1,267 @@ +import React, { useState, useMemo } from "react"; +import { Search, Check, X, UserPlus, Star, Clock, AlertTriangle } from "lucide-react"; +import { format, isSameDay, parseISO } from "date-fns"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/common/components/ui/dialog"; +import { Button } from "@/common/components/ui/button"; +import { Input } from "@/common/components/ui/input"; +import { Badge } from "@/common/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/common/components/ui/avatar"; +import { useToast } from "@/common/components/ui/use-toast"; +import { + useListWorkforceByVendorId, + useCreateAssignment, + useUpdateShiftRole, + useListAssignments, + useListStaffAvailabilitiesByDay +} from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import { AssignmentStatus } from "@/dataconnect-generated"; + +interface AssignStaffModalProps { + isOpen: boolean; + onClose: () => void; + shift: any; // The ShiftRole object + onSuccess: () => void; +} + +export default function AssignStaffModal({ isOpen, onClose, shift, onSuccess }: AssignStaffModalProps) { + const { toast } = useToast(); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedStaff, setSelectedStaff] = useState(null); + + const vendorId = shift.shift?.order?.vendorId || shift.order?.vendorId; + const shiftDate = shift.shift?.date || shift.date; + const shiftStartTime = shift.startTime || shift.start; + const shiftEndTime = shift.endTime || shift.end; + + // Fetch all workforce members for this vendor + const { data: workforceData, isLoading: isLoadingWorkforce } = useListWorkforceByVendorId( + dataConnect, + { vendorId: vendorId || "" }, + { enabled: !!vendorId } + ); + + // Fetch existing assignments to check for conflicts + const { data: assignmentsData } = useListAssignments(dataConnect); + + // Fetch availabilities for the day of the shift + // Note: This is simplified. Proper day of week mapping would be needed. + // const dayOfWeek = format(new Date(shiftDate), "EEEE").toUpperCase(); + // const { data: availabilitiesData } = useListStaffAvailabilitiesByDay( + // dataConnect, + // { day: dayOfWeek as any } + // ); + + const createAssignmentMutation = useCreateAssignment(dataConnect, { + onSuccess: () => { + // Also update the shift role's assigned count + updateShiftRoleMutation.mutate({ + shiftId: shift.shiftId, + roleId: shift.roleId, + assigned: (shift.assigned || 0) + 1, + }); + }, + onError: () => { + toast({ + title: "Assignment failed", + description: "Could not assign staff member. Please try again.", + variant: "destructive", + }); + }, + }); + + const updateShiftRoleMutation = useUpdateShiftRole(dataConnect, { + onSuccess: () => { + toast({ + title: "Staff Assigned", + description: "The staff member has been successfully assigned to the shift.", + }); + onSuccess(); + onClose(); + }, + }); + + const staffList = useMemo(() => { + if (!workforceData?.workforces) return []; + + return workforceData.workforces.map((w: any) => { + const staff = w.staff; + const workforceId = w.id; + + // Basic skill matching (check if role name is in staff skills) + const roleName = shift.role?.name?.toLowerCase() || ""; + const hasSkill = staff.skills?.some((s: string) => s.toLowerCase().includes(roleName)) || false; + + // Conflict detection (check if staff is already assigned at this time) + const hasConflict = assignmentsData?.assignments?.some((a: any) => { + if (a.workforce.staff.id !== staff.id) return false; + if (a.status === AssignmentStatus.CANCELED) return false; + + const aStart = new Date(a.shiftRole.startTime); + const aEnd = new Date(a.shiftRole.endTime); + const sStart = new Date(shiftStartTime); + const sEnd = new Date(shiftEndTime); + + // Overlap check + return sStart < aEnd && aStart < sEnd; + }); + + return { + ...staff, + workforceId, + hasSkill, + hasConflict, + reliability: staff.reliabilityScore || 0, + }; + }); + }, [workforceData, assignmentsData, shift, shiftStartTime, shiftEndTime]); + + const filteredStaff = useMemo(() => { + return staffList.filter((s: any) => + s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) || + s.email?.toLowerCase().includes(searchQuery.toLowerCase()) + ).sort((a: any, b: any) => { + // Sort by skill match and then reliability + if (a.hasSkill && !b.hasSkill) return -1; + if (!a.hasSkill && b.hasSkill) return 1; + return b.reliability - a.reliability; + }); + }, [staffList, searchQuery]); + + const handleAssign = () => { + if (!selectedStaff) return; + + createAssignmentMutation.mutate({ + workforceId: selectedStaff.workforceId, + shiftId: shift.shiftId, + roleId: shift.roleId, + status: AssignmentStatus.PENDING, + title: `Assignment for ${shift.role?.name || "Shift"}`, + }); + }; + + return ( + + + + Assign Staff + + Select a staff member for the {shift.role?.name || "Shift"} role. + + + +
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+

+ Available Staff Members +

+ + {isLoadingWorkforce ? ( +
+
+

Loading workforce...

+
+ ) : filteredStaff.length === 0 ? ( +
+

No staff members found matching your search.

+
+ ) : ( +
+ {filteredStaff.map((staff: any) => ( +
!staff.hasConflict && setSelectedStaff(staff)} + className={` + flex items-center justify-between p-3 rounded-xl border transition-all cursor-pointer + ${selectedStaff?.id === staff.id + ? "border-primary bg-primary/5 ring-1 ring-primary/20" + : "border-border/40 hover:border-border hover:bg-slate-50/50"} + ${staff.hasConflict ? "opacity-60 grayscale cursor-not-allowed" : ""} + `} + > +
+ + + + {staff.fullName.charAt(0)} + + +
+
+

{staff.fullName}

+ {staff.hasSkill && ( + + SKILL MATCH + + )} +
+
+
+ + {staff.reliability}% Reliability +
+
+ + {staff.totalShifts || 0} Shifts +
+
+
+
+ +
+ {staff.hasConflict ? ( +
+ + + CONFLICT + +
+ ) : selectedStaff?.id === staff.id ? ( +
+ +
+ ) : ( +
+ )} +
+
+ ))} +
+ )} +
+
+ + + + + + +
+ ); +}