import React, { useState, useMemo, useEffect } from "react"; import { base44 } from "@/api/base44Client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { useToast } from "@/components/ui/use-toast"; import { Search, Users, AlertTriangle, Star, MapPin, Sparkles, Check, Calendar, Sliders, TrendingUp, Shield, DollarSign, Zap, Bell, } from "lucide-react"; import { format } from "date-fns"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Slider } from "@/components/ui/slider"; // Helper to check time overlap with buffer function hasTimeOverlap(start1, end1, start2, end2, bufferMinutes = 30) { const s1 = new Date(start1).getTime(); const e1 = new Date(end1).getTime() + bufferMinutes * 60 * 1000; const s2 = new Date(start2).getTime(); const e2 = new Date(end2).getTime() + bufferMinutes * 60 * 1000; return s1 < e2 && s2 < e1; } export default function SmartAssignModal({ open, onClose, event, shift, role }) { const { toast } = useToast(); const queryClient = useQueryClient(); const [searchQuery, setSearchQuery] = useState(""); const [selected, setSelected] = useState(new Set()); const [sortMode, setSortMode] = useState("smart"); const [selectedRole, setSelectedRole] = useState(null); // New state to manage current selected role for assignment // Smart assignment priorities const [priorities, setPriorities] = useState({ skill: 100, // Skill is implied by position match, not a slider reliability: 80, fatigue: 60, compliance: 70, proximity: 50, cost: 40, }); useEffect(() => { if (open) { setSelected(new Set()); setSearchQuery(""); // Auto-select first role if available or the one passed in props if (event && !role) { // If no specific role is passed, find roles that need assignment const initialRoles = []; (event.shifts || []).forEach(s => { (s.roles || []).forEach(r => { const currentAssignedCount = event.assigned_staff?.filter(staff => staff.role === r.role && staff.shift_name === s.shift_name )?.length || 0; if ((r.count || 0) > currentAssignedCount) { initialRoles.push({ shift: s, role: r }); } }); }); if (initialRoles.length > 0) { setSelectedRole(initialRoles[0]); } else { setSelectedRole(null); // No roles need assignment } } else if (shift && role) { setSelectedRole({ shift, role }); } } }, [open, event, shift, role]); const { data: allStaff = [] } = useQuery({ queryKey: ['staff-for-assignment'], queryFn: () => base44.entities.Staff.list(), enabled: open, }); const { data: allEvents = [] } = useQuery({ queryKey: ['events-for-conflict-check'], queryFn: () => base44.entities.Event.list(), enabled: open, }); const { data: vendorRates = [] } = useQuery({ queryKey: ['vendor-rates-assignment'], queryFn: () => base44.entities.VendorRate.list(), enabled: open, initialData: [], }); // Get all roles that need assignment for display in the header const allRoles = useMemo(() => { if (!event) return []; const roles = []; (event.shifts || []).forEach(s => { (s.roles || []).forEach(r => { const currentAssignedCount = event.assigned_staff?.filter(staff => staff.role === r.role && staff.shift_name === s.shift_name )?.length || 0; const remaining = Math.max((r.count || 0) - currentAssignedCount, 0); if (remaining > 0) { roles.push({ shift: s, role: r, currentAssigned: currentAssignedCount, remaining, label: `${r.role} (${remaining} needed)` }); } }); }); return roles; }, [event]); // Use selectedRole for current assignment context const currentRole = selectedRole?.role; const currentShift = selectedRole?.shift; const requiredCount = currentRole?.count || 1; const currentAssigned = event?.assigned_staff?.filter(s => s.role === currentRole?.role && s.shift_name === currentShift?.shift_name )?.length || 0; const remainingCount = Math.max(requiredCount - currentAssigned, 0); const eligibleStaff = useMemo(() => { if (!currentRole || !event) return []; return allStaff .filter(staff => { // Check if position matches const positionMatch = staff.position === currentRole.role || staff.position_2 === currentRole.role || staff.position?.toLowerCase() === currentRole.role?.toLowerCase() || staff.position_2?.toLowerCase() === currentRole.role?.toLowerCase(); if (!positionMatch) return false; if (searchQuery) { const query = searchQuery.toLowerCase(); const nameMatch = staff.employee_name?.toLowerCase().includes(query); const locationMatch = staff.hub_location?.toLowerCase().includes(query); if (!nameMatch && !locationMatch) return false; } return true; }) .map(staff => { // Check for time conflicts const conflicts = allEvents.filter(e => { if (e.id === event.id) return false; // Don't conflict with current event if (e.status === "Canceled" || e.status === "Completed") return false; // Ignore past/canceled events const isAssignedToEvent = e.assigned_staff?.some(s => s.staff_id === staff.id); if (!isAssignedToEvent) return false; // Staff not assigned to this event // Check for time overlap within the conflicting event's shifts const eventShifts = e.shifts || []; return eventShifts.some(eventShift => { const eventRoles = eventShift.roles || []; return eventRoles.some(eventRole => { // Ensure staff is assigned to this specific role within the conflicting shift const isStaffAssignedToThisRole = e.assigned_staff?.some( s => s.staff_id === staff.id && s.role === eventRole.role && s.shift_name === eventShift.shift_name ); if (!isStaffAssignedToThisRole) return false; const shiftStart = `${e.date}T${eventRole.start_time || '00:00'}`; const shiftEnd = `${e.date}T${eventRole.end_time || '23:59'}`; const currentStart = `${event.date}T${currentRole.start_time || '00:00'}`; const currentEnd = `${event.date}T${currentRole.end_time || '23:59'}`; return hasTimeOverlap(shiftStart, shiftEnd, currentStart, currentEnd); }); }); }); const hasConflict = conflicts.length > 0; const totalShifts = staff.total_shifts || 0; const reliability = staff.reliability_score || (totalShifts > 0 ? 85 : 0); // Calculate smart scores // Skill score is implicitly 100 if they pass the filter (position match) const fatigueScore = 100 - Math.min((totalShifts / 30) * 100, 100); // More shifts = more fatigue = lower score const complianceScore = staff.background_check_status === 'cleared' ? 100 : 50; // Simple compliance check const proximityScore = staff.hub_location === event.hub ? 100 : 50; // Location match const costRate = vendorRates.find(r => r.vendor_id === staff.vendor_id && r.role_name === currentRole.role); const costScore = costRate ? Math.max(0, 100 - (costRate.client_rate / 50) * 100) : 50; // Lower rate = higher score const smartScore = ( (priorities.skill / 100) * 100 + // Skill is 100 if eligible (priorities.reliability / 100) * reliability + (priorities.fatigue / 100) * fatigueScore + (priorities.compliance / 100) * complianceScore + (priorities.proximity / 100) * proximityScore + (priorities.cost / 100) * costScore ) / 6; // Divided by number of priorities (6) return { ...staff, hasConflict, conflictDetails: conflicts, reliability, shiftCount: totalShifts, smartScore, scores: { fatigue: fatigueScore, compliance: complianceScore, proximity: proximityScore, cost: costScore, } }; }) .sort((a, b) => { if (sortMode === "smart") { // Prioritize non-conflicting staff first, then by smart score if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1; return b.smartScore - a.smartScore; } else { // Manual mode: Prioritize non-conflicting, then reliability, then shift count if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1; if (b.reliability !== a.reliability) return b.reliability - a.reliability; return (b.shiftCount || 0) - (a.shiftCount || 0); } }); }, [allStaff, allEvents, currentRole, event, currentShift, searchQuery, sortMode, priorities, vendorRates]); const availableStaff = eligibleStaff.filter(s => !s.hasConflict); const unavailableStaff = eligibleStaff.filter(s => s.hasConflict); const handleSelectBest = () => { const best = availableStaff.slice(0, remainingCount); const newSelected = new Set(best.map(s => s.id)); setSelected(newSelected); }; const toggleSelect = (staffId) => { const newSelected = new Set(selected); if (newSelected.has(staffId)) { newSelected.delete(staffId); } else { if (newSelected.size >= remainingCount) { toast({ title: "Limit Reached", description: `You can only assign ${remainingCount} more ${currentRole.role}${remainingCount > 1 ? 's' : ''} to this role.`, variant: "destructive", }); return; } newSelected.add(staffId); } setSelected(newSelected); }; const assignMutation = useMutation({ mutationFn: async () => { const selectedStaff = eligibleStaff.filter(s => selected.has(s.id)); // Send notifications to unavailable staff who are being assigned const unavailableSelected = selectedStaff.filter(s => s.hasConflict); for (const staff of unavailableSelected) { try { // This is a placeholder for sending an actual email/notification // In a real application, you'd use a robust notification service. await base44.integrations.Core.SendEmail({ // Assuming base44.integrations.Core exists and has SendEmail to: staff.email || `${staff.employee_name.replace(/\s/g, '').toLowerCase()}@example.com`, subject: `New Shift Assignment - ${event.event_name} (Possible Conflict)`, body: `Dear ${staff.employee_name},\n\nYou have been assigned to work as a ${currentRole.role} for the event "${event.event_name}" on ${format(new Date(event.date), 'MMM d, yyyy')} from ${currentRole.start_time} to ${currentRole.end_time} at ${event.hub || event.event_location}.\n\nOur records indicate this assignment might overlap with another scheduled shift. Please review your schedule and confirm your availability for this new assignment as soon as possible.\n\nThank you!` }); } catch (error) { console.error("Failed to send email to conflicted staff:", staff.employee_name, error); // Decide whether to block assignment or just log the error } } const updatedAssignedStaff = [ ...(event.assigned_staff || []), ...selectedStaff.map(s => ({ staff_id: s.id, staff_name: s.employee_name, email: s.email, role: currentRole.role, department: currentRole.department, shift_name: currentShift.shift_name, // Include shift_name })) ]; const updatedShifts = (event.shifts || []).map(s => { if (s.shift_name === currentShift.shift_name) { const updatedRoles = (s.roles || []).map(r => { if (r.role === currentRole.role) { return { ...r, assigned: (r.assigned || 0) + selected.size, }; } return r; }); return { ...s, roles: updatedRoles }; } return s; }); await base44.entities.Event.update(event.id, { assigned_staff: updatedAssignedStaff, shifts: updatedShifts, requested: (event.requested || 0) + selected.size, // This `requested` field might need more careful handling if it's meant to be total }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['events'] }); queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] }); // New query key queryClient.invalidateQueries({ queryKey: ['vendor-events'] }); // New query key toast({ title: "✅ Staff Assigned", description: `Successfully assigned ${selected.size} ${currentRole.role}${selected.size > 1 ? 's' : ''}`, }); setSelected(new Set()); // Clear selection after assignment // Auto-select the next role that needs assignment const currentRoleIdentifier = { role: currentRole.role, shift_name: currentShift.shift_name }; const currentIndex = allRoles.findIndex(ar => ar.role.role === currentRoleIdentifier.role && ar.shift.shift_name === currentRoleIdentifier.shift_name); if (currentIndex !== -1 && currentIndex + 1 < allRoles.length) { setSelectedRole(allRoles[currentIndex + 1]); } else { onClose(); // Close if no more roles to assign } }, onError: (error) => { toast({ title: "❌ Assignment Failed", description: error.message, variant: "destructive", }); }, }); const handleAssign = () => { if (selected.size === 0) { toast({ title: "No Selection", description: "Please select at least one staff member", variant: "destructive", }); return; } // The logic to check for conflicts and stop was removed because // the new assignMutation now sends notifications to conflicted staff. // If a hard stop for conflicts is desired, this check should be re-enabled // and the notification logic in assignMutation modified. assignMutation.mutate(); }; if (!event) return null; // If there's no currentRole, it means either props were not passed or all roles are already assigned if (!currentRole || !currentShift) { return ( No Roles to Assign

