Files
Krow-workspace/frontend-web/src/components/events/SmartAssignModal.jsx
2025-11-18 21:32:16 -05:00

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>
);
}