879 lines
40 KiB
JavaScript
879 lines
40 KiB
JavaScript
|
|
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>
|
|
);
|
|
}
|