374 lines
15 KiB
JavaScript
374 lines
15 KiB
JavaScript
import React, { useState, useMemo } 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 { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Sparkles, Star, MapPin, Clock, Award, TrendingUp, AlertCircle, CheckCircle, Zap, Users, RefreshCw } from "lucide-react";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
|
|
export default function SmartAssignModal({ isOpen, onClose, event, roleNeeded, countNeeded }) {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [selectedWorkers, setSelectedWorkers] = useState([]);
|
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
|
const [aiRecommendations, setAiRecommendations] = useState(null);
|
|
|
|
const { data: allStaff = [] } = useQuery({
|
|
queryKey: ['staff-smart-assign'],
|
|
queryFn: () => base44.entities.Staff.list(),
|
|
});
|
|
|
|
const { data: allEvents = [] } = useQuery({
|
|
queryKey: ['events-conflict-check'],
|
|
queryFn: () => base44.entities.Event.list(),
|
|
});
|
|
|
|
// Smart filtering
|
|
const eligibleStaff = useMemo(() => {
|
|
if (!event || !roleNeeded) return [];
|
|
|
|
return allStaff.filter(worker => {
|
|
// Role match
|
|
const hasRole = worker.position === roleNeeded ||
|
|
worker.position_2 === roleNeeded ||
|
|
worker.profile_type === "Cross-Trained";
|
|
|
|
// Availability check
|
|
const isAvailable = worker.employment_type !== "Medical Leave" &&
|
|
worker.action !== "Inactive";
|
|
|
|
// Conflict check - check if worker is already assigned
|
|
const eventDate = new Date(event.date);
|
|
const hasConflict = allEvents.some(e => {
|
|
if (e.id === event.id) return false;
|
|
const eDate = new Date(e.date);
|
|
return eDate.toDateString() === eventDate.toDateString() &&
|
|
e.assigned_staff?.some(s => s.staff_id === worker.id);
|
|
});
|
|
|
|
return hasRole && isAvailable && !hasConflict;
|
|
});
|
|
}, [allStaff, event, roleNeeded, allEvents]);
|
|
|
|
// Run AI analysis
|
|
const runSmartAnalysis = async () => {
|
|
setIsAnalyzing(true);
|
|
|
|
try {
|
|
const prompt = `You are a workforce optimization AI. Analyze these workers and recommend the best ${countNeeded} for this job.
|
|
|
|
Event: ${event.event_name}
|
|
Location: ${event.event_location || event.hub}
|
|
Role Needed: ${roleNeeded}
|
|
Quantity: ${countNeeded}
|
|
|
|
Workers (JSON):
|
|
${JSON.stringify(eligibleStaff.map(w => ({
|
|
id: w.id,
|
|
name: w.employee_name,
|
|
rating: w.rating || 0,
|
|
reliability_score: w.reliability_score || 0,
|
|
total_shifts: w.total_shifts || 0,
|
|
no_show_count: w.no_show_count || 0,
|
|
position: w.position,
|
|
city: w.city,
|
|
profile_type: w.profile_type
|
|
})), null, 2)}
|
|
|
|
Rank them by:
|
|
1. Skills match (exact role match gets priority)
|
|
2. Rating (higher is better)
|
|
3. Reliability (lower no-shows, higher reliability score)
|
|
4. Experience (more shifts completed)
|
|
5. Distance (prefer closer to location)
|
|
|
|
Return the top ${countNeeded} worker IDs with brief reasoning.`;
|
|
|
|
const response = await base44.integrations.Core.InvokeLLM({
|
|
prompt,
|
|
response_json_schema: {
|
|
type: "object",
|
|
properties: {
|
|
recommendations: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
worker_id: { type: "string" },
|
|
reason: { type: "string" },
|
|
score: { type: "number" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const recommended = response.recommendations.map(rec => {
|
|
const worker = eligibleStaff.find(w => w.id === rec.worker_id);
|
|
return worker ? { ...worker, ai_reason: rec.reason, ai_score: rec.score } : null;
|
|
}).filter(Boolean);
|
|
|
|
setAiRecommendations(recommended);
|
|
setSelectedWorkers(recommended.slice(0, countNeeded));
|
|
|
|
toast({
|
|
title: "✨ AI Analysis Complete",
|
|
description: `Found ${recommended.length} optimal matches`,
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Analysis Failed",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsAnalyzing(false);
|
|
}
|
|
};
|
|
|
|
const assignMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const assigned_staff = selectedWorkers.map(w => ({
|
|
staff_id: w.id,
|
|
staff_name: w.employee_name,
|
|
role: roleNeeded
|
|
}));
|
|
|
|
return base44.entities.Event.update(event.id, {
|
|
assigned_staff: [...(event.assigned_staff || []), ...assigned_staff],
|
|
status: "Confirmed"
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
|
toast({
|
|
title: "✅ Staff Assigned Successfully",
|
|
description: `${selectedWorkers.length} workers assigned to ${event.event_name}`,
|
|
});
|
|
onClose();
|
|
},
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (isOpen && eligibleStaff.length > 0 && !aiRecommendations) {
|
|
runSmartAnalysis();
|
|
}
|
|
}, [isOpen, eligibleStaff.length]);
|
|
|
|
const toggleWorker = (worker) => {
|
|
setSelectedWorkers(prev => {
|
|
const exists = prev.find(w => w.id === worker.id);
|
|
if (exists) {
|
|
return prev.filter(w => w.id !== worker.id);
|
|
} else if (prev.length < countNeeded) {
|
|
return [...prev, worker];
|
|
}
|
|
return prev;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-3 text-2xl">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
|
<Sparkles className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-900">Smart Assign (AI Assisted)</span>
|
|
<p className="text-sm text-slate-600 font-normal mt-1">
|
|
AI selected the best {countNeeded} {roleNeeded}{countNeeded > 1 ? 's' : ''} for this event
|
|
</p>
|
|
</div>
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{isAnalyzing ? (
|
|
<div className="py-12 text-center">
|
|
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center animate-pulse">
|
|
<Sparkles className="w-8 h-8 text-white" />
|
|
</div>
|
|
<h3 className="font-bold text-lg text-slate-900 mb-2">Analyzing workforce...</h3>
|
|
<p className="text-sm text-slate-600">AI is finding the optimal matches based on skills, ratings, and availability</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Summary */}
|
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Users className="w-8 h-8 text-blue-600" />
|
|
<div>
|
|
<p className="text-xs text-blue-700 mb-1">Selected</p>
|
|
<p className="text-2xl font-bold text-blue-900">{selectedWorkers.length}/{countNeeded}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-200">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Zap className="w-8 h-8 text-purple-600" />
|
|
<div>
|
|
<p className="text-xs text-purple-700 mb-1">Avg Rating</p>
|
|
<p className="text-2xl font-bold text-purple-900">
|
|
{selectedWorkers.length > 0
|
|
? (selectedWorkers.reduce((sum, w) => sum + (w.rating || 0), 0) / selectedWorkers.length).toFixed(1)
|
|
: "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-200">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
|
<div>
|
|
<p className="text-xs text-green-700 mb-1">Available</p>
|
|
<p className="text-2xl font-bold text-green-900">{eligibleStaff.length}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* AI Recommendations */}
|
|
{aiRecommendations && aiRecommendations.length > 0 ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-bold text-slate-900">AI Recommendations</h3>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={runSmartAnalysis}
|
|
className="border-purple-300 hover:bg-purple-50"
|
|
>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
Re-analyze
|
|
</Button>
|
|
</div>
|
|
|
|
{aiRecommendations.map((worker, idx) => {
|
|
const isSelected = selectedWorkers.some(w => w.id === worker.id);
|
|
const isOverLimit = selectedWorkers.length >= countNeeded && !isSelected;
|
|
|
|
return (
|
|
<Card
|
|
key={worker.id}
|
|
className={`transition-all cursor-pointer ${
|
|
isSelected
|
|
? 'bg-gradient-to-br from-purple-50 to-indigo-50 border-2 border-purple-400 shadow-lg'
|
|
: 'bg-white border border-slate-200 hover:border-purple-300 hover:shadow-md'
|
|
} ${isOverLimit ? 'opacity-50' : ''}`}
|
|
onClick={() => !isOverLimit && toggleWorker(worker)}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-3 flex-1">
|
|
<div className="relative">
|
|
<Avatar className="w-12 h-12 border-2 border-purple-300">
|
|
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-indigo-600 text-white font-bold">
|
|
{worker.employee_name?.charAt(0) || 'W'}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
{idx === 0 && (
|
|
<div className="absolute -top-1 -right-1 w-6 h-6 bg-yellow-500 rounded-full flex items-center justify-center shadow-md">
|
|
<Award className="w-3 h-3 text-white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<h4 className="font-bold text-slate-900">{worker.employee_name}</h4>
|
|
{idx === 0 && (
|
|
<Badge className="bg-gradient-to-r from-yellow-500 to-orange-500 text-white text-xs">
|
|
Top Pick
|
|
</Badge>
|
|
)}
|
|
<Badge variant="outline" className="text-xs">
|
|
{worker.position}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 mb-2">
|
|
<div className="flex items-center gap-1">
|
|
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
|
<span className="text-sm font-bold text-slate-900">{worker.rating?.toFixed(1) || 'N/A'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
|
{worker.total_shifts || 0} shifts
|
|
</div>
|
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
|
<MapPin className="w-4 h-4 text-blue-600" />
|
|
{worker.city || 'N/A'}
|
|
</div>
|
|
</div>
|
|
|
|
{worker.ai_reason && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-2 mt-2">
|
|
<p className="text-xs text-purple-900">
|
|
<strong className="text-purple-700">AI Insight:</strong> {worker.ai_reason}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-2">
|
|
{worker.ai_score && (
|
|
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-bold">
|
|
{Math.round(worker.ai_score)}/100
|
|
</Badge>
|
|
)}
|
|
{isSelected && (
|
|
<CheckCircle className="w-6 h-6 text-purple-600" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<AlertCircle className="w-12 h-12 mx-auto mb-3 text-slate-400" />
|
|
<p className="text-slate-600">No eligible staff found for this role</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => assignMutation.mutate()}
|
|
disabled={selectedWorkers.length === 0 || assignMutation.isPending}
|
|
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-bold"
|
|
>
|
|
{assignMutation.isPending ? "Assigning..." : `Assign ${selectedWorkers.length} Workers`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |