Files
Krow-workspace/frontend-web-free/src/components/orders/SmartAssignModal.jsx
2025-12-04 18:02:28 -05:00

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