All positions for this order are fully staffed, or no roles were specified.

); } const statusColor = remainingCount === 0 ? "bg-green-100 text-green-700 border-green-300" : currentAssigned > 0 ? "bg-blue-100 text-blue-700 border-blue-300" : "bg-slate-100 text-slate-700 border-slate-300"; return (
Smart Assign Staff
{event.event_name} {event.date ? format(new Date(event.date), 'MMM d, yyyy') : 'Date TBD'}
{selected.size} / {remainingCount} Selected
{/* Role Selector */} {allRoles.length > 1 && (
{allRoles.map((roleItem, idx) => ( ))}
)}
Smart Assignment Manual Selection {/* Priority Controls */}

Assignment Priorities

Reliability {priorities.reliability}%
setPriorities({...priorities, reliability: v[0]})} max={100} step={10} className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" />
Fatigue {priorities.fatigue}%
setPriorities({...priorities, fatigue: v[0]})} max={100} step={10} className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" />
Compliance {priorities.compliance}%
setPriorities({...priorities, compliance: v[0]})} max={100} step={10} className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" />
Proximity {priorities.proximity}%
setPriorities({...priorities, proximity: v[0]})} max={100} step={10} className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" />
setSearchQuery(e.target.value)} className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]" />
{availableStaff.length} Available
{unavailableStaff.length > 0 && (
{unavailableStaff.length} Unavailable
)}
{eligibleStaff.length === 0 ? (

No {currentRole.role}s found

Try adjusting your search or check staff positions

) : (
{/* Available Staff First */} {availableStaff.length > 0 && ( <>

Available ({availableStaff.length})

{availableStaff.map((staff) => { const isSelected = selected.has(staff.id); return (
toggleSelect(staff.id)} > toggleSelect(staff.id)} className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]" onClick={(e) => e.stopPropagation()} /> {staff.employee_name}

{staff.employee_name}

{Math.round(staff.smartScore)}% Match
{staff.reliability}% {Math.round(staff.scores.fatigue)} {Math.round(staff.scores.compliance)} {staff.hub_location && ( {staff.hub_location} )}
{staff.shiftCount || 0} shifts Available
); })} )} {/* Unavailable Staff */} {unavailableStaff.length > 0 && ( <>

Unavailable ({unavailableStaff.length})

Will be notified if assigned
{unavailableStaff.map((staff) => { const isSelected = selected.has(staff.id); return (
toggleSelect(staff.id)} > toggleSelect(staff.id)} className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]" onClick={(e) => e.stopPropagation()} /> {staff.employee_name}

{staff.employee_name}

{Math.round(staff.smartScore)}% Match
Time Conflict {staff.hub_location && ( {staff.hub_location} )}
{staff.shiftCount || 0} shifts Will Notify
); })} )}
)}
setSearchQuery(e.target.value)} className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]" />
{availableStaff.length} Available {currentRole.role}s
{unavailableStaff.length > 0 && (
{unavailableStaff.length} Conflicts
)}
{eligibleStaff.length === 0 ? (

No {currentRole.role}s found

Try adjusting your search or filters

) : (
{eligibleStaff.map((staff) => { const isSelected = selected.has(staff.id); // In manual mode, we still allow selection of conflicted staff, // and the system will notify them. return (
toggleSelect(staff.id)} > toggleSelect(staff.id)} className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]" onClick={(e) => e.stopPropagation()} /> {staff.employee_name}

{staff.employee_name}

{staff.rating && (
{staff.rating.toFixed(1)}
)}
{currentRole.role} {staff.hub_location && ( {staff.hub_location} )}
{staff.shiftCount || 0} shifts {staff.hasConflict ? ( Conflict (Will Notify) ) : ( Available )}
); })}
)}
); }