export base44 - Nov 18
This commit is contained in:
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal file
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal file
@@ -0,0 +1,878 @@
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>No Roles to Assign</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-slate-600">All positions for this order are fully staffed, or no roles were specified.</p>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-[#0A39DF]" />
|
||||
Smart Assign Staff
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-3 mt-2 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{event.event_name}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{event.date ? format(new Date(event.date), 'MMM d, yyyy') : 'Date TBD'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge className={`${statusColor} border-2 text-lg px-4 py-2 font-bold`}>
|
||||
{selected.size} / {remainingCount} Selected
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Selector */}
|
||||
{allRoles.length > 1 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{allRoles.map((roleItem, idx) => (
|
||||
<Button
|
||||
key={`${roleItem.shift.shift_name}-${roleItem.role.role}-${idx}`}
|
||||
variant={roleItem.role.role === currentRole.role && roleItem.shift.shift_name === currentShift.shift_name ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedRole(roleItem);
|
||||
setSelected(new Set()); // Clear selection when changing roles
|
||||
}}
|
||||
className={roleItem.role.role === currentRole.role && roleItem.shift.shift_name === currentShift.shift_name ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white" : "border-slate-300"}
|
||||
>
|
||||
{roleItem.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={sortMode} onValueChange={setSortMode} className="flex-1 overflow-hidden flex flex-col">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="smart" className="flex-1">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Smart Assignment
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manual" className="flex-1">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Manual Selection
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="smart" className="flex-1 overflow-hidden flex flex-col mt-4 space-y-4">
|
||||
{/* Priority Controls */}
|
||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sliders className="w-4 h-4 text-[#0A39DF]" />
|
||||
<h4 className="font-semibold text-slate-900">Assignment Priorities</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
Reliability
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">{priorities.reliability}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[priorities.reliability]}
|
||||
onValueChange={(v) => setPriorities({...priorities, reliability: v[0]})}
|
||||
max={100}
|
||||
step={10}
|
||||
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Fatigue
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">{priorities.fatigue}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[priorities.fatigue]}
|
||||
onValueChange={(v) => setPriorities({...priorities, fatigue: v[0]})}
|
||||
max={100}
|
||||
step={10}
|
||||
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
Compliance
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">{priorities.compliance}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[priorities.compliance]}
|
||||
onValueChange={(v) => setPriorities({...priorities, compliance: v[0]})}
|
||||
max={100}
|
||||
step={10}
|
||||
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
Proximity
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">{priorities.proximity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[priorities.proximity]}
|
||||
onValueChange={(v) => setPriorities({...priorities, proximity: v[0]})}
|
||||
max={100}
|
||||
step={10}
|
||||
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 flex-1 overflow-hidden flex flex-col">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search employees..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="font-semibold text-slate-900">{availableStaff.length} Available</span>
|
||||
</div>
|
||||
{unavailableStaff.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||
<span className="font-semibold text-orange-600">{unavailableStaff.length} Unavailable</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSelectBest}
|
||||
disabled={remainingCount === 0 || availableStaff.length === 0}
|
||||
className="gap-2 bg-[#0A39DF] hover:bg-blue-700 font-semibold"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Auto-Select Best {remainingCount}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-2 border-slate-200 rounded-lg">
|
||||
{eligibleStaff.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="font-medium">No {currentRole.role}s found</p>
|
||||
<p className="text-sm">Try adjusting your search or check staff positions</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{/* Available Staff First */}
|
||||
{availableStaff.length > 0 && (
|
||||
<>
|
||||
<div className="bg-green-50 px-4 py-2 sticky top-0 z-10 border-b border-green-100">
|
||||
<p className="text-xs font-bold text-green-700 uppercase">Available ({availableStaff.length})</p>
|
||||
</div>
|
||||
{availableStaff.map((staff) => {
|
||||
const isSelected = selected.has(staff.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={staff.id}
|
||||
className={`p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => toggleSelect(staff.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelect(staff.id)}
|
||||
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Avatar className="w-12 h-12">
|
||||
<img
|
||||
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=0A39DF&color=fff&size=128&bold=true`}
|
||||
alt={staff.employee_name}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||
<Badge variant="outline" className="text-xs bg-gradient-to-r from-[#0A39DF] to-blue-600 text-white border-0">
|
||||
{Math.round(staff.smartScore)}% Match
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{staff.reliability}%
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
{Math.round(staff.scores.fatigue)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
{Math.round(staff.scores.compliance)}
|
||||
</span>
|
||||
{staff.hub_location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{staff.hub_location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||
{staff.shiftCount || 0} shifts
|
||||
</Badge>
|
||||
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-semibold">
|
||||
Available
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unavailable Staff */}
|
||||
{unavailableStaff.length > 0 && (
|
||||
<>
|
||||
<div className="bg-orange-50 px-4 py-2 sticky top-0 z-10 border-b border-orange-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-bold text-orange-700 uppercase">Unavailable ({unavailableStaff.length})</p>
|
||||
<Bell className="w-3 h-3 text-orange-700" />
|
||||
<span className="text-xs text-orange-600">Will be notified if assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
{unavailableStaff.map((staff) => {
|
||||
const isSelected = selected.has(staff.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={staff.id}
|
||||
className={`p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => toggleSelect(staff.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelect(staff.id)}
|
||||
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Avatar className="w-12 h-12">
|
||||
<img
|
||||
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=f97316&color=fff&size=128&bold=true`}
|
||||
alt={staff.employee_name}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||
<Badge variant="outline" className="text-xs bg-gradient-to-r from-orange-500 to-orange-600 text-white border-0">
|
||||
{Math.round(staff.smartScore)}% Match
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-orange-600" />
|
||||
Time Conflict
|
||||
</span>
|
||||
{staff.hub_location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{staff.hub_location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||
{staff.shiftCount || 0} shifts
|
||||
</Badge>
|
||||
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs font-semibold">
|
||||
Will Notify
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="flex-1 overflow-hidden flex flex-col mt-4 space-y-4">
|
||||
<div className="space-y-3 flex-1 overflow-hidden flex flex-col">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search employees..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="font-semibold text-slate-900">{availableStaff.length} Available {currentRole.role}s</span>
|
||||
</div>
|
||||
{unavailableStaff.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||
<span className="font-semibold text-orange-600">{unavailableStaff.length} Conflicts</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSelectBest}
|
||||
disabled={remainingCount === 0 || availableStaff.length === 0}
|
||||
variant="outline"
|
||||
className="gap-2 border-2 border-[#0A39DF] text-[#0A39DF] hover:bg-blue-50 font-semibold"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Select Top {remainingCount}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-2 border-slate-200 rounded-lg">
|
||||
{eligibleStaff.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="font-medium">No {currentRole.role}s found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{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 (
|
||||
<div
|
||||
key={staff.id}
|
||||
className={`p-4 flex items-center gap-4 transition-all ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||
} cursor-pointer`}
|
||||
onClick={() => toggleSelect(staff.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelect(staff.id)}
|
||||
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Avatar className="w-12 h-12">
|
||||
<img
|
||||
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=0A39DF&color=fff&size=128&bold=true`}
|
||||
alt={staff.employee_name}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||
{staff.rating && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
|
||||
<span className="text-sm font-medium text-slate-700">{staff.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||
<Badge variant="outline" className="text-xs border-slate-300">
|
||||
{currentRole.role}
|
||||
</Badge>
|
||||
{staff.hub_location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{staff.hub_location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||
{staff.shiftCount || 0} shifts
|
||||
</Badge>
|
||||
{staff.hasConflict ? (
|
||||
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs font-semibold">
|
||||
Conflict (Will Notify)
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-semibold">
|
||||
Available
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={onClose} className="border-2 border-slate-300">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
disabled={selected.size === 0 || assignMutation.isPending}
|
||||
className="bg-[#0A39DF] hover:bg-blue-700 font-semibold"
|
||||
>
|
||||
{assignMutation.isPending ? (
|
||||
"Assigning..."
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Assign {selected.size} {selected.size === 1 ? 'Employee' : 'Employees'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user