feat(Makefile): install frontend dependencies on dev command
feat(Makefile): patch Layout.jsx queryKey for local development feat(frontend-web): mock base44 client for local development with role switching feat(frontend-web): add event assignment modal with conflict detection and bulk assign feat(frontend-web): add client dashboard with key metrics and quick actions feat(frontend-web): add layout component with role-based navigation feat(frontend-web): update various pages to use "@/components" alias feat(frontend-web): update create event page with ai assistant toggle feat(frontend-web): update dashboard page with new components feat(frontend-web): update events page with quick assign popover feat(frontend-web): update invite vendor page with hover card feat(frontend-web): update messages page with conversation list and message thread feat(frontend-web): update operator dashboard page with new components feat(frontend-web): update partner management page with new components feat(frontend-web): update permissions page with new components feat(frontend-web): update procurement dashboard page with new components feat(frontend-web): update smart vendor onboarding page with new components feat(frontend-web): update staff directory page with new components feat(frontend-web): update teams page with new components feat(frontend-web): update user management page with new components feat(frontend-web): update vendor compliance page with new components feat(frontend-web): update main.jsx to include react query provider feat: add vendor marketplace page feat: add global import fix to prepare-export script feat: add patch-layout-query-key script to fix query key feat: update patch-base44-client script to use a more robust method
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Calendar, Users, Check, Plus, X, Clock, MapPin, ChevronLeft, ChevronRight, AlertTriangle, RefreshCw, Search } from "lucide-react";
|
||||
import { format, parseISO, isWithinInterval } from "date-fns";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
@@ -40,9 +42,32 @@ const avatarColors = [
|
||||
'bg-red-500',
|
||||
];
|
||||
|
||||
// Helper to check if times overlap
|
||||
const hasTimeConflict = (existingStart, existingEnd, newStart, newEnd, existingDate, newDate) => {
|
||||
// If different dates, no conflict
|
||||
if (existingDate !== newDate) return false;
|
||||
|
||||
if (!existingStart || !existingEnd || !newStart || !newEnd) return false;
|
||||
|
||||
const parseTime = (time) => {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
|
||||
const existingStartMin = parseTime(existingStart);
|
||||
const existingEndMin = parseTime(existingEnd);
|
||||
const newStartMin = parseTime(newStart);
|
||||
const newEndMin = parseTime(newEnd);
|
||||
|
||||
return (newStartMin < existingEndMin && newEndMin > existingStartMin);
|
||||
};
|
||||
|
||||
export default function EventAssignmentModal({ open, onClose, order, onUpdate }) {
|
||||
const [selectedShiftIndex, setSelectedShiftIndex] = useState(0);
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||
const [selectedStaffIds, setSelectedStaffIds] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [swapMode, setSwapMode] = useState(null); // { employeeId, assignmentIndex }
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -52,13 +77,20 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: allOrders = [] } = useQuery({
|
||||
queryKey: ['orders-for-conflict-check'],
|
||||
queryFn: () => base44.entities.Order.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const updateOrderMutation = useMutation({
|
||||
mutationFn: (updatedOrder) => base44.entities.Order.update(order.id, updatedOrder),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
if (onUpdate) onUpdate();
|
||||
setSelectedStaffIds([]);
|
||||
toast({
|
||||
title: "Staff assigned successfully",
|
||||
title: "✅ Staff assigned successfully",
|
||||
description: "The order has been updated with new assignments.",
|
||||
});
|
||||
},
|
||||
@@ -73,6 +105,152 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
|
||||
if (!currentRole) return null;
|
||||
|
||||
// Check for conflicts
|
||||
const getStaffConflicts = (staffId) => {
|
||||
const conflicts = [];
|
||||
|
||||
allOrders.forEach(existingOrder => {
|
||||
if (existingOrder.id === order.id) return; // Skip current order
|
||||
if (!existingOrder.shifts_data) return;
|
||||
|
||||
existingOrder.shifts_data.forEach(shift => {
|
||||
shift.roles.forEach(role => {
|
||||
if (!role.assignments) return;
|
||||
|
||||
role.assignments.forEach(assignment => {
|
||||
if (assignment.employee_id === staffId) {
|
||||
const hasConflict = hasTimeConflict(
|
||||
role.start_time,
|
||||
role.end_time,
|
||||
currentRole.start_time,
|
||||
currentRole.end_time,
|
||||
existingOrder.event_date,
|
||||
order.event_date
|
||||
);
|
||||
|
||||
if (hasConflict) {
|
||||
conflicts.push({
|
||||
orderName: existingOrder.event_name,
|
||||
role: role.service,
|
||||
time: `${convertTo12Hour(role.start_time)} - ${convertTo12Hour(role.end_time)}`,
|
||||
date: existingOrder.event_date
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
|
||||
// Role-based filtering
|
||||
const roleKeywords = {
|
||||
'bartender': ['bartender', 'bar'],
|
||||
'cook': ['cook', 'chef', 'kitchen'],
|
||||
'server': ['server', 'waiter', 'waitress'],
|
||||
'cashier': ['cashier', 'register'],
|
||||
'host': ['host', 'hostess', 'greeter'],
|
||||
'busser': ['busser', 'bus'],
|
||||
'dishwasher': ['dishwasher', 'dish'],
|
||||
'manager': ['manager', 'supervisor'],
|
||||
};
|
||||
|
||||
const getRoleCategory = (roleName) => {
|
||||
const lowerRole = roleName.toLowerCase();
|
||||
for (const [category, keywords] of Object.entries(roleKeywords)) {
|
||||
if (keywords.some(keyword => lowerRole.includes(keyword))) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const matchesRole = (staffPosition, requiredRole) => {
|
||||
const staffLower = (staffPosition || '').toLowerCase();
|
||||
const requiredLower = (requiredRole || '').toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (staffLower.includes(requiredLower) || requiredLower.includes(staffLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Category match
|
||||
const staffCategory = getRoleCategory(staffPosition || '');
|
||||
const requiredCategory = getRoleCategory(requiredRole);
|
||||
|
||||
return staffCategory && requiredCategory && staffCategory === requiredCategory;
|
||||
};
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedStaffIds.length === 0) {
|
||||
toast({
|
||||
title: "No staff selected",
|
||||
description: "Please select staff members to assign.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
// Check for conflicts
|
||||
const conflictingStaff = [];
|
||||
selectedStaffIds.forEach(staffId => {
|
||||
const conflicts = getStaffConflicts(staffId);
|
||||
if (conflicts.length > 0) {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
conflictingStaff.push(staff?.employee_name);
|
||||
}
|
||||
});
|
||||
|
||||
if (conflictingStaff.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict Detected",
|
||||
description: `${conflictingStaff.join(', ')} ${conflictingStaff.length === 1 ? 'is' : 'are'} already assigned to overlapping shifts.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const needed = parseInt(currentRole.count) || 0;
|
||||
const currentAssigned = assignments.length;
|
||||
const remaining = needed - currentAssigned;
|
||||
|
||||
if (selectedStaffIds.length > remaining) {
|
||||
toast({
|
||||
title: "Too many selected",
|
||||
description: `Only ${remaining} more staff ${remaining === 1 ? 'is' : 'are'} needed.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add all selected staff
|
||||
const newAssignments = selectedStaffIds.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
employee_id: staff.id,
|
||||
employee_name: staff.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,
|
||||
...newAssignments
|
||||
];
|
||||
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleAssignStaff = (staffMember) => {
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
@@ -87,6 +265,17 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts = getStaffConflicts(staffMember.id);
|
||||
if (conflicts.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict",
|
||||
description: `${staffMember.employee_name} is already assigned to ${conflicts[0].orderName} at ${conflicts[0].time}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new assignment
|
||||
const newAssignment = {
|
||||
employee_id: staffMember.id,
|
||||
@@ -117,14 +306,63 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleSwapStaff = (newStaffMember) => {
|
||||
if (!swapMode) return;
|
||||
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts = getStaffConflicts(newStaffMember.id);
|
||||
if (conflicts.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict",
|
||||
description: `${newStaffMember.employee_name} is already assigned to ${conflicts[0].orderName} at ${conflicts[0].time}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the assignment
|
||||
assignments[swapMode.assignmentIndex] = {
|
||||
employee_id: newStaffMember.id,
|
||||
employee_name: newStaffMember.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;
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
setSwapMode(null);
|
||||
};
|
||||
|
||||
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)
|
||||
// Filter staff by role and exclude already assigned
|
||||
const assignedIds = new Set(assignments.map(a => a.employee_id));
|
||||
const availableStaff = allStaff.filter(s => !assignedIds.has(s.id));
|
||||
const roleFilteredStaff = allStaff.filter(s =>
|
||||
matchesRole(s.position, currentRole.service) ||
|
||||
matchesRole(s.position_2, currentRole.service)
|
||||
);
|
||||
|
||||
const availableStaff = roleFilteredStaff
|
||||
.filter(s => !assignedIds.has(s.id))
|
||||
.filter(s => {
|
||||
if (!searchTerm) return true;
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
return (
|
||||
s.employee_name?.toLowerCase().includes(lowerSearch) ||
|
||||
s.position?.toLowerCase().includes(lowerSearch) ||
|
||||
s.position_2?.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
});
|
||||
|
||||
// Calculate total assignments across all roles in this shift
|
||||
let totalNeeded = 0;
|
||||
@@ -134,9 +372,27 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
totalAssigned += role.assignments?.length || 0;
|
||||
});
|
||||
|
||||
const toggleStaffSelection = (staffId) => {
|
||||
setSelectedStaffIds(prev =>
|
||||
prev.includes(staffId)
|
||||
? prev.filter(id => id !== staffId)
|
||||
: [...prev, staffId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const remaining = needed - assigned;
|
||||
const selectableStaff = availableStaff.slice(0, remaining);
|
||||
setSelectedStaffIds(selectableStaff.map(s => s.id));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedStaffIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogContent className="max-w-3xl 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">
|
||||
@@ -147,12 +403,22 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
</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`}
|
||||
totalAssigned >= totalNeeded
|
||||
? 'bg-emerald-500 text-white border-0 animate-pulse'
|
||||
: 'bg-orange-500 text-white border-0'
|
||||
} font-semibold px-3 py-1 shadow-md`}
|
||||
>
|
||||
{isFullyStaffed ? 'Fully Staffed' : 'Needs Staff'}
|
||||
{totalAssigned >= totalNeeded ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Fully Staffed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Needs Staff
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -181,21 +447,21 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm font-bold border-2"
|
||||
style={{
|
||||
borderColor: totalAssigned >= totalNeeded ? '#10b981' : '#f97316',
|
||||
color: totalAssigned >= totalNeeded ? '#10b981' : '#f97316'
|
||||
}}
|
||||
className={`text-sm font-bold border-2 ${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'border-emerald-500 text-emerald-700 bg-emerald-50'
|
||||
: 'border-orange-500 text-orange-700 bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
{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 className="mb-3 p-3 rounded-lg bg-gradient-to-r from-emerald-50 to-green-50 border-2 border-emerald-200">
|
||||
<div className="flex items-center gap-2 text-emerald-700 text-sm font-semibold">
|
||||
<Check className="w-5 h-5" />
|
||||
✨ Fully staffed - All positions filled!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -207,7 +473,11 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
<label className="text-sm font-semibold text-slate-700 mb-2 block">Select Position:</label>
|
||||
<Select
|
||||
value={selectedRoleIndex.toString()}
|
||||
onValueChange={(value) => setSelectedRoleIndex(parseInt(value))}
|
||||
onValueChange={(value) => {
|
||||
setSelectedRoleIndex(parseInt(value));
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -216,13 +486,17 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
{currentShift.roles.map((role, idx) => {
|
||||
const roleAssigned = role.assignments?.length || 0;
|
||||
const roleNeeded = parseInt(role.count) || 0;
|
||||
const roleFilled = roleAssigned >= roleNeeded;
|
||||
return (
|
||||
<SelectItem key={idx} value={idx.toString()}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>{role.service}</span>
|
||||
<div className="flex items-center justify-between gap-4 w-full">
|
||||
<span className="font-medium">{role.service}</span>
|
||||
<Badge
|
||||
variant={roleAssigned >= roleNeeded ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
className={`${
|
||||
roleFilled
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-orange-100 text-orange-700'
|
||||
} text-xs font-bold ml-2`}
|
||||
>
|
||||
{roleAssigned}/{roleNeeded}
|
||||
</Badge>
|
||||
@@ -236,23 +510,23 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
)}
|
||||
|
||||
{/* Current Position Details */}
|
||||
<div className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold text-slate-900">{currentRole.service}</h4>
|
||||
<h4 className="font-bold text-slate-900 text-lg">{currentRole.service}</h4>
|
||||
<Badge
|
||||
className={`${
|
||||
assigned >= needed
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-orange-100 text-orange-700'
|
||||
} font-semibold`}
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-orange-500 text-white'
|
||||
} font-bold px-3 py-1 text-base`}
|
||||
>
|
||||
{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" />
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700 font-medium">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<span>
|
||||
{convertTo12Hour(currentRole.start_time)} - {convertTo12Hour(currentRole.end_time)}
|
||||
</span>
|
||||
@@ -260,108 +534,282 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swap Mode Banner */}
|
||||
{swapMode && (
|
||||
<div className="mb-4 p-4 bg-purple-50 border-2 border-purple-300 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold text-purple-900">
|
||||
Swap Mode Active - Select replacement for {assignments[swapMode.assignmentIndex]?.employee_name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSwapMode(null)}
|
||||
className="text-purple-600 hover:bg-purple-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</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>
|
||||
<h4 className="text-sm font-bold text-slate-700 mb-3 uppercase tracking-wide">✅ 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>
|
||||
{assignments.map((assignment, idx) => {
|
||||
const conflicts = getStaffConflicts(assignment.employee_id);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-xl border-2 border-slate-200 hover:border-blue-300 transition-all shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{getInitials(assignment.employee_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-slate-900">{assignment.employee_name}</p>
|
||||
<p className="text-xs text-slate-500">{currentRole.service}</p>
|
||||
{conflicts.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs text-red-600 font-medium">Time conflict detected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSwapMode({ employeeId: assignment.employee_id, assignmentIndex: idx })}
|
||||
className="text-purple-600 hover:bg-purple-50 border-purple-300"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Swap
|
||||
</Button>
|
||||
<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>
|
||||
<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 && (
|
||||
{(assigned < needed || swapMode) && (
|
||||
<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 justify-between mb-3">
|
||||
<h4 className="text-sm font-bold text-slate-700 uppercase tracking-wide">
|
||||
{swapMode ? '🔄 Select Replacement:' : '➕ Add Staff:'}
|
||||
</h4>
|
||||
{!swapMode && availableStaff.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
disabled={needed - assigned === 0}
|
||||
>
|
||||
<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"
|
||||
Select All ({Math.min(availableStaff.length, needed - assigned)})
|
||||
</Button>
|
||||
{selectedStaffIds.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deselectAll}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBulkAssign}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Assign {selectedStaffIds.length}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search staff by name or position..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Showing {availableStaff.length} {currentRole.service.toLowerCase()}(s)
|
||||
{roleFilteredStaff.length !== allStaff.length && (
|
||||
<span className="text-blue-600 font-medium"> (filtered by role)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{availableStaff.length > 0 ? (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto pr-2">
|
||||
{availableStaff.map((staff, idx) => {
|
||||
const isSelected = selectedStaffIds.includes(staff.id);
|
||||
const conflicts = getStaffConflicts(staff.id);
|
||||
const hasConflict = conflicts.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={staff.id}
|
||||
className={`flex items-center justify-between p-3 bg-white rounded-xl border-2 transition-all ${
|
||||
hasConflict
|
||||
? 'border-red-200 bg-red-50'
|
||||
: isSelected
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-slate-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{!swapMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleStaffSelection(staff.id)}
|
||||
disabled={hasConflict}
|
||||
className="border-2"
|
||||
/>
|
||||
)}
|
||||
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{getInitials(staff.employee_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-900 truncate">{staff.employee_name}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-xs text-slate-500">{staff.position || 'Staff Member'}</p>
|
||||
{staff.position_2 && (
|
||||
<Badge variant="outline" className="text-[10px]">{staff.position_2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasConflict && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs text-red-600 font-medium">
|
||||
Conflict: {conflicts[0].orderName} ({conflicts[0].time})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => swapMode ? handleSwapStaff(staff) : handleAssignStaff(staff)}
|
||||
disabled={hasConflict}
|
||||
className={`${
|
||||
swapMode
|
||||
? 'bg-purple-600 hover:bg-purple-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
} ${hasConflict ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{swapMode ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Swap
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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 className="text-center py-12 bg-slate-50 rounded-xl border-2 border-dashed border-slate-200">
|
||||
<Users className="w-16 h-16 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium text-slate-600">
|
||||
{searchTerm
|
||||
? 'No staff match your search'
|
||||
: `No available ${currentRole.service.toLowerCase()}s found`
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{searchTerm ? 'Try a different search term' : 'All matching staff have been assigned'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 flex items-center justify-between">
|
||||
<div className="border-t pt-4 flex items-center justify-between bg-slate-50 -mx-6 px-6 -mb-6 pb-6">
|
||||
<div className="flex gap-2">
|
||||
{selectedShiftIndex > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedShiftIndex(selectedShiftIndex - 1)}
|
||||
onClick={() => {
|
||||
setSelectedShiftIndex(selectedShiftIndex - 1);
|
||||
setSelectedRoleIndex(0);
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous
|
||||
Previous Shift
|
||||
</Button>
|
||||
)}
|
||||
{selectedShiftIndex < order.shifts_data.length - 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedShiftIndex(selectedShiftIndex + 1)}
|
||||
onClick={() => {
|
||||
setSelectedShiftIndex(selectedShiftIndex + 1);
|
||||
setSelectedRoleIndex(0);
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
Next Shift
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-4 py-2 text-base font-bold ${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'bg-emerald-50 border-emerald-500 text-emerald-700'
|
||||
: 'bg-orange-50 border-orange-500 text-orange-700'
|
||||
}`}
|
||||
>
|
||||
{totalAssigned}/{totalNeeded} Filled
|
||||
</Badge>
|
||||
<Button onClick={onClose} className="bg-blue-600 hover:bg-blue-700">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user