369 lines
14 KiB
JavaScript
369 lines
14 KiB
JavaScript
import React, { useState } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Calendar, Users, Check, Plus, X, Clock, MapPin, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { format } from "date-fns";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
|
|
const convertTo12Hour = (time24) => {
|
|
if (!time24) return '';
|
|
const [hours, minutes] = time24.split(':');
|
|
const hour = parseInt(hours);
|
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
|
const hour12 = hour % 12 || 12;
|
|
return `${hour12}:${minutes} ${ampm}`;
|
|
};
|
|
|
|
const getInitials = (name) => {
|
|
if (!name) return 'S';
|
|
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
|
};
|
|
|
|
const avatarColors = [
|
|
'bg-blue-500',
|
|
'bg-purple-500',
|
|
'bg-green-500',
|
|
'bg-orange-500',
|
|
'bg-pink-500',
|
|
'bg-indigo-500',
|
|
'bg-teal-500',
|
|
'bg-red-500',
|
|
];
|
|
|
|
export default function EventAssignmentModal({ open, onClose, order, onUpdate }) {
|
|
const [selectedShiftIndex, setSelectedShiftIndex] = useState(0);
|
|
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
const { data: allStaff = [] } = useQuery({
|
|
queryKey: ['staff-for-assignment'],
|
|
queryFn: () => base44.entities.Staff.list(),
|
|
enabled: open,
|
|
});
|
|
|
|
const updateOrderMutation = useMutation({
|
|
mutationFn: (updatedOrder) => base44.entities.Order.update(order.id, updatedOrder),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
|
if (onUpdate) onUpdate();
|
|
toast({
|
|
title: "Staff assigned successfully",
|
|
description: "The order has been updated with new assignments.",
|
|
});
|
|
},
|
|
});
|
|
|
|
if (!order || !order.shifts_data || order.shifts_data.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const currentShift = order.shifts_data[selectedShiftIndex];
|
|
const currentRole = currentShift?.roles[selectedRoleIndex];
|
|
|
|
if (!currentRole) return null;
|
|
|
|
const handleAssignStaff = (staffMember) => {
|
|
const updatedOrder = { ...order };
|
|
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
|
|
|
// Check if already assigned
|
|
if (assignments.some(a => a.employee_id === staffMember.id)) {
|
|
toast({
|
|
title: "Already assigned",
|
|
description: `${staffMember.employee_name} is already assigned to this role.`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Add new assignment
|
|
const newAssignment = {
|
|
employee_id: staffMember.id,
|
|
employee_name: staffMember.employee_name,
|
|
position: currentRole.service,
|
|
shift_date: order.event_date,
|
|
shift_start: currentRole.start_time,
|
|
shift_end: currentRole.end_time,
|
|
location: currentShift.address || order.event_address,
|
|
hub_location: order.hub_location,
|
|
};
|
|
|
|
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments = [
|
|
...assignments,
|
|
newAssignment
|
|
];
|
|
|
|
updateOrderMutation.mutate(updatedOrder);
|
|
};
|
|
|
|
const handleRemoveStaff = (employeeId) => {
|
|
const updatedOrder = { ...order };
|
|
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
|
|
|
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments =
|
|
assignments.filter(a => a.employee_id !== employeeId);
|
|
|
|
updateOrderMutation.mutate(updatedOrder);
|
|
};
|
|
|
|
const assignments = currentRole.assignments || [];
|
|
const needed = parseInt(currentRole.count) || 0;
|
|
const assigned = assignments.length;
|
|
const isFullyStaffed = assigned >= needed;
|
|
|
|
// Filter available staff (not already assigned to this role)
|
|
const assignedIds = new Set(assignments.map(a => a.employee_id));
|
|
const availableStaff = allStaff.filter(s => !assignedIds.has(s.id));
|
|
|
|
// Calculate total assignments across all roles in this shift
|
|
let totalNeeded = 0;
|
|
let totalAssigned = 0;
|
|
currentShift.roles.forEach(role => {
|
|
totalNeeded += parseInt(role.count) || 0;
|
|
totalAssigned += role.assignments?.length || 0;
|
|
});
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
<DialogHeader className="border-b pb-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<DialogTitle className="text-2xl font-bold text-slate-900 mb-1">
|
|
{order.event_name}
|
|
</DialogTitle>
|
|
<p className="text-sm text-slate-600">{order.client_business}</p>
|
|
</div>
|
|
<Badge
|
|
className={`${
|
|
isFullyStaffed
|
|
? 'bg-green-100 text-green-800 border-green-200'
|
|
: 'bg-orange-100 text-orange-800 border-orange-200'
|
|
} border font-semibold`}
|
|
>
|
|
{isFullyStaffed ? 'Fully Staffed' : 'Needs Staff'}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-slate-700 mt-3">
|
|
<Calendar className="w-4 h-4 text-blue-600" />
|
|
<span className="text-sm font-medium">
|
|
{order.event_date ? format(new Date(order.event_date), 'EEEE, MMMM d, yyyy') : 'No date'}
|
|
</span>
|
|
</div>
|
|
|
|
{currentShift.address && (
|
|
<div className="flex items-center gap-2 text-slate-700 mt-2">
|
|
<MapPin className="w-4 h-4 text-blue-600" />
|
|
<span className="text-sm">{currentShift.address}</span>
|
|
</div>
|
|
)}
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto py-4">
|
|
{/* Staff Assignment Summary */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-5 h-5 text-purple-600" />
|
|
<h3 className="font-semibold text-slate-900">Staff Assignment</h3>
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-sm font-bold border-2"
|
|
style={{
|
|
borderColor: totalAssigned >= totalNeeded ? '#10b981' : '#f97316',
|
|
color: totalAssigned >= totalNeeded ? '#10b981' : '#f97316'
|
|
}}
|
|
>
|
|
{totalAssigned} / {totalNeeded}
|
|
</Badge>
|
|
</div>
|
|
|
|
{totalAssigned >= totalNeeded && (
|
|
<div className="mb-3 p-3 rounded-lg bg-green-50 border border-green-200">
|
|
<div className="flex items-center gap-2 text-green-700 text-sm font-medium">
|
|
<Check className="w-4 h-4" />
|
|
Fully staffed
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Position Selection */}
|
|
{currentShift.roles.length > 1 && (
|
|
<div className="mb-6">
|
|
<label className="text-sm font-semibold text-slate-700 mb-2 block">Select Position:</label>
|
|
<Select
|
|
value={selectedRoleIndex.toString()}
|
|
onValueChange={(value) => setSelectedRoleIndex(parseInt(value))}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{currentShift.roles.map((role, idx) => {
|
|
const roleAssigned = role.assignments?.length || 0;
|
|
const roleNeeded = parseInt(role.count) || 0;
|
|
return (
|
|
<SelectItem key={idx} value={idx.toString()}>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span>{role.service}</span>
|
|
<Badge
|
|
variant={roleAssigned >= roleNeeded ? "default" : "secondary"}
|
|
className="text-xs"
|
|
>
|
|
{roleAssigned}/{roleNeeded}
|
|
</Badge>
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Current Position Details */}
|
|
<div className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="font-semibold text-slate-900">{currentRole.service}</h4>
|
|
<Badge
|
|
className={`${
|
|
assigned >= needed
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-orange-100 text-orange-700'
|
|
} font-semibold`}
|
|
>
|
|
{assigned}/{needed}
|
|
</Badge>
|
|
</div>
|
|
|
|
{currentRole.start_time && (
|
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
|
<Clock className="w-4 h-4" />
|
|
<span>
|
|
{convertTo12Hour(currentRole.start_time)} - {convertTo12Hour(currentRole.end_time)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Assigned Staff List */}
|
|
{assignments.length > 0 && (
|
|
<div className="mb-6">
|
|
<h4 className="text-sm font-semibold text-slate-700 mb-3">ASSIGNED STAFF:</h4>
|
|
<div className="space-y-2">
|
|
{assignments.map((assignment, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200 hover:border-blue-300 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
|
<AvatarFallback className="text-white font-bold">
|
|
{getInitials(assignment.employee_name)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<p className="font-semibold text-slate-900">{assignment.employee_name}</p>
|
|
<p className="text-xs text-slate-500">{currentRole.service}</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveStaff(assignment.employee_id)}
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Staff Section */}
|
|
{assigned < needed && (
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-slate-700 mb-3">ADD STAFF:</h4>
|
|
{availableStaff.length > 0 ? (
|
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
{availableStaff.map((staff, idx) => (
|
|
<div
|
|
key={staff.id}
|
|
className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200 hover:border-blue-300 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
|
<AvatarFallback className="text-white font-bold">
|
|
{getInitials(staff.employee_name)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
|
<p className="text-xs text-slate-500">{staff.position || 'Staff Member'}</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleAssignStaff(staff)}
|
|
className="bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Assign
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-slate-400">
|
|
<Users className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
|
<p className="text-sm">All available staff have been assigned</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t pt-4 flex items-center justify-between">
|
|
<div className="flex gap-2">
|
|
{selectedShiftIndex > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setSelectedShiftIndex(selectedShiftIndex - 1)}
|
|
>
|
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
|
Previous
|
|
</Button>
|
|
)}
|
|
{selectedShiftIndex < order.shifts_data.length - 1 && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setSelectedShiftIndex(selectedShiftIndex + 1)}
|
|
>
|
|
Next
|
|
<ChevronRight className="w-4 h-4 ml-1" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Button onClick={onClose}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |