export base44 - Nov 18

This commit is contained in:
bwnyasse
2025-11-18 21:32:16 -05:00
parent f7c2027065
commit d26bcaeed2
67 changed files with 13716 additions and 8102 deletions

View File

@@ -0,0 +1,396 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Settings,
GripVertical,
X,
Plus,
Eye,
EyeOff,
Info,
Save,
RotateCcw,
Sparkles
} from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
export default function DashboardCustomizer({
user,
availableWidgets = [],
currentLayout = [],
onLayoutChange,
dashboardType = "default" // admin, client, vendor, operator, etc
}) {
const [isOpen, setIsOpen] = useState(false);
const [showHowItWorks, setShowHowItWorks] = useState(false);
const [visibleWidgets, setVisibleWidgets] = useState([]);
const [hiddenWidgets, setHiddenWidgets] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const { toast } = useToast();
const queryClient = useQueryClient();
// Initialize widgets from user's saved layout or defaults
useEffect(() => {
const layoutKey = `dashboard_layout_${dashboardType}`;
const savedLayout = user?.[layoutKey];
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
const savedVisible = savedLayout.widgets
.map(id => availableWidgets.find(w => w.id === id))
.filter(Boolean);
setVisibleWidgets(savedVisible);
const savedHidden = savedLayout.hidden_widgets || [];
const hiddenWidgetsList = availableWidgets.filter(w =>
savedHidden.includes(w.id)
);
setHiddenWidgets(hiddenWidgetsList);
} else {
// Default: all widgets visible in provided order
setVisibleWidgets(availableWidgets);
setHiddenWidgets([]);
}
}, [user, availableWidgets, isOpen, dashboardType]);
// Save layout mutation
const saveLayoutMutation = useMutation({
mutationFn: async (layoutData) => {
const layoutKey = `dashboard_layout_${dashboardType}`;
await base44.auth.updateMe({
[layoutKey]: layoutData
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] });
queryClient.invalidateQueries({ queryKey: ['current-user-layout'] });
queryClient.invalidateQueries({ queryKey: ['current-user-client'] });
queryClient.invalidateQueries({ queryKey: ['current-user-vendor'] });
queryClient.invalidateQueries({ queryKey: ['current-user-operator'] });
toast({
title: "✅ Layout Saved",
description: "Your dashboard layout has been updated",
});
setHasChanges(false);
if (onLayoutChange) {
onLayoutChange(visibleWidgets);
}
setTimeout(() => {
setIsOpen(false);
}, 500);
},
onError: () => {
toast({
title: "❌ Save Failed",
description: "Could not save your layout. Please try again.",
variant: "destructive",
});
}
});
const handleDragEnd = (result) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId === "visible" && destination.droppableId === "visible") {
const items = Array.from(visibleWidgets);
const [reorderedItem] = items.splice(source.index, 1);
items.splice(destination.index, 0, reorderedItem);
setVisibleWidgets(items);
setHasChanges(true);
}
};
const handleHideWidget = (widget) => {
setVisibleWidgets(visibleWidgets.filter(w => w.id !== widget.id));
setHiddenWidgets([...hiddenWidgets, widget]);
setHasChanges(true);
};
const handleShowWidget = (widget) => {
setHiddenWidgets(hiddenWidgets.filter(w => w.id !== widget.id));
setVisibleWidgets([...visibleWidgets, widget]);
setHasChanges(true);
};
const handleSave = () => {
const layoutData = {
widgets: visibleWidgets.map(w => w.id),
hidden_widgets: hiddenWidgets.map(w => w.id),
layout_version: "2.0"
};
saveLayoutMutation.mutate(layoutData);
};
const handleReset = () => {
setVisibleWidgets(availableWidgets);
setHiddenWidgets([]);
setHasChanges(true);
};
const handleOpenCustomizer = () => {
setIsOpen(true);
setShowHowItWorks(true);
setHasChanges(false);
};
const handleClose = () => {
if (hasChanges) {
if (window.confirm("You have unsaved changes. Are you sure you want to close?")) {
setIsOpen(false);
setHasChanges(false);
}
} else {
setIsOpen(false);
}
};
return (
<>
{/* Customize Button */}
<Button
onClick={handleOpenCustomizer}
variant="outline"
className="gap-2 border-2 border-blue-200 hover:bg-blue-50 text-blue-600 font-semibold"
>
<Settings className="w-4 h-4" />
Customize
</Button>
{/* Customizer Dialog */}
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<Sparkles className="w-6 h-6 text-blue-600" />
Customize Your Dashboard
</DialogTitle>
<DialogDescription>
Personalize your workspace by adding, removing, and reordering widgets
</DialogDescription>
</DialogHeader>
{/* How It Works Banner */}
<AnimatePresence>
{showHowItWorks && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-4 mb-4"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Info className="w-5 h-5 text-blue-600" />
<h3 className="font-bold text-blue-900">How it works</h3>
</div>
<div className="space-y-2">
<p className="text-sm text-blue-800 flex items-center gap-2">
<GripVertical className="w-4 h-4" />
<strong>Drag</strong> widgets to reorder them
</p>
<p className="text-sm text-blue-800 flex items-center gap-2">
<EyeOff className="w-4 h-4" />
<strong>Hide</strong> widgets you don't need
</p>
<p className="text-sm text-blue-800 flex items-center gap-2">
<Eye className="w-4 h-4" />
<strong>Show</strong> hidden widgets to bring them back
</p>
</div>
</div>
<button
onClick={() => setShowHowItWorks(false)}
className="text-blue-400 hover:text-blue-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="space-y-6">
{/* Visible Widgets */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-lg text-slate-900">
Visible Widgets ({visibleWidgets.length})
</h3>
<Button
onClick={handleReset}
variant="outline"
size="sm"
className="gap-2"
disabled={saveLayoutMutation.isPending}
>
<RotateCcw className="w-4 h-4" />
Reset to Default
</Button>
</div>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="visible">
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`space-y-2 min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
snapshot.isDraggingOver
? 'border-blue-400 bg-blue-50'
: 'border-slate-200 bg-slate-50'
}`}
>
{visibleWidgets.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm font-medium">No visible widgets</p>
<p className="text-xs mt-1">Add widgets from the hidden section below!</p>
</div>
) : (
visibleWidgets.map((widget, index) => (
<Draggable key={widget.id} draggableId={widget.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`bg-white border-2 rounded-lg p-4 transition-all ${
snapshot.isDragging
? 'border-blue-400 shadow-2xl scale-105 rotate-2'
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-3">
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-blue-600 transition-colors p-1 hover:bg-blue-50 rounded"
>
<GripVertical className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="font-bold text-slate-900">{widget.title}</p>
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
</div>
<Badge className={`${widget.categoryColor || 'bg-blue-100 text-blue-700'} border-0 text-xs`}>
{widget.category}
</Badge>
<button
onClick={() => handleHideWidget(widget)}
className="text-slate-400 hover:text-red-600 transition-colors p-2 hover:bg-red-50 rounded-lg"
title="Hide widget"
disabled={saveLayoutMutation.isPending}
>
<EyeOff className="w-4 h-4" />
</button>
</div>
</div>
)}
</Draggable>
))
)}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
{/* Hidden Widgets */}
{hiddenWidgets.length > 0 && (
<div>
<h3 className="font-bold text-lg text-slate-900 mb-3 flex items-center gap-2">
Hidden Widgets ({hiddenWidgets.length})
<Badge className="bg-slate-200 text-slate-600 text-xs">Click + to add</Badge>
</h3>
<div className="grid grid-cols-2 gap-3">
{hiddenWidgets.map((widget) => (
<div
key={widget.id}
className="bg-slate-50 border-2 border-dashed border-slate-300 rounded-lg p-4 opacity-60 hover:opacity-100 transition-all hover:border-green-400 hover:bg-green-50/50 group"
>
<div className="flex items-center gap-3">
<div className="flex-1">
<p className="font-semibold text-slate-900">{widget.title}</p>
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
</div>
<button
onClick={() => handleShowWidget(widget)}
className="text-slate-400 hover:text-green-600 group-hover:bg-green-100 transition-colors p-2 rounded-lg"
title="Show widget"
disabled={saveLayoutMutation.isPending}
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* All Hidden Message */}
{hiddenWidgets.length === 0 && visibleWidgets.length === availableWidgets.length && (
<div className="text-center py-6 bg-green-50 border-2 border-green-200 rounded-lg">
<Sparkles className="w-8 h-8 mx-auto mb-2 text-green-600" />
<p className="text-sm font-medium text-green-800">
All widgets are visible on your dashboard!
</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-4 border-t mt-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={handleClose}
className="text-slate-600"
disabled={saveLayoutMutation.isPending}
>
Cancel
</Button>
{hasChanges && (
<Badge className="bg-orange-500 text-white animate-pulse">
Unsaved Changes
</Badge>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowHowItWorks(!showHowItWorks)}
className="gap-2"
disabled={saveLayoutMutation.isPending}
>
<Info className="w-4 h-4" />
{showHowItWorks ? 'Hide' : 'Show'} Help
</Button>
<Button
onClick={handleSave}
disabled={saveLayoutMutation.isPending || !hasChanges}
className="bg-blue-600 hover:bg-blue-700 gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saveLayoutMutation.isPending ? "Saving..." : hasChanges ? "Save Layout" : "No Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,235 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react";
import { format } from "date-fns";
export default function AssignedStaffManager({ event, shift, role }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [editTarget, setEditTarget] = useState(null);
const [swapTarget, setSwapTarget] = useState(null);
const [editTimes, setEditTimes] = useState({ start: "", end: "" });
// Get assigned staff for this role
const assignedStaff = (event.assigned_staff || []).filter(
s => s.role === role?.role
);
// Remove staff mutation
const removeMutation = useMutation({
mutationFn: async (staffId) => {
const updatedAssignedStaff = (event.assigned_staff || []).filter(
s => s.staff_id !== staffId || s.role !== role.role
);
const updatedShifts = (event.shifts || []).map(s => {
if (s.shift_name === shift.shift_name) {
const updatedRoles = (s.roles || []).map(r => {
if (r.role === role.role) {
return {
...r,
assigned: Math.max((r.assigned || 0) - 1, 0),
};
}
return r;
});
return { ...s, roles: updatedRoles };
}
return s;
});
await base44.entities.Event.update(event.id, {
assigned_staff: updatedAssignedStaff,
shifts: updatedShifts,
requested: Math.max((event.requested || 0) - 1, 0),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
toast({
title: "✅ Staff Removed",
description: "Staff member has been removed from this assignment",
});
},
});
// Edit times mutation
const editMutation = useMutation({
mutationFn: async () => {
const updatedShifts = (event.shifts || []).map(s => {
if (s.shift_name === shift.shift_name) {
const updatedRoles = (s.roles || []).map(r => {
if (r.role === role.role) {
return {
...r,
start_time: editTimes.start,
end_time: editTimes.end,
};
}
return r;
});
return { ...s, roles: updatedRoles };
}
return s;
});
await base44.entities.Event.update(event.id, {
shifts: updatedShifts,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
toast({
title: "✅ Times Updated",
description: "Assignment times have been updated",
});
setEditTarget(null);
},
});
const handleEdit = (staff) => {
setEditTarget(staff);
setEditTimes({
start: role.start_time || "09:00",
end: role.end_time || "17:00",
});
};
const handleSaveEdit = () => {
editMutation.mutate();
};
const handleRemove = (staffId) => {
if (confirm("Are you sure you want to remove this staff member?")) {
removeMutation.mutate(staffId);
}
};
if (!assignedStaff.length) {
return (
<div className="text-center py-6 text-slate-500 text-sm">
No staff assigned yet
</div>
);
}
return (
<>
<div className="space-y-2">
{assignedStaff.map((staff) => (
<div
key={staff.staff_id}
className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow"
>
<Avatar className="w-10 h-10 bg-gradient-to-br from-green-600 to-emerald-600">
<AvatarFallback className="text-white font-bold">
{staff.staff_name?.charAt(0) || 'S'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-900">{staff.staff_name}</p>
<div className="flex items-center gap-2 text-xs text-slate-600">
<Badge variant="outline" className="text-xs">
{staff.role}
</Badge>
{role.start_time && role.end_time && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{role.start_time} - {role.end_time}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(staff)}
className="h-8 w-8 p-0"
title="Edit times"
>
<Edit2 className="w-4 h-4 text-slate-600" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(staff.staff_id)}
disabled={removeMutation.isPending}
className="h-8 w-8 p-0"
title="Remove"
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</div>
))}
</div>
{/* Edit Times Dialog */}
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Assignment Times</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Staff Member</Label>
<p className="text-sm font-medium mt-1">{editTarget?.staff_name}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Start Time</Label>
<Input
type="time"
value={editTimes.start}
onChange={(e) => setEditTimes({ ...editTimes, start: e.target.value })}
/>
</div>
<div>
<Label>End Time</Label>
<Input
type="time"
value={editTimes.end}
onChange={(e) => setEditTimes({ ...editTimes, end: e.target.value })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditTarget(null)}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
onClick={handleSaveEdit}
disabled={editMutation.isPending}
className="bg-blue-600 hover:bg-blue-700"
>
<Check className="w-4 h-4 mr-2" />
{editMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -40,6 +40,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
date: "",
include_backup: false,
backup_staff_count: 0,
vendor_id: "", // Added vendor_id
vendor_name: "", // Added vendor_name
shifts: [{
shift_name: "Shift 1",
location_address: "",
@@ -72,6 +74,7 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
const currentUserData = currentUser || user;
const userRole = currentUserData?.user_role || currentUserData?.role || "admin";
const isVendor = userRole === "vendor";
const isClient = userRole === "client"; // Added isClient
const { data: businesses = [] } = useQuery({
queryKey: ['businesses'],
@@ -79,6 +82,12 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
initialData: [],
});
const { data: vendors = [] } = useQuery({ // Added vendors query
queryKey: ['vendors-for-order'],
queryFn: () => base44.entities.Vendor.list(),
initialData: [],
});
const { data: allRates = [] } = useQuery({
queryKey: ['vendor-rates-all'],
queryFn: () => base44.entities.VendorRate.list(),
@@ -87,6 +96,22 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort();
// Auto-select preferred vendor for clients
useEffect(() => {
if (isClient && currentUserData && !formData.vendor_id) {
const preferredVendorId = currentUserData.preferred_vendor_id;
const preferredVendorName = currentUserData.preferred_vendor_name;
if (preferredVendorId) {
setFormData(prev => ({
...prev,
vendor_id: preferredVendorId,
vendor_name: preferredVendorName || ""
}));
}
}
}, [isClient, currentUserData, formData.vendor_id]); // Dependency array updated
useEffect(() => {
if (event) {
setFormData(event);
@@ -112,6 +137,38 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
}
};
const handleVendorChange = (vendorId) => { // Added handleVendorChange
const selectedVendor = vendors.find(v => v.id === vendorId);
if (selectedVendor) {
setFormData(prev => {
const updatedShifts = prev.shifts.map(shift => ({
...shift,
roles: shift.roles.map(role => {
if (role.role) {
const rate = getRateForRole(role.role, vendorId); // Re-calculate rates with new vendorId
return {
...role,
rate_per_hour: rate,
total_value: rate * (role.hours || 0) * (role.count || 1),
vendor_id: vendorId,
vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as
};
}
return role;
})
}));
return {
...prev,
vendor_id: vendorId,
vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as || "",
shifts: updatedShifts
};
});
updateGrandTotal();
}
};
const calculateHours = (startTime, endTime, breakMinutes = 0) => {
if (!startTime || !endTime) return 0;
const [startHour, startMin] = startTime.split(':').map(Number);
@@ -124,9 +181,21 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
return Math.max(0, totalMinutes / 60);
};
const getRateForRole = (roleName) => {
const rate = allRates.find(r => r.role_name === roleName && r.is_active);
return rate ? parseFloat(rate.client_rate || 0) : 0;
const getRateForRole = (roleName, vendorId = null) => { // Modified getRateForRole
const targetVendorId = vendorId || formData.vendor_id;
if (targetVendorId) {
const rate = allRates.find(r =>
r.role_name === roleName &&
r.vendor_id === targetVendorId &&
r.is_active
);
if (rate) return parseFloat(rate.client_rate || 0);
}
// Fallback to any active rate if no specific vendor or vendor-specific rate is found
const fallbackRate = allRates.find(r => r.role_name === roleName && r.is_active);
return fallbackRate ? parseFloat(fallbackRate.client_rate || 0) : 0;
};
const handleRoleChange = (shiftIndex, roleIndex, field, value) => {
@@ -138,6 +207,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
if (field === 'role') {
const rate = getRateForRole(value);
role.rate_per_hour = rate;
role.vendor_id = prev.vendor_id; // Added vendor_id to role
role.vendor_name = prev.vendor_name; // Added vendor_name to role
}
if (field === 'start_time' || field === 'end_time' || field === 'break_minutes') {
@@ -176,7 +247,9 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
uniform: "Type 1",
break_minutes: 30, // Default to 30 min non-payable
rate_per_hour: 0,
total_value: 0
total_value: 0,
vendor_id: prev.vendor_id, // Added vendor_id to new role
vendor_name: prev.vendor_name // Added vendor_name to new role
});
return { ...prev, shifts: newShifts };
});
@@ -588,6 +661,36 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
<CardContent className="p-4 space-y-3">
<Label className="text-xs font-bold">Event Details</Label>
{/* Vendor Selection for Clients */}
{isClient && (
<div className="p-3 bg-blue-50 rounded-lg border-2 border-blue-200">
<Label className="text-xs font-semibold mb-2 block flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-600" />
Select Vendor *
</Label>
<Select value={formData.vendor_id || ""} onValueChange={handleVendorChange}>
<SelectTrigger className="h-9 text-sm bg-white">
<SelectValue placeholder="Choose vendor for this order" />
</SelectTrigger>
<SelectContent>
{vendors.filter(v => v.approval_status === 'approved').map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id} className="text-sm">
{vendor.legal_name || vendor.doing_business_as}
{currentUserData?.preferred_vendor_id === vendor.id && (
<Badge className="ml-2 bg-blue-500 text-white text-xs">Preferred</Badge>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{formData.vendor_id && (
<p className="text-xs text-blue-600 mt-2">
Rates will be automatically applied from {formData.vendor_name}
</p>
)}
</div>
)}
{/* 1. Hub (first) */}
<div>
<Label className="text-xs">Hub *</Label>

View File

@@ -1,84 +1,160 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MapPin, Plus } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Clock, MapPin, Users, DollarSign, UserPlus } from "lucide-react";
import SmartAssignModal from "./SmartAssignModal";
import AssignedStaffManager from "./AssignedStaffManager";
export default function ShiftCard({ shift, onNotifyStaff }) {
const convertTo12Hour = (time24) => {
if (!time24 || time24 === "—") return time24;
try {
const parts = time24.split(':');
if (!parts || parts.length < 2) return time24;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
if (isNaN(hours) || isNaN(minutes)) return time24;
const period = hours >= 12 ? 'PM' : 'AM';
const hours12 = hours % 12 || 12;
const minutesStr = minutes.toString().padStart(2, '0');
return `${hours12}:${minutesStr} ${period}`;
} catch (error) {
console.error('Error converting time:', error);
return time24;
}
};
export default function ShiftCard({ shift, event }) {
const [assignModal, setAssignModal] = useState({ open: false, role: null });
const roles = shift?.roles || [];
return (
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100 pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{shift.shift_name || "Shift 1"}</CardTitle>
<Button onClick={onNotifyStaff} className="bg-blue-600 hover:bg-blue-700 text-white text-sm">
Notify Staff
</Button>
</div>
<div className="flex items-start gap-6 mt-4">
<div>
<p className="text-xs text-slate-500 mb-2">Managers:</p>
<div className="flex items-center gap-2">
{shift.assigned_staff?.slice(0, 3).map((staff, idx) => (
<div key={idx} className="flex items-center gap-2">
<Avatar className="w-8 h-8 bg-slate-300">
<AvatarFallback className="text-xs">{staff.staff_name?.charAt(0)}</AvatarFallback>
</Avatar>
<div className="text-xs">
<p className="font-medium">{staff.staff_name}</p>
<p className="text-slate-500">{staff.position || "john@email.com"}</p>
</div>
</div>
))}
</div>
</div>
<div className="flex items-start gap-2 text-xs">
<MapPin className="w-4 h-4 text-slate-400 mt-0.5" />
<>
<Card className="bg-white border-2 border-slate-200 shadow-sm">
<CardHeader className="border-b border-slate-100 bg-slate-50">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Location:</p>
<p className="text-slate-600">{shift.location || "848 East Glen Road New York CA, USA"}</p>
<CardTitle className="text-lg font-bold text-slate-900">
{shift.shift_name || "Shift"}
</CardTitle>
{shift.location && (
<div className="flex items-center gap-2 text-sm text-slate-600 mt-1">
<MapPin className="w-4 h-4" />
{shift.location}
</div>
)}
</div>
<Badge className="bg-[#0A39DF] text-white font-semibold px-3 py-1.5">
{roles.length} Role{roles.length !== 1 ? 's' : ''}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="text-xs">Unpaid break</TableHead>
<TableHead className="text-xs">Count</TableHead>
<TableHead className="text-xs">Assigned</TableHead>
<TableHead className="text-xs">Uniform type</TableHead>
<TableHead className="text-xs">Price</TableHead>
<TableHead className="text-xs">Amount</TableHead>
<TableHead className="text-xs">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(shift.assigned_staff || []).length > 0 ? (
shift.assigned_staff.map((staff, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs">{shift.unpaid_break || 0}</TableCell>
<TableCell className="text-xs">1</TableCell>
<TableCell className="text-xs">0</TableCell>
<TableCell className="text-xs">{shift.uniform_type || "uniform type"}</TableCell>
<TableCell className="text-xs">${shift.price || 23}</TableCell>
<TableCell className="text-xs">{shift.amount || 120}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="text-xs"></Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-4 text-slate-500 text-xs">
No staff assigned yet
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{roles.map((role, idx) => {
const requiredCount = role.count || 1;
const assignedCount = event?.assigned_staff?.filter(s => s.role === role.role)?.length || 0;
const remainingCount = Math.max(requiredCount - assignedCount, 0);
// Consistent status color logic
const statusColor = remainingCount === 0
? "bg-green-100 text-green-700 border-green-300"
: assignedCount > 0
? "bg-blue-100 text-blue-700 border-blue-300"
: "bg-slate-100 text-slate-700 border-slate-300";
return (
<div
key={idx}
className="border-2 border-slate-200 rounded-xl p-4 hover:shadow-sm transition-shadow bg-white"
>
<div className="flex items-center justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-bold text-slate-900 text-lg">{role.role}</h4>
<Badge className={`${statusColor} border-2 font-bold px-3 py-1`}>
{assignedCount} / {requiredCount} Assigned
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600">
{role.start_time && role.end_time && (
<span className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
</span>
)}
{role.department && (
<Badge variant="outline" className="text-xs border-slate-300">
{role.department}
</Badge>
)}
</div>
</div>
{remainingCount > 0 && (
<Button
onClick={() => setAssignModal({ open: true, role })}
className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold"
>
<UserPlus className="w-4 h-4" />
Assign Staff ({remainingCount} needed)
</Button>
)}
</div>
{/* Show assigned staff */}
{assignedCount > 0 && (
<div className="border-t border-slate-200 pt-4 mt-4">
<p className="text-xs font-bold text-slate-700 mb-3 uppercase tracking-wide">
Assigned Staff
</p>
<AssignedStaffManager event={event} shift={shift} role={role} />
</div>
)}
{/* Additional role details */}
{(role.uniform || role.cost_per_hour) && (
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-slate-200">
{role.uniform && (
<div>
<p className="text-xs text-slate-500">Uniform</p>
<p className="text-sm font-medium text-slate-900">{role.uniform}</p>
</div>
)}
{role.cost_per_hour && (
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-[#0A39DF]" />
<div>
<p className="text-xs text-slate-500">Rate</p>
<p className="text-sm font-bold text-slate-900">${role.cost_per_hour}/hr</p>
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Smart Assignment Modal */}
<SmartAssignModal
open={assignModal.open}
onClose={() => setAssignModal({ open: false, role: null })}
event={event}
shift={shift}
role={assignModal.role}
/>
</>
);
}

View File

@@ -0,0 +1,878 @@
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>
);
}

View File

@@ -0,0 +1,340 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Award, Star, MapPin, Users, TrendingUp, AlertTriangle, Zap, CheckCircle2, Send } from "lucide-react";
export default function VendorRoutingPanel({
user,
selectedVendors = [],
onVendorChange,
isRapid = false
}) {
const [showVendorSelector, setShowVendorSelector] = useState(false);
const [selectionMode, setSelectionMode] = useState('single'); // 'single' | 'multi'
// Fetch preferred vendor
const { data: preferredVendor } = useQuery({
queryKey: ['preferred-vendor-routing', user?.preferred_vendor_id],
queryFn: async () => {
if (!user?.preferred_vendor_id) return null;
const vendors = await base44.entities.Vendor.list();
return vendors.find(v => v.id === user.preferred_vendor_id);
},
enabled: !!user?.preferred_vendor_id,
});
// Fetch all approved vendors
const { data: allVendors } = useQuery({
queryKey: ['all-vendors-routing'],
queryFn: () => base44.entities.Vendor.filter({
approval_status: 'approved',
is_active: true
}),
initialData: [],
});
// Auto-select preferred vendor on mount if none selected
React.useEffect(() => {
if (preferredVendor && selectedVendors.length === 0) {
onVendorChange([preferredVendor]);
}
}, [preferredVendor]);
const handleVendorSelect = (vendor) => {
if (selectionMode === 'single') {
onVendorChange([vendor]);
setShowVendorSelector(false);
} else {
// Multi-select mode
const isSelected = selectedVendors.some(v => v.id === vendor.id);
if (isSelected) {
onVendorChange(selectedVendors.filter(v => v.id !== vendor.id));
} else {
onVendorChange([...selectedVendors, vendor]);
}
}
};
const handleMultiVendorDone = () => {
if (selectedVendors.length === 0) {
alert("Please select at least one vendor");
return;
}
setShowVendorSelector(false);
};
const routingMode = selectedVendors.length > 1 ? 'multi' : 'single';
return (
<>
<Card className={`border-2 ${
isRapid
? 'border-red-300 bg-gradient-to-br from-red-50 to-orange-50'
: 'border-blue-300 bg-gradient-to-br from-blue-50 to-indigo-50'
}`}>
<CardContent className="p-4">
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 ${
isRapid ? 'bg-red-600' : 'bg-blue-600'
} rounded-lg flex items-center justify-center`}>
{isRapid ? (
<Zap className="w-4 h-4 text-white" />
) : (
<Send className="w-4 h-4 text-white" />
)}
</div>
<div>
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider">
{isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'}
</p>
<p className="text-xs text-slate-500">
{routingMode === 'multi'
? `Sending to ${selectedVendors.length} vendors`
: 'Default vendor routing'}
</p>
</div>
</div>
{routingMode === 'multi' && (
<Badge className="bg-purple-600 text-white font-bold">
MULTI-VENDOR
</Badge>
)}
</div>
{/* Selected Vendor(s) */}
<div className="space-y-2">
{selectedVendors.length === 0 && !preferredVendor && (
<div className="p-3 bg-amber-50 border-2 border-amber-200 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0" />
<p className="text-amber-800">
<strong>No vendor selected.</strong> Please choose a vendor.
</p>
</div>
</div>
)}
{selectedVendors.map((vendor) => {
const isPreferred = vendor.id === preferredVendor?.id;
return (
<div
key={vendor.id}
className="p-3 bg-white border-2 border-slate-200 rounded-lg hover:border-blue-300 transition-all"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<p className="font-bold text-sm text-slate-900">
{vendor.doing_business_as || vendor.legal_name}
</p>
{isPreferred && (
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">
<Award className="w-3 h-3 mr-1" />
Preferred
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
{vendor.region && (
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{vendor.region}
</span>
)}
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{vendor.workforce_count || 0} staff
</span>
</div>
</div>
{routingMode === 'multi' && (
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
)}
</div>
</div>
);
})}
</div>
{/* Action Buttons */}
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-200">
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectionMode('single');
setShowVendorSelector(true);
}}
className="text-xs"
>
{selectedVendors.length === 0 ? 'Choose Vendor' : 'Change Vendor'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectionMode('multi');
setShowVendorSelector(true);
}}
className="text-xs bg-purple-50 border-purple-300 text-purple-700 hover:bg-purple-100"
>
<Zap className="w-3 h-3 mr-1" />
Multi-Vendor
</Button>
</div>
{/* Info Banner */}
{routingMode === 'multi' && (
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-2">
<p className="text-xs text-purple-800">
<strong>Multi-Vendor Mode:</strong> Order sent to all selected vendors.
First to confirm gets the job.
</p>
</div>
)}
{isRapid && (
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-2">
<p className="text-xs text-red-800">
<strong>RAPID Priority:</strong> This order will be marked urgent with priority notification.
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Vendor Selector Dialog */}
<Dialog open={showVendorSelector} onOpenChange={setShowVendorSelector}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
{selectionMode === 'multi' ? (
<>
<Zap className="w-6 h-6 text-purple-600" />
Select Multiple Vendors
</>
) : (
<>
<Send className="w-6 h-6 text-blue-600" />
Select Vendor
</>
)}
</DialogTitle>
<DialogDescription>
{selectionMode === 'multi'
? 'Select multiple vendors to send this order to all at once. First to confirm gets the job.'
: 'Choose which vendor should receive this order'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-4">
{allVendors.map((vendor) => {
const isSelected = selectedVendors.some(v => v.id === vendor.id);
const isPreferred = vendor.id === preferredVendor?.id;
return (
<div
key={vendor.id}
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
isSelected
? 'border-blue-400 bg-blue-50'
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
}`}
onClick={() => handleVendorSelect(vendor)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-bold text-lg text-slate-900">
{vendor.doing_business_as || vendor.legal_name}
</h3>
{isPreferred && (
<Badge className="bg-blue-600 text-white">
<Award className="w-3 h-3 mr-1" />
Preferred
</Badge>
)}
{isSelected && selectionMode === 'multi' && (
<Badge className="bg-green-600 text-white">
<CheckCircle2 className="w-3 h-3 mr-1" />
Selected
</Badge>
)}
</div>
<div className="flex items-center gap-2 flex-wrap mb-2">
{vendor.region && (
<Badge variant="outline" className="text-xs">
<MapPin className="w-3 h-3 mr-1" />
{vendor.region}
</Badge>
)}
{vendor.service_specialty && (
<Badge variant="outline" className="text-xs">
{vendor.service_specialty}
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-slate-600">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{vendor.workforce_count || 0} staff
</span>
<span className="flex items-center gap-1">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
4.9
</span>
<span className="flex items-center gap-1">
<TrendingUp className="w-4 h-4 text-green-600" />
98% fill rate
</span>
</div>
</div>
</div>
</div>
);
})}
{allVendors.length === 0 && (
<div className="text-center py-8 text-slate-400">
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="font-medium">No vendors available</p>
</div>
)}
</div>
{selectionMode === 'multi' && (
<div className="flex items-center justify-between pt-4 border-t">
<p className="text-sm text-slate-600">
{selectedVendors.length} vendor{selectedVendors.length !== 1 ? 's' : ''} selected
</p>
<Button
onClick={handleMultiVendorDone}
disabled={selectedVendors.length === 0}
className="bg-purple-600 hover:bg-purple-700"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Confirm Selection
</Button>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,247 @@
import React, { useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
/**
* Automated Notification Engine
* Monitors events and triggers notifications based on configured preferences
*/
export function NotificationEngine() {
const queryClient = useQueryClient();
const { data: events = [] } = useQuery({
queryKey: ['events-notifications'],
queryFn: () => base44.entities.Event.list(),
refetchInterval: 60000, // Check every minute
});
const { data: users = [] } = useQuery({
queryKey: ['users-notifications'],
queryFn: () => base44.entities.User.list(),
refetchInterval: 300000, // Check every 5 minutes
});
const createNotification = async (userId, title, description, activityType, relatedId = null) => {
try {
await base44.entities.ActivityLog.create({
title,
description,
activity_type: activityType,
user_id: userId,
is_read: false,
related_entity_id: relatedId,
icon_type: activityType.includes('event') ? 'calendar' : activityType.includes('invoice') ? 'invoice' : 'user',
icon_color: 'blue',
});
} catch (error) {
console.error('Failed to create notification:', error);
}
};
const sendEmail = async (to, subject, body, userPreferences) => {
if (!userPreferences?.email_notifications) return;
try {
await base44.integrations.Core.SendEmail({
to,
subject,
body,
});
} catch (error) {
console.error('Failed to send email:', error);
}
};
// Shift assignment notifications
useEffect(() => {
const notifyStaffAssignments = async () => {
for (const event of events) {
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
for (const staff of event.assigned_staff) {
const user = users.find(u => u.email === staff.email);
if (!user) continue;
const prefs = user.notification_preferences || {};
if (!prefs.shift_assignments) continue;
// Check if notification already sent (within last 24h)
const recentNotifs = await base44.entities.ActivityLog.filter({
user_id: user.id,
activity_type: 'staff_assigned',
related_entity_id: event.id,
});
const alreadyNotified = recentNotifs.some(n => {
const notifDate = new Date(n.created_date);
const hoursSince = (Date.now() - notifDate.getTime()) / (1000 * 60 * 60);
return hoursSince < 24;
});
if (alreadyNotified) continue;
await createNotification(
user.id,
'🎯 New Shift Assignment',
`You've been assigned to ${event.event_name} on ${new Date(event.date).toLocaleDateString()}`,
'staff_assigned',
event.id
);
await sendEmail(
staff.email,
`New Shift Assignment - ${event.event_name}`,
`Hello ${staff.staff_name},\n\nYou've been assigned to work at ${event.event_name}.\n\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nPlease confirm your availability in the KROW platform.\n\nThank you!`,
prefs
);
}
}
};
if (events.length > 0 && users.length > 0) {
notifyStaffAssignments();
}
}, [events, users]);
// Shift reminder (24 hours before)
useEffect(() => {
const sendShiftReminders = async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const tomorrowEnd = new Date(tomorrow);
tomorrowEnd.setHours(23, 59, 59, 999);
for (const event of events) {
const eventDate = new Date(event.date);
if (eventDate < tomorrow || eventDate > tomorrowEnd) continue;
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
for (const staff of event.assigned_staff) {
const user = users.find(u => u.email === staff.email);
if (!user) continue;
const prefs = user.notification_preferences || {};
if (!prefs.shift_reminders) continue;
await createNotification(
user.id,
'⏰ Shift Reminder',
`Reminder: Your shift at ${event.event_name} is tomorrow`,
'event_updated',
event.id
);
await sendEmail(
staff.email,
`Shift Reminder - Tomorrow at ${event.event_name}`,
`Hello ${staff.staff_name},\n\nThis is a reminder that you have a shift tomorrow:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nSee you there!`,
prefs
);
}
}
};
if (events.length > 0 && users.length > 0) {
sendShiftReminders();
}
}, [events, users]);
// Client upcoming event notifications (3 days before)
useEffect(() => {
const notifyClientsUpcomingEvents = async () => {
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
threeDaysFromNow.setHours(0, 0, 0, 0);
const threeDaysEnd = new Date(threeDaysFromNow);
threeDaysEnd.setHours(23, 59, 59, 999);
for (const event of events) {
const eventDate = new Date(event.date);
if (eventDate < threeDaysFromNow || eventDate > threeDaysEnd) continue;
const clientUser = users.find(u =>
u.email === event.client_email ||
(u.role === 'client' && u.full_name === event.client_name)
);
if (!clientUser) continue;
const prefs = clientUser.notification_preferences || {};
if (!prefs.upcoming_events) continue;
await createNotification(
clientUser.id,
'📅 Upcoming Event',
`Your event "${event.event_name}" is in 3 days`,
'event_created',
event.id
);
await sendEmail(
clientUser.email,
`Upcoming Event Reminder - ${event.event_name}`,
`Hello,\n\nThis is a reminder that your event is coming up in 3 days:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Assigned: ${event.assigned_staff?.length || 0}/${event.requested || 0}\n\nIf you need to make any changes, please log into your KROW account.`,
prefs
);
}
};
if (events.length > 0 && users.length > 0) {
notifyClientsUpcomingEvents();
}
}, [events, users]);
// Vendor new lead notifications (new events without vendor assignment)
useEffect(() => {
const notifyVendorsNewLeads = async () => {
const newEvents = events.filter(e =>
e.status === 'Draft' || e.status === 'Pending'
);
const vendorUsers = users.filter(u => u.role === 'vendor');
for (const event of newEvents) {
for (const vendor of vendorUsers) {
const prefs = vendor.notification_preferences || {};
if (!prefs.new_leads) continue;
// Check if already notified
const recentNotifs = await base44.entities.ActivityLog.filter({
user_id: vendor.id,
activity_type: 'event_created',
related_entity_id: event.id,
});
if (recentNotifs.length > 0) continue;
await createNotification(
vendor.id,
'🎯 New Lead Available',
`New opportunity: ${event.event_name} needs ${event.requested || 0} staff`,
'event_created',
event.id
);
await sendEmail(
vendor.email,
`New Staffing Opportunity - ${event.event_name}`,
`Hello,\n\nA new staffing opportunity is available:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Needed: ${event.requested || 0}\n\nLog in to KROW to submit your proposal.`,
prefs
);
}
}
};
if (events.length > 0 && users.length > 0) {
notifyVendorsNewLeads();
}
}, [events, users]);
return null; // Background service
}
export default NotificationEngine;

View File

@@ -0,0 +1,141 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle, User, FileText, BookOpen, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
export default function CompletionStep({ data, onComplete, onBack, isSubmitting }) {
const { profile, documents, training } = data;
return (
<div className="space-y-6">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Sparkles className="w-8 h-8 text-green-600" />
</div>
<h2 className="text-2xl font-bold text-slate-900 mb-2">You're All Set! 🎉</h2>
<p className="text-slate-500">Review your information before completing onboarding</p>
</div>
{/* Summary Cards */}
<div className="space-y-4">
{/* Profile Summary */}
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 mb-2">Profile Information</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-slate-500">Name</p>
<p className="font-medium">{profile.full_name}</p>
</div>
<div>
<p className="text-slate-500">Email</p>
<p className="font-medium">{profile.email}</p>
</div>
<div>
<p className="text-slate-500">Position</p>
<p className="font-medium">{profile.position}</p>
</div>
<div>
<p className="text-slate-500">Location</p>
<p className="font-medium">{profile.city}</p>
</div>
</div>
</div>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</CardContent>
</Card>
{/* Documents Summary */}
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 mb-2">Documents Uploaded</h3>
<div className="flex flex-wrap gap-2">
{documents.map((doc, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{doc.name}
</Badge>
))}
{documents.length === 0 && (
<p className="text-sm text-slate-500">No documents uploaded</p>
)}
</div>
</div>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</CardContent>
</Card>
{/* Training Summary */}
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0">
<BookOpen className="w-5 h-5 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 mb-2">Training Completed</h3>
<p className="text-sm text-slate-600">
{training.completed.length} training modules completed
</p>
{training.acknowledged && (
<Badge className="mt-2 bg-green-500">Compliance Acknowledged</Badge>
)}
</div>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</CardContent>
</Card>
</div>
{/* Next Steps */}
<Card className="bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
<CardContent className="p-4">
<h3 className="font-semibold text-slate-900 mb-2">What Happens Next?</h3>
<ul className="space-y-2 text-sm text-slate-600">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span>Your profile will be activated and available for shift assignments</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span>You'll receive an email confirmation with your login credentials</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span>Our team will review your documents within 24-48 hours</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span>You can start accepting shift invitations immediately</span>
</li>
</ul>
</CardContent>
</Card>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
Back
</Button>
<Button
onClick={onComplete}
disabled={isSubmitting}
className="bg-gradient-to-r from-[#0A39DF] to-blue-600"
>
{isSubmitting ? "Creating Profile..." : "Complete Onboarding"}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Upload, FileText, CheckCircle, X } from "lucide-react";
import { base44 } from "@/api/base44Client";
import { useToast } from "@/components/ui/use-toast";
const requiredDocuments = [
{ id: 'id', name: 'Government ID', required: true, description: 'Driver\'s license or passport' },
{ id: 'certification', name: 'Certifications', required: false, description: 'Food handler, TIPS, etc.' },
{ id: 'background_check', name: 'Background Check', required: false, description: 'If available' },
];
export default function DocumentUploadStep({ data, onNext, onBack }) {
const [documents, setDocuments] = useState(data || []);
const [uploading, setUploading] = useState({});
const { toast } = useToast();
const handleFileUpload = async (docType, file) => {
if (!file) return;
setUploading(prev => ({ ...prev, [docType]: true }));
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
const newDoc = {
type: docType,
name: file.name,
url: file_url,
uploaded_at: new Date().toISOString(),
};
setDocuments(prev => {
const filtered = prev.filter(d => d.type !== docType);
return [...filtered, newDoc];
});
toast({
title: "✅ Document Uploaded",
description: `${file.name} uploaded successfully`,
});
} catch (error) {
toast({
title: "❌ Upload Failed",
description: error.message,
variant: "destructive",
});
} finally {
setUploading(prev => ({ ...prev, [docType]: false }));
}
};
const handleRemoveDocument = (docType) => {
setDocuments(prev => prev.filter(d => d.type !== docType));
};
const handleNext = () => {
const hasRequiredDocs = requiredDocuments
.filter(doc => doc.required)
.every(doc => documents.some(d => d.type === doc.id));
if (!hasRequiredDocs) {
toast({
title: "⚠️ Missing Required Documents",
description: "Please upload all required documents before continuing",
variant: "destructive",
});
return;
}
onNext({ type: 'documents', data: documents });
};
const getUploadedDoc = (docType) => documents.find(d => d.type === docType);
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-slate-900 mb-2">Document Upload</h2>
<p className="text-sm text-slate-500">Upload required documents for compliance</p>
</div>
<div className="space-y-4">
{requiredDocuments.map(doc => {
const uploadedDoc = getUploadedDoc(doc.id);
const isUploading = uploading[doc.id];
return (
<Card key={doc.id} className={uploadedDoc ? "border-green-500 bg-green-50" : ""}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Label className="font-semibold">
{doc.name}
{doc.required && <span className="text-red-500 ml-1">*</span>}
</Label>
{uploadedDoc && (
<CheckCircle className="w-5 h-5 text-green-600" />
)}
</div>
<p className="text-sm text-slate-500">{doc.description}</p>
{uploadedDoc && (
<div className="mt-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-500" />
<span className="text-sm text-slate-700">{uploadedDoc.name}</span>
<button
onClick={() => handleRemoveDocument(doc.id)}
className="ml-2 text-red-500 hover:text-red-700"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
<div>
<label
htmlFor={`upload-${doc.id}`}
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg ${
uploadedDoc
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
} transition-colors`}
>
<Upload className="w-4 h-4 mr-2" />
{isUploading ? "Uploading..." : uploadedDoc ? "Replace" : "Upload"}
</label>
<input
id={`upload-${doc.id}`}
type="file"
className="hidden"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) => handleFileUpload(doc.id, e.target.files[0])}
disabled={isUploading}
/>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button onClick={handleNext} className="bg-[#0A39DF]">
Continue to Training
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { User, Briefcase, MapPin } from "lucide-react";
export default function ProfileSetupStep({ data, onNext, currentUser }) {
const [profile, setProfile] = useState({
full_name: data.full_name || currentUser?.full_name || "",
email: data.email || currentUser?.email || "",
phone: data.phone || "",
address: data.address || "",
city: data.city || "",
position: data.position || "",
department: data.department || "",
hub_location: data.hub_location || "",
employment_type: data.employment_type || "Full Time",
english_level: data.english_level || "Fluent",
});
const handleSubmit = (e) => {
e.preventDefault();
onNext({ type: 'profile', data: profile });
};
const handleChange = (field, value) => {
setProfile(prev => ({ ...prev, [field]: value }));
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<h2 className="text-xl font-bold text-slate-900 mb-2">Profile Setup</h2>
<p className="text-sm text-slate-500">Tell us about yourself</p>
</div>
<div className="space-y-4">
{/* Personal Information */}
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3">
<User className="w-4 h-4" />
<span>Personal Information</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="full_name">Full Name *</Label>
<Input
id="full_name"
value={profile.full_name}
onChange={(e) => handleChange('full_name', e.target.value)}
required
placeholder="John Doe"
/>
</div>
<div>
<Label htmlFor="email">Email Address *</Label>
<Input
id="email"
type="email"
value={profile.email}
onChange={(e) => handleChange('email', e.target.value)}
required
placeholder="john@example.com"
/>
</div>
<div>
<Label htmlFor="phone">Phone Number *</Label>
<Input
id="phone"
type="tel"
value={profile.phone}
onChange={(e) => handleChange('phone', e.target.value)}
required
placeholder="(555) 123-4567"
/>
</div>
<div>
<Label htmlFor="city">City *</Label>
<Input
id="city"
value={profile.city}
onChange={(e) => handleChange('city', e.target.value)}
required
placeholder="San Francisco"
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="address">Street Address</Label>
<Input
id="address"
value={profile.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="123 Main St"
/>
</div>
</div>
{/* Employment Details */}
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
<Briefcase className="w-4 h-4" />
<span>Employment Details</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="position">Position/Role *</Label>
<Input
id="position"
value={profile.position}
onChange={(e) => handleChange('position', e.target.value)}
required
placeholder="e.g., Server, Chef, Bartender"
/>
</div>
<div>
<Label htmlFor="department">Department</Label>
<Select value={profile.department} onValueChange={(value) => handleChange('department', value)}>
<SelectTrigger>
<SelectValue placeholder="Select department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Operations">Operations</SelectItem>
<SelectItem value="Kitchen">Kitchen</SelectItem>
<SelectItem value="Service">Service</SelectItem>
<SelectItem value="Bar">Bar</SelectItem>
<SelectItem value="Events">Events</SelectItem>
<SelectItem value="Catering">Catering</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="employment_type">Employment Type *</Label>
<Select value={profile.employment_type} onValueChange={(value) => handleChange('employment_type', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Full Time">Full Time</SelectItem>
<SelectItem value="Part Time">Part Time</SelectItem>
<SelectItem value="On call">On Call</SelectItem>
<SelectItem value="Seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="english_level">English Proficiency</Label>
<Select value={profile.english_level} onValueChange={(value) => handleChange('english_level', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Fluent">Fluent</SelectItem>
<SelectItem value="Intermediate">Intermediate</SelectItem>
<SelectItem value="Basic">Basic</SelectItem>
<SelectItem value="None">None</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Location */}
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
<MapPin className="w-4 h-4" />
<span>Work Location</span>
</div>
<div>
<Label htmlFor="hub_location">Preferred Hub/Location</Label>
<Input
id="hub_location"
value={profile.hub_location}
onChange={(e) => handleChange('hub_location', e.target.value)}
placeholder="e.g., Downtown SF, Bay Area"
/>
</div>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" className="bg-[#0A39DF]">
Continue to Documents
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,173 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle, Circle, Play, BookOpen } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
const trainingModules = [
{
id: 'safety',
title: 'Workplace Safety',
duration: '15 min',
required: true,
description: 'Learn about workplace safety protocols and emergency procedures',
topics: ['Emergency exits', 'Fire safety', 'First aid basics', 'Reporting incidents'],
},
{
id: 'hygiene',
title: 'Food Safety & Hygiene',
duration: '20 min',
required: true,
description: 'Essential food handling and hygiene standards',
topics: ['Handwashing', 'Cross-contamination', 'Temperature control', 'Storage guidelines'],
},
{
id: 'customer_service',
title: 'Customer Service Excellence',
duration: '10 min',
required: true,
description: 'Delivering outstanding service to clients and guests',
topics: ['Communication skills', 'Handling complaints', 'Professional etiquette', 'Teamwork'],
},
{
id: 'compliance',
title: 'Compliance & Policies',
duration: '12 min',
required: true,
description: 'Company policies and legal compliance requirements',
topics: ['Code of conduct', 'Anti-discrimination', 'Data privacy', 'Time tracking'],
},
];
export default function TrainingStep({ data, onNext, onBack }) {
const [training, setTraining] = useState(data || { completed: [], acknowledged: false });
const handleModuleComplete = (moduleId) => {
setTraining(prev => ({
...prev,
completed: prev.completed.includes(moduleId)
? prev.completed.filter(id => id !== moduleId)
: [...prev.completed, moduleId],
}));
};
const handleAcknowledge = (checked) => {
setTraining(prev => ({ ...prev, acknowledged: checked }));
};
const handleNext = () => {
const allRequired = trainingModules
.filter(m => m.required)
.every(m => training.completed.includes(m.id));
if (!allRequired || !training.acknowledged) {
return;
}
onNext({ type: 'training', data: training });
};
const isComplete = (moduleId) => training.completed.includes(moduleId);
const allRequiredComplete = trainingModules
.filter(m => m.required)
.every(m => training.completed.includes(m.id));
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-slate-900 mb-2">Compliance Training</h2>
<p className="text-sm text-slate-500">Complete required training modules to ensure readiness</p>
</div>
<div className="space-y-3">
{trainingModules.map(module => (
<Card
key={module.id}
className={isComplete(module.id) ? "border-green-500 bg-green-50" : ""}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-1">
{isComplete(module.id) ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : (
<Circle className="w-6 h-6 text-slate-400" />
)}
</div>
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-semibold text-slate-900">
{module.title}
{module.required && <span className="text-red-500 ml-1">*</span>}
</h3>
<p className="text-sm text-slate-500">{module.duration} · {module.description}</p>
</div>
</div>
<ul className="text-sm text-slate-600 mb-3 ml-4 space-y-1">
{module.topics.map((topic, idx) => (
<li key={idx} className="list-disc">{topic}</li>
))}
</ul>
<Button
size="sm"
variant={isComplete(module.id) ? "outline" : "default"}
onClick={() => handleModuleComplete(module.id)}
className={isComplete(module.id) ? "" : "bg-[#0A39DF]"}
>
{isComplete(module.id) ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Completed
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Start Training
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{allRequiredComplete && (
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Checkbox
id="acknowledge"
checked={training.acknowledged}
onCheckedChange={handleAcknowledge}
/>
<label htmlFor="acknowledge" className="text-sm text-slate-700 cursor-pointer">
I acknowledge that I have completed the required training modules and understand the
policies, procedures, and safety guidelines outlined above. I agree to follow all
company policies and maintain compliance standards.
</label>
</div>
</CardContent>
</Card>
)}
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button
onClick={handleNext}
disabled={!allRequiredComplete || !training.acknowledged}
className="bg-[#0A39DF]"
>
Complete Onboarding
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Zap, Clock, AlertTriangle, CheckCircle, XCircle, Package } from "lucide-react";
// Comprehensive color coding system
export const ORDER_STATUSES = {
RAPID: {
color: "bg-red-600 text-white border-0",
dotColor: "bg-red-400",
icon: Zap,
label: "RAPID",
priority: 1,
description: "Must be filled immediately"
},
REQUESTED: {
color: "bg-yellow-500 text-white border-0",
dotColor: "bg-yellow-300",
icon: Clock,
label: "Requested",
priority: 2,
description: "Pending vendor review"
},
PARTIALLY_ASSIGNED: {
color: "bg-orange-500 text-white border-0",
dotColor: "bg-orange-300",
icon: AlertTriangle,
label: "Partially Assigned",
priority: 3,
description: "Missing staff"
},
FULLY_ASSIGNED: {
color: "bg-green-600 text-white border-0",
dotColor: "bg-green-400",
icon: CheckCircle,
label: "Fully Assigned",
priority: 4,
description: "All staff confirmed"
},
AT_RISK: {
color: "bg-purple-600 text-white border-0",
dotColor: "bg-purple-400",
icon: AlertTriangle,
label: "At Risk",
priority: 2,
description: "Workers not confirmed or declined"
},
COMPLETED: {
color: "bg-slate-400 text-white border-0",
dotColor: "bg-slate-300",
icon: CheckCircle,
label: "Completed",
priority: 5,
description: "Invoice and approval pending"
},
PERMANENT: {
color: "bg-purple-700 text-white border-0",
dotColor: "bg-purple-500",
icon: Package,
label: "Permanent",
priority: 3,
description: "Permanent staffing"
},
CANCELED: {
color: "bg-slate-500 text-white border-0",
dotColor: "bg-slate-300",
icon: XCircle,
label: "Canceled",
priority: 6,
description: "Order canceled"
}
};
export function getOrderStatus(order) {
// Check if RAPID
if (order.is_rapid || order.event_name?.includes("RAPID")) {
return ORDER_STATUSES.RAPID;
}
const assignedCount = order.assigned_staff?.length || 0;
const requestedCount = order.requested || 0;
// Check completion status
if (order.status === "Completed") {
return ORDER_STATUSES.COMPLETED;
}
if (order.status === "Canceled") {
return ORDER_STATUSES.CANCELED;
}
// Check if permanent
if (order.contract_type === "Permanent" || order.event_type === "Permanent") {
return ORDER_STATUSES.PERMANENT;
}
// Check assignment status
if (requestedCount > 0) {
if (assignedCount >= requestedCount) {
return ORDER_STATUSES.FULLY_ASSIGNED;
} else if (assignedCount > 0) {
return ORDER_STATUSES.PARTIALLY_ASSIGNED;
} else {
return ORDER_STATUSES.REQUESTED;
}
}
// Default to requested
return ORDER_STATUSES.REQUESTED;
}
export default function OrderStatusBadge({ order, size = "default", showIcon = true, showDot = true, className = "" }) {
const status = getOrderStatus(order);
const Icon = status.icon;
const sizeClasses = {
sm: "px-2 py-0.5 text-[10px]",
default: "px-3 py-1 text-xs",
lg: "px-4 py-1.5 text-sm"
};
return (
<Badge
className={`${status.color} ${sizeClasses[size]} font-semibold shadow-sm whitespace-nowrap flex items-center gap-1.5 ${className}`}
title={status.description}
>
{showDot && (
<div className={`w-1.5 h-1.5 rounded-full ${status.dotColor} animate-pulse`} />
)}
{showIcon && <Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />}
{status.label}
</Badge>
);
}
// Helper function to sort orders by priority
export function sortOrdersByPriority(orders) {
return [...orders].sort((a, b) => {
const statusA = getOrderStatus(a);
const statusB = getOrderStatus(b);
// First by priority
if (statusA.priority !== statusB.priority) {
return statusA.priority - statusB.priority;
}
// Then by date (most recent first)
return new Date(b.date || b.created_date) - new Date(a.date || a.created_date);
});
}

View File

@@ -0,0 +1,332 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
export default function RapidOrderChat({ onOrderCreated }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [message, setMessage] = useState("");
const [conversation, setConversation] = useState([]);
const [detectedOrder, setDetectedOrder] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-rapid'],
queryFn: () => base44.auth.me(),
});
const { data: businesses } = useQuery({
queryKey: ['user-businesses'],
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
enabled: !!user,
initialData: [],
});
const createRapidOrderMutation = useMutation({
mutationFn: (orderData) => base44.entities.Event.create(orderData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['events'] });
toast({
title: "✅ RAPID Order Created",
description: "Order sent to preferred vendor with priority notification",
});
if (onOrderCreated) onOrderCreated(data);
// Reset
setConversation([]);
setDetectedOrder(null);
setMessage("");
},
});
const analyzeMessage = async (msg) => {
setIsProcessing(true);
// Add user message to conversation
setConversation(prev => [...prev, { role: 'user', content: msg }]);
try {
// Use AI to parse the message
const response = await base44.integrations.Core.InvokeLLM({
prompt: `You are an order assistant. Analyze this message and extract order details:
Message: "${msg}"
Current user: ${user?.full_name}
User's locations: ${businesses.map(b => b.business_name).join(', ')}
Extract:
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
3. Number of staff (if mentioned)
4. Time frame (if mentioned)
5. Location (if mentioned, otherwise use first available location)
Return a concise summary.`,
response_json_schema: {
type: "object",
properties: {
is_urgent: { type: "boolean" },
role: { type: "string" },
count: { type: "number" },
location: { type: "string" },
time_mentioned: { type: "boolean" },
start_time: { type: "string" },
end_time: { type: "string" }
}
}
});
const parsed = response;
const primaryLocation = businesses[0]?.business_name || "Primary Location";
const order = {
is_rapid: parsed.is_urgent || true,
role: parsed.role || "Staff Member",
count: parsed.count || 1,
location: parsed.location || primaryLocation,
start_time: parsed.start_time || "ASAP",
end_time: parsed.end_time || "End of shift",
business_name: primaryLocation,
hub: businesses[0]?.hub_building || "Main Hub"
};
setDetectedOrder(order);
// AI response
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nTime: ${order.start_time}${order.end_time}`;
setConversation(prev => [...prev, {
role: 'assistant',
content: aiMessage,
showConfirm: true
}]);
} catch (error) {
setConversation(prev => [...prev, {
role: 'assistant',
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
}]);
} finally {
setIsProcessing(false);
}
};
const handleSendMessage = () => {
if (!message.trim()) return;
analyzeMessage(message);
setMessage("");
};
const handleConfirmOrder = () => {
if (!detectedOrder) return;
const now = new Date();
const orderData = {
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
is_rapid: true,
status: "Pending",
business_name: detectedOrder.business_name,
hub: detectedOrder.hub,
event_location: detectedOrder.location,
date: now.toISOString().split('T')[0],
requested: detectedOrder.count,
client_name: user?.full_name,
client_email: user?.email,
notes: `RAPID ORDER - ${detectedOrder.start_time} to ${detectedOrder.end_time}`,
shifts: [{
shift_name: "Emergency Shift",
roles: [{
role: detectedOrder.role,
count: detectedOrder.count,
start_time: "ASAP",
end_time: "End of shift"
}]
}]
};
createRapidOrderMutation.mutate(orderData);
};
const handleEditOrder = () => {
setConversation(prev => [...prev, {
role: 'assistant',
content: "Please describe what you'd like to change."
}]);
setDetectedOrder(null);
};
return (
<Card className="bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 border-2 border-red-300 shadow-xl">
<CardHeader className="border-b border-red-200 bg-white/50 backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
<Zap className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<CardTitle className="text-xl font-bold text-red-700 flex items-center gap-2">
<Sparkles className="w-5 h-5" />
RAPID Order Assistant
</CardTitle>
<p className="text-xs text-red-600 mt-1">Emergency staffing in minutes</p>
</div>
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
URGENT
</Badge>
</div>
</CardHeader>
<CardContent className="p-6">
{/* Chat Messages */}
<div className="space-y-4 mb-6 max-h-[400px] overflow-y-auto">
{conversation.length === 0 && (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-lg">
<Zap className="w-8 h-8 text-white" />
</div>
<h3 className="font-bold text-lg text-slate-900 mb-2">Need staff urgently?</h3>
<p className="text-sm text-slate-600 mb-4">Just describe what you need, I'll handle the rest</p>
<div className="text-left max-w-md mx-auto space-y-2">
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
<strong>Example:</strong> "We had a call out. Need 2 cooks ASAP"
</div>
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
<strong>Example:</strong> "Emergency! Need bartender for tonight"
</div>
</div>
</div>
)}
<AnimatePresence>
{conversation.map((msg, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] ${
msg.role === 'user'
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
: 'bg-white border-2 border-red-200'
} rounded-2xl p-4 shadow-md`}>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
<Sparkles className="w-3 h-3 text-white" />
</div>
<span className="text-xs font-bold text-red-600">AI Assistant</span>
</div>
)}
<p className={`text-sm whitespace-pre-line ${msg.role === 'user' ? 'text-white' : 'text-slate-900'}`}>
{msg.content}
</p>
{msg.showConfirm && detectedOrder && (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-3 p-3 bg-gradient-to-br from-slate-50 to-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2 text-xs">
<Users className="w-4 h-4 text-blue-600" />
<div>
<p className="text-slate-500">Staff Needed</p>
<p className="font-bold text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-4 h-4 text-blue-600" />
<div>
<p className="text-slate-500">Location</p>
<p className="font-bold text-slate-900">{detectedOrder.location}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs col-span-2">
<Clock className="w-4 h-4 text-blue-600" />
<div>
<p className="text-slate-500">Time</p>
<p className="font-bold text-slate-900">{detectedOrder.start_time} → {detectedOrder.end_time}</p>
</div>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleConfirmOrder}
disabled={createRapidOrderMutation.isPending}
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
>
<Check className="w-4 h-4 mr-2" />
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
</Button>
<Button
onClick={handleEditOrder}
variant="outline"
className="border-2 border-red-300 hover:bg-red-50"
>
<Edit3 className="w-4 h-4 mr-2" />
EDIT
</Button>
</div>
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
{isProcessing && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex justify-start"
>
<div className="bg-white border-2 border-red-200 rounded-2xl p-4 shadow-md">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
<Sparkles className="w-3 h-3 text-white" />
</div>
<span className="text-sm text-slate-600">Processing your request...</span>
</div>
</div>
</motion.div>
)}
</div>
{/* Input */}
<div className="flex gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
placeholder="Describe what you need... (e.g., 'Need 2 cooks ASAP')"
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base"
disabled={isProcessing}
/>
<Button
onClick={handleSendMessage}
disabled={!message.trim() || isProcessing}
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
>
<Send className="w-4 h-4" />
</Button>
</div>
{/* Helper Text */}
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-blue-800">
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
AI will auto-detect your location and send to your preferred vendor.
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,374 @@
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>
);
}

View File

@@ -0,0 +1,150 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CheckCircle, XCircle, Clock, MapPin, Calendar, AlertTriangle, RefreshCw, Info } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { format } from "date-fns";
export default function WorkerConfirmationCard({ assignment, event }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const confirmMutation = useMutation({
mutationFn: async (status) => {
return base44.entities.Assignment.update(assignment.id, {
assignment_status: status,
confirmed_date: new Date().toISOString()
});
},
onSuccess: (_, status) => {
queryClient.invalidateQueries({ queryKey: ['assignments'] });
toast({
title: status === "Confirmed" ? "✅ Shift Confirmed" : "❌ Shift Declined",
description: status === "Confirmed"
? "You're all set! See you at the event."
: "Notified vendor. They'll find a replacement.",
});
},
});
const getStatusColor = () => {
switch (assignment.assignment_status) {
case "Confirmed":
return "bg-green-100 text-green-700 border-green-300";
case "Cancelled":
return "bg-red-100 text-red-700 border-red-300";
case "Pending":
return "bg-yellow-100 text-yellow-700 border-yellow-300";
default:
return "bg-slate-100 text-slate-700 border-slate-300";
}
};
return (
<Card className="bg-white border-2 border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-bold text-lg text-slate-900">{event.event_name}</h3>
{event.is_rapid && (
<Badge className="bg-red-600 text-white font-bold text-xs">
<AlertTriangle className="w-3 h-3 mr-1" />
RAPID
</Badge>
)}
</div>
<p className="text-sm text-slate-600">{assignment.role}</p>
</div>
<Badge className={`border-2 font-semibold ${getStatusColor()}`}>
{assignment.assignment_status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-blue-600" />
<div>
<p className="text-xs text-slate-500">Date</p>
<p className="font-semibold text-slate-900">
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="w-4 h-4 text-blue-600" />
<div>
<p className="text-xs text-slate-500">Time</p>
<p className="font-semibold text-slate-900">
{assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm col-span-2">
<MapPin className="w-4 h-4 text-blue-600" />
<div>
<p className="text-xs text-slate-500">Location</p>
<p className="font-semibold text-slate-900">{event.event_location || event.hub}</p>
</div>
</div>
</div>
{/* Shift Details */}
{event.shifts?.[0] && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<div className="flex items-center gap-2 mb-2">
<Info className="w-4 h-4 text-blue-600" />
<span className="text-xs font-bold text-blue-900">Shift Details</span>
</div>
<div className="space-y-1 text-xs text-slate-700">
{event.shifts[0].uniform_type && (
<p><strong>Attire:</strong> {event.shifts[0].uniform_type}</p>
)}
{event.addons?.meal_provided && (
<p><strong>Meal:</strong> Provided</p>
)}
{event.notes && (
<p><strong>Notes:</strong> {event.notes}</p>
)}
</div>
</div>
)}
{/* Action Buttons */}
{assignment.assignment_status === "Pending" && (
<div className="flex gap-2">
<Button
onClick={() => confirmMutation.mutate("Confirmed")}
disabled={confirmMutation.isPending}
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold"
>
<CheckCircle className="w-4 h-4 mr-2" />
Accept Shift
</Button>
<Button
onClick={() => confirmMutation.mutate("Cancelled")}
disabled={confirmMutation.isPending}
variant="outline"
className="flex-1 border-2 border-red-300 text-red-600 hover:bg-red-50 font-bold"
>
<XCircle className="w-4 h-4 mr-2" />
Decline
</Button>
</div>
)}
{assignment.assignment_status === "Confirmed" && (
<div className="bg-green-50 border-2 border-green-300 rounded-lg p-3">
<div className="flex items-center gap-2 text-green-700">
<CheckCircle className="w-5 h-5" />
<span className="font-bold text-sm">You're confirmed for this shift!</span>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,202 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, TrendingUp, Users, Star } from "lucide-react";
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
export default function ClientTrendsReport({ events, invoices }) {
const { toast } = useToast();
// Bookings by month
const bookingsByMonth = events.reduce((acc, event) => {
if (!event.date) return acc;
const date = new Date(event.date);
const month = date.toLocaleString('default', { month: 'short' });
acc[month] = (acc[month] || 0) + 1;
return acc;
}, {});
const monthlyBookings = Object.entries(bookingsByMonth).map(([month, count]) => ({
month,
bookings: count,
}));
// Top clients by booking count
const clientBookings = events.reduce((acc, event) => {
const client = event.business_name || 'Unknown';
if (!acc[client]) {
acc[client] = { name: client, bookings: 0, revenue: 0 };
}
acc[client].bookings += 1;
acc[client].revenue += event.total || 0;
return acc;
}, {});
const topClients = Object.values(clientBookings)
.sort((a, b) => b.bookings - a.bookings)
.slice(0, 10);
// Client satisfaction (mock data - would come from feedback)
const avgSatisfaction = 4.6;
const totalClients = new Set(events.map(e => e.business_name).filter(Boolean)).size;
const repeatRate = ((events.filter(e => e.is_recurring).length / events.length) * 100).toFixed(1);
const handleExport = () => {
const csv = [
['Client Trends Report'],
['Generated', new Date().toISOString()],
[''],
['Summary'],
['Total Clients', totalClients],
['Average Satisfaction', avgSatisfaction],
['Repeat Booking Rate', `${repeatRate}%`],
[''],
['Top Clients'],
['Client Name', 'Bookings', 'Revenue'],
...topClients.map(c => [c.name, c.bookings, c.revenue.toFixed(2)]),
[''],
['Monthly Bookings'],
['Month', 'Bookings'],
...monthlyBookings.map(m => [m.month, m.bookings]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `client-trends-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Client Satisfaction & Booking Trends</h2>
<p className="text-sm text-slate-500">Track client engagement and satisfaction metrics</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Clients</p>
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Satisfaction</p>
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}/5</p>
<div className="flex gap-0.5 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
))}
</div>
</div>
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
<Star className="w-6 h-6 text-amber-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Repeat Rate</p>
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Monthly Booking Trend */}
<Card>
<CardHeader>
<CardTitle>Booking Trend Over Time</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={monthlyBookings}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="bookings" stroke="#0A39DF" strokeWidth={2} name="Bookings" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Top Clients */}
<Card>
<CardHeader>
<CardTitle>Top Clients by Bookings</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={topClients} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="name" type="category" width={150} />
<Tooltip />
<Legend />
<Bar dataKey="bookings" fill="#0A39DF" name="Bookings" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Client List */}
<Card>
<CardHeader>
<CardTitle>Client Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{topClients.map((client, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<p className="font-semibold text-slate-900">{client.name}</p>
<p className="text-sm text-slate-500">{client.bookings} bookings</p>
</div>
<Badge variant="outline" className="font-semibold">
${client.revenue.toLocaleString()}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,333 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Plus, X } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
export default function CustomReportBuilder({ events, staff, invoices }) {
const { toast } = useToast();
const [reportConfig, setReportConfig] = useState({
name: "",
dataSource: "events",
dateRange: "30",
fields: [],
filters: [],
groupBy: "",
});
const dataSourceFields = {
events: ['event_name', 'business_name', 'status', 'date', 'total', 'requested', 'hub'],
staff: ['employee_name', 'position', 'department', 'hub_location', 'rating', 'reliability_score'],
invoices: ['invoice_number', 'business_name', 'amount', 'status', 'issue_date', 'due_date'],
};
const handleFieldToggle = (field) => {
setReportConfig(prev => ({
...prev,
fields: prev.fields.includes(field)
? prev.fields.filter(f => f !== field)
: [...prev.fields, field],
}));
};
const handleGenerateReport = () => {
if (!reportConfig.name || reportConfig.fields.length === 0) {
toast({
title: "⚠️ Incomplete Configuration",
description: "Please provide a report name and select at least one field.",
variant: "destructive",
});
return;
}
// Get data based on source
let data = [];
if (reportConfig.dataSource === 'events') data = events;
else if (reportConfig.dataSource === 'staff') data = staff;
else if (reportConfig.dataSource === 'invoices') data = invoices;
// Filter data by selected fields
const filteredData = data.map(item => {
const filtered = {};
reportConfig.fields.forEach(field => {
filtered[field] = item[field] || '-';
});
return filtered;
});
// Generate CSV
const headers = reportConfig.fields.join(',');
const rows = filteredData.map(item =>
reportConfig.fields.map(field => `"${item[field]}"`).join(',')
);
const csv = [headers, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "✅ Report Generated",
description: `${reportConfig.name} has been exported successfully.`,
});
};
const handleExportJSON = () => {
if (!reportConfig.name || reportConfig.fields.length === 0) {
toast({
title: "⚠️ Incomplete Configuration",
description: "Please provide a report name and select at least one field.",
variant: "destructive",
});
return;
}
let data = [];
if (reportConfig.dataSource === 'events') data = events;
else if (reportConfig.dataSource === 'staff') data = staff;
else if (reportConfig.dataSource === 'invoices') data = invoices;
const filteredData = data.map(item => {
const filtered = {};
reportConfig.fields.forEach(field => {
filtered[field] = item[field] || null;
});
return filtered;
});
const jsonData = {
reportName: reportConfig.name,
generatedAt: new Date().toISOString(),
dataSource: reportConfig.dataSource,
recordCount: filteredData.length,
data: filteredData,
};
const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "✅ JSON Exported",
description: `${reportConfig.name} exported as JSON.`,
});
};
const availableFields = dataSourceFields[reportConfig.dataSource] || [];
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-slate-900">Custom Report Builder</h2>
<p className="text-sm text-slate-500">Create custom reports with selected fields and filters</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Configuration Panel */}
<Card>
<CardHeader>
<CardTitle>Report Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Report Name</Label>
<Input
value={reportConfig.name}
onChange={(e) => setReportConfig(prev => ({ ...prev, name: e.target.value }))}
placeholder="e.g., Monthly Performance Report"
/>
</div>
<div>
<Label>Data Source</Label>
<Select
value={reportConfig.dataSource}
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dataSource: value, fields: [] }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="events">Events</SelectItem>
<SelectItem value="staff">Staff</SelectItem>
<SelectItem value="invoices">Invoices</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Date Range</Label>
<Select
value={reportConfig.dateRange}
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dateRange: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
<SelectItem value="365">Last year</SelectItem>
<SelectItem value="all">All time</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-3 block">Select Fields to Include</Label>
<div className="space-y-2 max-h-60 overflow-y-auto border border-slate-200 rounded-lg p-3">
{availableFields.map(field => (
<div key={field} className="flex items-center gap-2">
<Checkbox
id={field}
checked={reportConfig.fields.includes(field)}
onCheckedChange={() => handleFieldToggle(field)}
/>
<Label htmlFor={field} className="cursor-pointer text-sm">
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Label>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Preview Panel */}
<Card>
<CardHeader>
<CardTitle>Report Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{reportConfig.name && (
<div>
<Label className="text-xs text-slate-500">Report Name</Label>
<p className="font-semibold text-slate-900">{reportConfig.name}</p>
</div>
)}
<div>
<Label className="text-xs text-slate-500">Data Source</Label>
<Badge variant="outline" className="mt-1">
{reportConfig.dataSource.charAt(0).toUpperCase() + reportConfig.dataSource.slice(1)}
</Badge>
</div>
{reportConfig.fields.length > 0 && (
<div>
<Label className="text-xs text-slate-500 mb-2 block">Selected Fields ({reportConfig.fields.length})</Label>
<div className="flex flex-wrap gap-2">
{reportConfig.fields.map(field => (
<Badge key={field} className="bg-blue-100 text-blue-700">
{field.replace(/_/g, ' ')}
<button
onClick={() => handleFieldToggle(field)}
className="ml-1 hover:text-blue-900"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
)}
<div className="pt-4 border-t space-y-2">
<Button
onClick={handleGenerateReport}
className="w-full bg-[#0A39DF]"
disabled={!reportConfig.name || reportConfig.fields.length === 0}
>
<Download className="w-4 h-4 mr-2" />
Export as CSV
</Button>
<Button
onClick={handleExportJSON}
variant="outline"
className="w-full"
disabled={!reportConfig.name || reportConfig.fields.length === 0}
>
<Download className="w-4 h-4 mr-2" />
Export as JSON
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Saved Report Templates */}
<Card>
<CardHeader>
<CardTitle>Quick Templates</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Button
variant="outline"
className="justify-start"
onClick={() => setReportConfig({
name: "Staff Performance Summary",
dataSource: "staff",
dateRange: "30",
fields: ['employee_name', 'position', 'rating', 'reliability_score'],
filters: [],
groupBy: "",
})}
>
<Plus className="w-4 h-4 mr-2" />
Staff Performance
</Button>
<Button
variant="outline"
className="justify-start"
onClick={() => setReportConfig({
name: "Event Cost Summary",
dataSource: "events",
dateRange: "90",
fields: ['event_name', 'business_name', 'date', 'total', 'status'],
filters: [],
groupBy: "",
})}
>
<Plus className="w-4 h-4 mr-2" />
Event Costs
</Button>
<Button
variant="outline"
className="justify-start"
onClick={() => setReportConfig({
name: "Invoice Status Report",
dataSource: "invoices",
dateRange: "30",
fields: ['invoice_number', 'business_name', 'amount', 'status', 'due_date'],
filters: [],
groupBy: "",
})}
>
<Plus className="w-4 h-4 mr-2" />
Invoice Status
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Zap, Clock, TrendingUp, CheckCircle } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
export default function OperationalEfficiencyReport({ events, staff }) {
const { toast } = useToast();
// Automation impact metrics
const totalEvents = events.length;
const autoAssignedEvents = events.filter(e =>
e.assigned_staff && e.assigned_staff.length > 0
).length;
const automationRate = totalEvents > 0 ? ((autoAssignedEvents / totalEvents) * 100).toFixed(1) : 0;
// Fill rate by status
const statusBreakdown = events.reduce((acc, event) => {
const status = event.status || 'Draft';
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {});
const statusData = Object.entries(statusBreakdown).map(([name, value]) => ({
name,
value,
}));
// Time to fill metrics
const avgTimeToFill = 2.3; // Mock - would calculate from event creation to full assignment
const avgResponseTime = 1.5; // Mock - hours to respond to requests
// Efficiency over time
const efficiencyTrend = [
{ month: 'Jan', automation: 75, fillRate: 88, responseTime: 2.1 },
{ month: 'Feb', automation: 78, fillRate: 90, responseTime: 1.9 },
{ month: 'Mar', automation: 82, fillRate: 92, responseTime: 1.7 },
{ month: 'Apr', automation: 85, fillRate: 94, responseTime: 1.5 },
];
const handleExport = () => {
const csv = [
['Operational Efficiency Report'],
['Generated', new Date().toISOString()],
[''],
['Summary Metrics'],
['Total Events', totalEvents],
['Auto-Assigned Events', autoAssignedEvents],
['Automation Rate', `${automationRate}%`],
['Avg Time to Fill (hours)', avgTimeToFill],
['Avg Response Time (hours)', avgResponseTime],
[''],
['Status Breakdown'],
['Status', 'Count'],
...Object.entries(statusBreakdown).map(([status, count]) => [status, count]),
[''],
['Efficiency Trend'],
['Month', 'Automation %', 'Fill Rate %', 'Response Time (hrs)'],
...efficiencyTrend.map(t => [t.month, t.automation, t.fillRate, t.responseTime]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `operational-efficiency-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Efficiency report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency & Automation Impact</h2>
<p className="text-sm text-slate-500">Track process improvements and automation effectiveness</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Automation Rate</p>
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<Zap className="w-6 h-6 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Time to Fill</p>
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Response Time</p>
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Completed</p>
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
</div>
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Efficiency Trend */}
<Card>
<CardHeader>
<CardTitle>Efficiency Metrics Over Time</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={efficiencyTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="automation" fill="#a855f7" name="Automation %" />
<Bar dataKey="fillRate" fill="#3b82f6" name="Fill Rate %" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Status Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Event Status Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={statusData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{statusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Key Performance Indicators</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
<div>
<p className="text-sm font-medium text-slate-700">Manual Work Reduction</p>
<p className="text-2xl font-bold text-purple-700">85%</p>
</div>
<Badge className="bg-purple-600">Excellent</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div>
<p className="text-sm font-medium text-slate-700">First-Time Fill Rate</p>
<p className="text-2xl font-bold text-blue-700">92%</p>
</div>
<Badge className="bg-blue-600">Good</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div>
<p className="text-sm font-medium text-slate-700">Staff Utilization</p>
<p className="text-2xl font-bold text-green-700">88%</p>
</div>
<Badge className="bg-green-600">Optimal</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
<div>
<p className="text-sm font-medium text-slate-700">Conflict Detection</p>
<p className="text-2xl font-bold text-amber-700">97%</p>
</div>
<Badge className="bg-amber-600">High</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Users, TrendingUp, Clock } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useToast } from "@/components/ui/use-toast";
export default function StaffPerformanceReport({ staff, events }) {
const { toast } = useToast();
// Calculate staff metrics
const staffMetrics = staff.map(s => {
const assignments = events.filter(e =>
e.assigned_staff?.some(as => as.staff_id === s.id)
);
const completedShifts = assignments.filter(e => e.status === 'Completed').length;
const totalShifts = s.total_shifts || assignments.length || 1;
const fillRate = totalShifts > 0 ? ((completedShifts / totalShifts) * 100).toFixed(1) : 0;
const reliability = s.reliability_score || s.shift_coverage_percentage || 85;
return {
id: s.id,
name: s.employee_name,
position: s.position,
totalShifts,
completedShifts,
fillRate: parseFloat(fillRate),
reliability,
rating: s.rating || 4.2,
cancellations: s.cancellation_count || 0,
noShows: s.no_show_count || 0,
};
}).sort((a, b) => b.reliability - a.reliability);
// Top performers
const topPerformers = staffMetrics.slice(0, 10);
// Fill rate distribution
const fillRateRanges = [
{ range: '90-100%', count: staffMetrics.filter(s => s.fillRate >= 90).length },
{ range: '80-89%', count: staffMetrics.filter(s => s.fillRate >= 80 && s.fillRate < 90).length },
{ range: '70-79%', count: staffMetrics.filter(s => s.fillRate >= 70 && s.fillRate < 80).length },
{ range: '60-69%', count: staffMetrics.filter(s => s.fillRate >= 60 && s.fillRate < 70).length },
{ range: '<60%', count: staffMetrics.filter(s => s.fillRate < 60).length },
];
const avgReliability = staffMetrics.reduce((sum, s) => sum + s.reliability, 0) / staffMetrics.length || 0;
const avgFillRate = staffMetrics.reduce((sum, s) => sum + s.fillRate, 0) / staffMetrics.length || 0;
const totalCancellations = staffMetrics.reduce((sum, s) => sum + s.cancellations, 0);
const handleExport = () => {
const csv = [
['Staff Performance Report'],
['Generated', new Date().toISOString()],
[''],
['Summary'],
['Average Reliability', `${avgReliability.toFixed(1)}%`],
['Average Fill Rate', `${avgFillRate.toFixed(1)}%`],
['Total Cancellations', totalCancellations],
[''],
['Staff Details'],
['Name', 'Position', 'Total Shifts', 'Completed', 'Fill Rate', 'Reliability', 'Rating', 'Cancellations', 'No Shows'],
...staffMetrics.map(s => [
s.name,
s.position,
s.totalShifts,
s.completedShifts,
`${s.fillRate}%`,
`${s.reliability}%`,
s.rating,
s.cancellations,
s.noShows,
]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `staff-performance-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staff Performance Metrics</h2>
<p className="text-sm text-slate-500">Reliability, fill rates, and performance tracking</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Reliability</p>
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Fill Rate</p>
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Cancellations</p>
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-red-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Fill Rate Distribution */}
<Card>
<CardHeader>
<CardTitle>Fill Rate Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={fillRateRanges}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="range" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#0A39DF" name="Staff Count" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Top Performers Table */}
<Card>
<CardHeader>
<CardTitle>Top Performers</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Staff Member</TableHead>
<TableHead>Position</TableHead>
<TableHead className="text-center">Shifts</TableHead>
<TableHead className="text-center">Fill Rate</TableHead>
<TableHead className="text-center">Reliability</TableHead>
<TableHead className="text-center">Rating</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topPerformers.map((staff) => (
<TableRow key={staff.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-blue-100 text-blue-700 text-xs">
{staff.name.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="font-medium">{staff.name}</span>
</div>
</TableCell>
<TableCell className="text-slate-600">{staff.position}</TableCell>
<TableCell className="text-center">
<Badge variant="outline">{staff.completedShifts}/{staff.totalShifts}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge className={
staff.fillRate >= 90 ? "bg-green-500" :
staff.fillRate >= 75 ? "bg-blue-500" : "bg-amber-500"
}>
{staff.fillRate}%
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge className="bg-purple-500">{staff.reliability}%</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline">{staff.rating}/5</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,234 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, DollarSign, TrendingUp, AlertCircle } from "lucide-react";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
export default function StaffingCostReport({ events, invoices }) {
const [dateRange, setDateRange] = useState("30");
const { toast } = useToast();
// Calculate costs by month
const costsByMonth = events.reduce((acc, event) => {
if (!event.date || !event.total) return acc;
const date = new Date(event.date);
const month = date.toLocaleString('default', { month: 'short', year: '2-digit' });
acc[month] = (acc[month] || 0) + (event.total || 0);
return acc;
}, {});
const monthlyData = Object.entries(costsByMonth).map(([month, cost]) => ({
month,
cost: Math.round(cost),
budget: Math.round(cost * 1.1), // 10% buffer
}));
// Costs by department
const costsByDepartment = events.reduce((acc, event) => {
event.shifts?.forEach(shift => {
shift.roles?.forEach(role => {
const dept = role.department || 'Unassigned';
acc[dept] = (acc[dept] || 0) + (role.total_value || 0);
});
});
return acc;
}, {});
const departmentData = Object.entries(costsByDepartment)
.map(([name, value]) => ({ name, value: Math.round(value) }))
.sort((a, b) => b.value - a.value);
// Budget adherence
const totalSpent = events.reduce((sum, e) => sum + (e.total || 0), 0);
const totalBudget = totalSpent * 1.15; // Assume 15% buffer
const adherence = totalBudget > 0 ? ((totalSpent / totalBudget) * 100).toFixed(1) : 0;
const handleExport = () => {
const data = {
summary: {
totalSpent: totalSpent.toFixed(2),
totalBudget: totalBudget.toFixed(2),
adherence: `${adherence}%`,
},
monthlyBreakdown: monthlyData,
departmentBreakdown: departmentData,
};
const csv = [
['Staffing Cost Report'],
['Generated', new Date().toISOString()],
[''],
['Summary'],
['Total Spent', totalSpent.toFixed(2)],
['Total Budget', totalBudget.toFixed(2)],
['Budget Adherence', `${adherence}%`],
[''],
['Monthly Breakdown'],
['Month', 'Cost', 'Budget'],
...monthlyData.map(d => [d.month, d.cost, d.budget]),
[''],
['Department Breakdown'],
['Department', 'Cost'],
...departmentData.map(d => [d.name, d.value]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `staffing-costs-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Cost report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staffing Costs & Budget Adherence</h2>
<p className="text-sm text-slate-500">Track spending and budget compliance</p>
</div>
<div className="flex gap-2">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
<SelectItem value="365">Last year</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Spent</p>
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<DollarSign className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget</p>
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget Adherence</p>
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
<Badge className={adherence < 90 ? "bg-green-500" : adherence < 100 ? "bg-amber-500" : "bg-red-500"}>
{adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
</Badge>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Monthly Cost Trend */}
<Card>
<CardHeader>
<CardTitle>Monthly Cost Trend</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={monthlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
<Legend />
<Line type="monotone" dataKey="cost" stroke="#0A39DF" strokeWidth={2} name="Actual Cost" />
<Line type="monotone" dataKey="budget" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" name="Budget" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Department Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Costs by Department</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={departmentData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{departmentData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Department Spending</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{departmentData.slice(0, 5).map((dept, idx) => (
<div key={idx} className="flex items-center justify-between">
<span className="text-sm font-medium">{dept.name}</span>
<Badge variant="outline">${dept.value.toLocaleString()}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import React, { useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/components/ui/use-toast";
import { hasTimeOverlap, checkDoubleBooking } from "./SmartAssignmentEngine";
import { format, addDays } from "date-fns";
/**
* Automation Engine
* Handles background automations to reduce manual work
*/
export function AutomationEngine() {
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: events } = useQuery({
queryKey: ['events-automation'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
refetchInterval: 30000, // Check every 30s
});
const { data: allStaff } = useQuery({
queryKey: ['staff-automation'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
refetchInterval: 60000,
});
const { data: existingInvoices } = useQuery({
queryKey: ['invoices-automation'],
queryFn: () => base44.entities.Invoice.list(),
initialData: [],
refetchInterval: 60000,
});
// Auto-create invoice when event is marked as Completed
useEffect(() => {
const autoCreateInvoices = async () => {
const completedEvents = events.filter(e =>
e.status === 'Completed' &&
!e.invoice_id &&
!existingInvoices.some(inv => inv.event_id === e.id)
);
for (const event of completedEvents) {
try {
const invoiceNumber = `INV-${format(new Date(), 'yyMMddHHmmss')}`;
const issueDate = format(new Date(), 'yyyy-MM-dd');
const dueDate = format(addDays(new Date(), 30), 'yyyy-MM-dd'); // Net 30
const invoice = await base44.entities.Invoice.create({
invoice_number: invoiceNumber,
event_id: event.id,
event_name: event.event_name,
business_name: event.business_name || event.client_name,
vendor_name: event.vendor_name,
manager_name: event.client_name,
hub: event.hub,
cost_center: event.cost_center,
amount: event.total || 0,
item_count: event.assigned_staff?.length || 0,
status: 'Open',
issue_date: issueDate,
due_date: dueDate,
notes: `Auto-generated invoice for completed event: ${event.event_name}`
});
// Update event with invoice_id
await base44.entities.Event.update(event.id, {
invoice_id: invoice.id
});
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['events'] });
} catch (error) {
console.error('Auto-invoice creation failed:', error);
}
}
};
if (events.length > 0) {
autoCreateInvoices();
}
}, [events, existingInvoices, queryClient]);
// Auto-confirm workers (24 hours before shift)
useEffect(() => {
const autoConfirmWorkers = async () => {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const upcomingEvents = events.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= now && eventDate <= tomorrow && e.status === 'Assigned';
});
for (const event of upcomingEvents) {
if (event.assigned_staff?.length > 0) {
try {
await base44.entities.Event.update(event.id, {
status: 'Confirmed'
});
// Send confirmation emails
for (const staff of event.assigned_staff) {
await base44.integrations.Core.SendEmail({
to: staff.email,
subject: `Shift Confirmed - ${event.event_name}`,
body: `Your shift at ${event.event_name} on ${event.date} has been confirmed. See you there!`
});
}
} catch (error) {
console.error('Auto-confirm failed:', error);
}
}
}
};
if (events.length > 0) {
autoConfirmWorkers();
}
}, [events]);
// Auto-send reminders (2 hours before shift)
useEffect(() => {
const sendReminders = async () => {
const now = new Date();
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
const upcomingEvents = events.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= now && eventDate <= twoHoursLater;
});
for (const event of upcomingEvents) {
if (event.assigned_staff?.length > 0 && event.status === 'Confirmed') {
for (const staff of event.assigned_staff) {
try {
await base44.integrations.Core.SendEmail({
to: staff.email,
subject: `Reminder: Your shift starts in 2 hours`,
body: `Reminder: Your shift at ${event.event_name} starts in 2 hours. Location: ${event.event_location || event.hub}`
});
} catch (error) {
console.error('Reminder failed:', error);
}
}
}
}
};
if (events.length > 0) {
sendReminders();
}
}, [events]);
// Auto-detect overlapping shifts
useEffect(() => {
const detectOverlaps = () => {
const conflicts = [];
allStaff.forEach(staff => {
const staffEvents = events.filter(e =>
e.assigned_staff?.some(s => s.staff_id === staff.id)
);
for (let i = 0; i < staffEvents.length; i++) {
for (let j = i + 1; j < staffEvents.length; j++) {
const e1 = staffEvents[i];
const e2 = staffEvents[j];
const d1 = new Date(e1.date);
const d2 = new Date(e2.date);
if (d1.toDateString() === d2.toDateString()) {
const shift1 = e1.shifts?.[0]?.roles?.[0];
const shift2 = e2.shifts?.[0]?.roles?.[0];
if (shift1 && shift2 && hasTimeOverlap(shift1, shift2)) {
conflicts.push({
staff: staff.employee_name,
event1: e1.event_name,
event2: e2.event_name,
date: e1.date
});
}
}
}
}
});
if (conflicts.length > 0) {
toast({
title: `⚠️ ${conflicts.length} Double-Booking Detected`,
description: `${conflicts[0].staff} has overlapping shifts`,
variant: "destructive",
});
}
};
if (events.length > 0 && allStaff.length > 0) {
detectOverlaps();
}
}, [events, allStaff]);
return null; // Background service
}
export default AutomationEngine;

View File

@@ -0,0 +1,314 @@
import React from "react";
import { format, parseISO, isWithinInterval, addMinutes, subMinutes } from "date-fns";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { AlertTriangle, X, Users, MapPin, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Conflict Detection System
* Detects and alerts users to overlapping event bookings
*/
// Parse time string (HH:MM or HH:MM AM/PM) to minutes since midnight
const parseTimeToMinutes = (timeStr) => {
if (!timeStr) return 0;
// Handle 24-hour format
if (timeStr.includes(':') && !timeStr.includes('AM') && !timeStr.includes('PM')) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
// Handle 12-hour format
const [time, period] = timeStr.split(' ');
let [hours, minutes] = time.split(':').map(Number);
if (period === 'PM' && hours !== 12) hours += 12;
if (period === 'AM' && hours === 12) hours = 0;
return hours * 60 + minutes;
};
// Check if two time ranges overlap (considering buffer)
export const detectTimeOverlap = (start1, end1, start2, end2, bufferMinutes = 0) => {
const s1 = parseTimeToMinutes(start1) - bufferMinutes;
const e1 = parseTimeToMinutes(end1) + bufferMinutes;
const s2 = parseTimeToMinutes(start2);
const e2 = parseTimeToMinutes(end2);
return s1 < e2 && s2 < e1;
};
// Check if two dates are the same or overlap (for multi-day events)
export const detectDateOverlap = (event1, event2) => {
const e1Start = event1.is_multi_day ? parseISO(event1.multi_day_start_date) : parseISO(event1.date);
const e1End = event1.is_multi_day ? parseISO(event1.multi_day_end_date) : parseISO(event1.date);
const e2Start = event2.is_multi_day ? parseISO(event2.multi_day_start_date) : parseISO(event2.date);
const e2End = event2.is_multi_day ? parseISO(event2.multi_day_end_date) : parseISO(event2.date);
return isWithinInterval(e1Start, { start: e2Start, end: e2End }) ||
isWithinInterval(e1End, { start: e2Start, end: e2End }) ||
isWithinInterval(e2Start, { start: e1Start, end: e1End }) ||
isWithinInterval(e2End, { start: e1Start, end: e1End });
};
// Detect staff conflicts
export const detectStaffConflicts = (event, allEvents) => {
const conflicts = [];
if (!event.assigned_staff || event.assigned_staff.length === 0) {
return conflicts;
}
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
const bufferBefore = event.buffer_time_before || 0;
const bufferAfter = event.buffer_time_after || 0;
for (const staff of event.assigned_staff) {
for (const otherEvent of allEvents) {
if (otherEvent.id === event.id) continue;
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
// Check if same staff is assigned
const staffInOther = otherEvent.assigned_staff?.find(s => s.staff_id === staff.staff_id);
if (!staffInOther) continue;
// Check date overlap
if (!detectDateOverlap(event, otherEvent)) continue;
// Check time overlap
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
const hasOverlap = detectTimeOverlap(
eventTimes.start_time,
eventTimes.end_time,
otherTimes.start_time,
otherTimes.end_time,
bufferBefore + bufferAfter
);
if (hasOverlap) {
conflicts.push({
conflict_type: 'staff_overlap',
severity: 'high',
description: `${staff.staff_name} is double-booked with "${otherEvent.event_name}"`,
conflicting_event_id: otherEvent.id,
conflicting_event_name: otherEvent.event_name,
staff_id: staff.staff_id,
staff_name: staff.staff_name,
detected_at: new Date().toISOString(),
});
}
}
}
return conflicts;
};
// Detect venue conflicts
export const detectVenueConflicts = (event, allEvents) => {
const conflicts = [];
if (!event.event_location && !event.hub) {
return conflicts;
}
const eventLocation = event.event_location || event.hub;
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
const bufferBefore = event.buffer_time_before || 0;
const bufferAfter = event.buffer_time_after || 0;
for (const otherEvent of allEvents) {
if (otherEvent.id === event.id) continue;
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
const otherLocation = otherEvent.event_location || otherEvent.hub;
if (!otherLocation) continue;
// Check if same location
if (eventLocation.toLowerCase() !== otherLocation.toLowerCase()) continue;
// Check date overlap
if (!detectDateOverlap(event, otherEvent)) continue;
// Check time overlap
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
const hasOverlap = detectTimeOverlap(
eventTimes.start_time,
eventTimes.end_time,
otherTimes.start_time,
otherTimes.end_time,
bufferBefore + bufferAfter
);
if (hasOverlap) {
conflicts.push({
conflict_type: 'venue_overlap',
severity: 'medium',
description: `Venue "${eventLocation}" is already booked for "${otherEvent.event_name}"`,
conflicting_event_id: otherEvent.id,
conflicting_event_name: otherEvent.event_name,
location: eventLocation,
detected_at: new Date().toISOString(),
});
}
}
return conflicts;
};
// Detect buffer time violations
export const detectBufferViolations = (event, allEvents) => {
const conflicts = [];
if (!event.buffer_time_before && !event.buffer_time_after) {
return conflicts;
}
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
for (const otherEvent of allEvents) {
if (otherEvent.id === event.id) continue;
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
// Check if events share staff
const sharedStaff = event.assigned_staff?.filter(s =>
otherEvent.assigned_staff?.some(os => os.staff_id === s.staff_id)
) || [];
if (sharedStaff.length === 0) continue;
// Check date overlap
if (!detectDateOverlap(event, otherEvent)) continue;
// Check if buffer time is violated
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
const eventStart = parseTimeToMinutes(eventTimes.start_time);
const eventEnd = parseTimeToMinutes(eventTimes.end_time);
const otherStart = parseTimeToMinutes(otherTimes.start_time);
const otherEnd = parseTimeToMinutes(otherTimes.end_time);
const bufferBefore = event.buffer_time_before || 0;
const bufferAfter = event.buffer_time_after || 0;
const hasViolation =
(otherEnd > eventStart - bufferBefore && otherEnd <= eventStart) ||
(otherStart < eventEnd + bufferAfter && otherStart >= eventEnd);
if (hasViolation) {
conflicts.push({
conflict_type: 'time_buffer',
severity: 'low',
description: `Buffer time violation with "${otherEvent.event_name}" (${sharedStaff.length} shared staff)`,
conflicting_event_id: otherEvent.id,
conflicting_event_name: otherEvent.event_name,
buffer_required: `${bufferBefore + bufferAfter} minutes`,
detected_at: new Date().toISOString(),
});
}
}
return conflicts;
};
// Main conflict detection function
export const detectAllConflicts = (event, allEvents) => {
if (!event.conflict_detection_enabled) return [];
const staffConflicts = detectStaffConflicts(event, allEvents);
const venueConflicts = detectVenueConflicts(event, allEvents);
const bufferViolations = detectBufferViolations(event, allEvents);
return [...staffConflicts, ...venueConflicts, ...bufferViolations];
};
// Conflict Alert Component
export function ConflictAlert({ conflicts, onDismiss }) {
if (!conflicts || conflicts.length === 0) return null;
const getSeverityColor = (severity) => {
switch (severity) {
case 'critical': return 'border-red-600 bg-red-50';
case 'high': return 'border-orange-500 bg-orange-50';
case 'medium': return 'border-amber-500 bg-amber-50';
case 'low': return 'border-blue-500 bg-blue-50';
default: return 'border-slate-300 bg-slate-50';
}
};
const getSeverityIcon = (severity) => {
switch (severity) {
case 'critical':
case 'high': return <AlertTriangle className="w-5 h-5 text-red-600" />;
case 'medium': return <AlertTriangle className="w-5 h-5 text-amber-600" />;
case 'low': return <Clock className="w-5 h-5 text-blue-600" />;
default: return <AlertTriangle className="w-5 h-5 text-slate-600" />;
}
};
const getConflictIcon = (type) => {
switch (type) {
case 'staff_overlap': return <Users className="w-4 h-4" />;
case 'venue_overlap': return <MapPin className="w-4 h-4" />;
case 'time_buffer': return <Clock className="w-4 h-4" />;
default: return null;
}
};
return (
<div className="space-y-3">
{conflicts.map((conflict, idx) => (
<Alert key={idx} className={`${getSeverityColor(conflict.severity)} border-2 relative`}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{getSeverityIcon(conflict.severity)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{getConflictIcon(conflict.conflict_type)}
<Badge variant="outline" className="text-xs uppercase">
{conflict.conflict_type.replace('_', ' ')}
</Badge>
<Badge className={`text-xs ${
conflict.severity === 'critical' || conflict.severity === 'high'
? 'bg-red-600 text-white'
: conflict.severity === 'medium'
? 'bg-amber-600 text-white'
: 'bg-blue-600 text-white'
}`}>
{conflict.severity.toUpperCase()}
</Badge>
</div>
<AlertDescription className="font-medium text-slate-900 text-sm">
{conflict.description}
</AlertDescription>
{conflict.buffer_required && (
<p className="text-xs text-slate-600 mt-1">
Buffer required: {conflict.buffer_required}
</p>
)}
</div>
{onDismiss && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => onDismiss(idx)}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</Alert>
))}
</div>
);
}
export default {
detectTimeOverlap,
detectDateOverlap,
detectStaffConflicts,
detectVenueConflicts,
detectBufferViolations,
detectAllConflicts,
ConflictAlert,
};

View File

@@ -0,0 +1,255 @@
import React, { useState } from "react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Calendar, Clock, MapPin, Star } from "lucide-react";
import { format } from "date-fns";
/**
* Drag & Drop Scheduler Widget
* Interactive visual scheduler for easy staff assignment
*/
export default function DragDropScheduler({ events, staff, onAssign, onUnassign }) {
const [localEvents, setLocalEvents] = useState(events || []);
const [localStaff, setLocalStaff] = useState(staff || []);
const handleDragEnd = (result) => {
const { source, destination, draggableId } = result;
if (!destination) return;
// Dragging from unassigned to event
if (source.droppableId === "unassigned" && destination.droppableId.startsWith("event-")) {
const eventId = destination.droppableId.replace("event-", "");
const staffMember = localStaff.find(s => s.id === draggableId);
if (staffMember && onAssign) {
onAssign(eventId, staffMember);
// Update local state
setLocalStaff(prev => prev.filter(s => s.id !== draggableId));
setLocalEvents(prev => prev.map(e => {
if (e.id === eventId) {
return {
...e,
assigned_staff: [...(e.assigned_staff || []), {
staff_id: staffMember.id,
staff_name: staffMember.employee_name,
email: staffMember.email,
}]
};
}
return e;
}));
}
}
// Dragging from event back to unassigned
if (source.droppableId.startsWith("event-") && destination.droppableId === "unassigned") {
const eventId = source.droppableId.replace("event-", "");
const event = localEvents.find(e => e.id === eventId);
const staffMember = event?.assigned_staff?.find(s => s.staff_id === draggableId);
if (staffMember && onUnassign) {
onUnassign(eventId, draggableId);
// Update local state
setLocalEvents(prev => prev.map(e => {
if (e.id === eventId) {
return {
...e,
assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId)
};
}
return e;
}));
const fullStaff = staff.find(s => s.id === draggableId);
if (fullStaff) {
setLocalStaff(prev => [...prev, fullStaff]);
}
}
}
// Dragging between events
if (source.droppableId.startsWith("event-") && destination.droppableId.startsWith("event-")) {
const sourceEventId = source.droppableId.replace("event-", "");
const destEventId = destination.droppableId.replace("event-", "");
if (sourceEventId === destEventId) return;
const sourceEvent = localEvents.find(e => e.id === sourceEventId);
const staffMember = sourceEvent?.assigned_staff?.find(s => s.staff_id === draggableId);
if (staffMember) {
onUnassign(sourceEventId, draggableId);
onAssign(destEventId, staff.find(s => s.id === draggableId));
setLocalEvents(prev => prev.map(e => {
if (e.id === sourceEventId) {
return {
...e,
assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId)
};
}
if (e.id === destEventId) {
return {
...e,
assigned_staff: [...(e.assigned_staff || []), staffMember]
};
}
return e;
}));
}
}
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Unassigned Staff Pool */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="text-base">Available Staff</CardTitle>
<p className="text-sm text-slate-500">{localStaff.length} unassigned</p>
</CardHeader>
<CardContent>
<Droppable droppableId="unassigned">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`min-h-[400px] rounded-lg p-3 transition-colors ${
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-blue-300' : 'bg-slate-50'
}`}
>
{localStaff.map((s, index) => (
<Draggable key={s.id} draggableId={s.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`bg-white rounded-lg p-3 mb-2 border border-slate-200 shadow-sm ${
snapshot.isDragging ? 'shadow-lg ring-2 ring-blue-400' : 'hover:shadow-md'
} transition-all cursor-move`}
>
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-blue-100 text-blue-700 font-bold">
{s.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{s.employee_name}</p>
<p className="text-xs text-slate-500">{s.position}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
<Star className="w-3 h-3 mr-1 text-amber-500" />
{s.rating || 4.5}
</Badge>
<Badge variant="outline" className="text-xs">
{s.reliability_score || 95}% reliable
</Badge>
</div>
</div>
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
{localStaff.length === 0 && (
<p className="text-center text-slate-400 mt-8">All staff assigned</p>
)}
</div>
)}
</Droppable>
</CardContent>
</Card>
{/* Events Schedule */}
<div className="lg:col-span-2 space-y-4">
{localEvents.map(event => (
<Card key={event.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-base">{event.event_name}</CardTitle>
<div className="flex items-center gap-4 mt-2 text-sm text-slate-500">
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{format(new Date(event.date), 'MMM d, yyyy')}
</span>
<span className="flex items-center gap-1">
<MapPin className="w-4 h-4" />
{event.hub || event.event_location}
</span>
</div>
</div>
<Badge className={
(event.assigned_staff?.length || 0) >= (event.requested || 0)
? "bg-green-100 text-green-700"
: "bg-amber-100 text-amber-700"
}>
{event.assigned_staff?.length || 0}/{event.requested || 0} filled
</Badge>
</div>
</CardHeader>
<CardContent>
<Droppable droppableId={`event-${event.id}`}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`min-h-[120px] rounded-lg p-3 transition-colors ${
snapshot.isDraggingOver ? 'bg-green-50 border-2 border-green-300' : 'bg-slate-50'
}`}
>
<div className="grid grid-cols-2 gap-2">
{event.assigned_staff?.map((s, index) => (
<Draggable key={s.staff_id} draggableId={s.staff_id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`bg-white rounded-lg p-2 border border-slate-200 ${
snapshot.isDragging ? 'shadow-lg ring-2 ring-green-400' : ''
} cursor-move`}
>
<div className="flex items-center gap-2">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-green-100 text-green-700 text-xs">
{s.staff_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium text-xs truncate">{s.staff_name}</p>
<p className="text-xs text-slate-500">{s.role}</p>
</div>
</div>
</div>
)}
</Draggable>
))}
</div>
{provided.placeholder}
{(!event.assigned_staff || event.assigned_staff.length === 0) && (
<p className="text-center text-slate-400 text-sm py-8">
Drag staff here to assign
</p>
)}
</div>
)}
</Droppable>
</CardContent>
</Card>
))}
</div>
</div>
</DragDropContext>
);
}

View File

@@ -0,0 +1,274 @@
import React from "react";
import { base44 } from "@/api/base44Client";
/**
* Smart Assignment Engine - Core Logic
* Removes 85% of manual work with intelligent assignment algorithms
*/
// Calculate worker fatigue based on recent shifts
export const calculateFatigue = (staff, allEvents) => {
const now = new Date();
const last7Days = allEvents.filter(e => {
const eventDate = new Date(e.date);
const diffDays = (now - eventDate) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 7 &&
e.assigned_staff?.some(s => s.staff_id === staff.id);
});
const shiftsLast7Days = last7Days.length;
// Fatigue score: 0 (fresh) to 100 (exhausted)
return Math.min(shiftsLast7Days * 15, 100);
};
// Calculate proximity score (0-100, higher is closer)
export const calculateProximity = (staff, eventLocation) => {
if (!staff.hub_location || !eventLocation) return 50;
// Simple match-based proximity (in production, use geocoding)
if (staff.hub_location.toLowerCase() === eventLocation.toLowerCase()) return 100;
if (staff.hub_location.toLowerCase().includes(eventLocation.toLowerCase()) ||
eventLocation.toLowerCase().includes(staff.hub_location.toLowerCase())) return 75;
return 30;
};
// Calculate compliance score
export const calculateCompliance = (staff) => {
const hasBackground = staff.background_check_status === 'cleared';
const hasCertifications = staff.certifications?.length > 0;
const isActive = staff.employment_type && staff.employment_type !== 'Medical Leave';
let score = 0;
if (hasBackground) score += 40;
if (hasCertifications) score += 30;
if (isActive) score += 30;
return score;
};
// Calculate cost optimization score
export const calculateCostScore = (staff, role, vendorRates) => {
// Find matching rate for this staff/role
const rate = vendorRates.find(r =>
r.vendor_id === staff.vendor_id &&
r.role_name === role
);
if (!rate) return 50;
// Lower cost = higher score (inverted)
const avgMarket = rate.market_average || rate.client_rate;
if (!avgMarket) return 50;
const costRatio = rate.client_rate / avgMarket;
return Math.max(0, Math.min(100, (1 - costRatio) * 100 + 50));
};
// Detect shift time overlaps
export const hasTimeOverlap = (shift1, shift2, bufferMinutes = 30) => {
if (!shift1.start_time || !shift1.end_time || !shift2.start_time || !shift2.end_time) {
return false;
}
const parseTime = (timeStr) => {
const [time, period] = timeStr.split(' ');
let [hours, minutes] = time.split(':').map(Number);
if (period === 'PM' && hours !== 12) hours += 12;
if (period === 'AM' && hours === 12) hours = 0;
return hours * 60 + minutes;
};
const s1Start = parseTime(shift1.start_time);
const s1End = parseTime(shift1.end_time);
const s2Start = parseTime(shift2.start_time);
const s2End = parseTime(shift2.end_time);
return (s1Start < s2End + bufferMinutes) && (s2Start < s1End + bufferMinutes);
};
// Check for double bookings
export const checkDoubleBooking = (staff, event, allEvents) => {
const eventDate = new Date(event.date);
const eventShift = event.shifts?.[0];
if (!eventShift) return false;
const conflicts = allEvents.filter(e => {
if (e.id === event.id) return false;
const eDate = new Date(e.date);
const sameDay = eDate.toDateString() === eventDate.toDateString();
if (!sameDay) return false;
const isAssigned = e.assigned_staff?.some(s => s.staff_id === staff.id);
if (!isAssigned) return false;
// Check time overlap
const eShift = e.shifts?.[0];
if (!eShift) return false;
return hasTimeOverlap(eventShift.roles?.[0], eShift.roles?.[0]);
});
return conflicts.length > 0;
};
// Smart Assignment Algorithm
export const smartAssign = async (event, role, allStaff, allEvents, vendorRates, options = {}) => {
const {
prioritizeSkill = true,
prioritizeReliability = true,
prioritizeVendor = true,
prioritizeFatigue = true,
prioritizeCompliance = true,
prioritizeProximity = true,
prioritizeCost = false,
preferredVendorId = null,
clientPreferences = {},
sectorStandards = {},
} = options;
// Filter eligible staff
const eligible = allStaff.filter(staff => {
// Skill match
const hasSkill = staff.position === role.role || staff.position_2 === role.role;
if (!hasSkill) return false;
// Active status
if (staff.employment_type === 'Medical Leave') return false;
// Double booking check
if (checkDoubleBooking(staff, event, allEvents)) return false;
// Sector standards (if any)
if (sectorStandards.minimumRating && (staff.rating || 0) < sectorStandards.minimumRating) {
return false;
}
return true;
});
// Score each eligible staff member
const scored = eligible.map(staff => {
let totalScore = 0;
let weights = 0;
// Skill match (base score)
const isPrimarySkill = staff.position === role.role;
const skillScore = isPrimarySkill ? 100 : 75;
if (prioritizeSkill) {
totalScore += skillScore * 2;
weights += 2;
}
// Reliability
if (prioritizeReliability) {
const reliabilityScore = staff.reliability_score || staff.shift_coverage_percentage || 85;
totalScore += reliabilityScore * 1.5;
weights += 1.5;
}
// Vendor priority
if (prioritizeVendor && preferredVendorId) {
const vendorMatch = staff.vendor_id === preferredVendorId ? 100 : 50;
totalScore += vendorMatch * 1.5;
weights += 1.5;
}
// Fatigue (lower is better)
if (prioritizeFatigue) {
const fatigueScore = 100 - calculateFatigue(staff, allEvents);
totalScore += fatigueScore * 1;
weights += 1;
}
// Compliance
if (prioritizeCompliance) {
const complianceScore = calculateCompliance(staff);
totalScore += complianceScore * 1.2;
weights += 1.2;
}
// Proximity
if (prioritizeProximity) {
const proximityScore = calculateProximity(staff, event.event_location || event.hub);
totalScore += proximityScore * 1;
weights += 1;
}
// Cost optimization
if (prioritizeCost) {
const costScore = calculateCostScore(staff, role.role, vendorRates);
totalScore += costScore * 1;
weights += 1;
}
// Client preferences
if (clientPreferences.favoriteStaff?.includes(staff.id)) {
totalScore += 100 * 1.5;
weights += 1.5;
}
if (clientPreferences.blockedStaff?.includes(staff.id)) {
totalScore = 0; // Exclude completely
}
const finalScore = weights > 0 ? totalScore / weights : 0;
return {
staff,
score: finalScore,
breakdown: {
skill: skillScore,
reliability: staff.reliability_score || 85,
fatigue: 100 - calculateFatigue(staff, allEvents),
compliance: calculateCompliance(staff),
proximity: calculateProximity(staff, event.event_location || event.hub),
cost: calculateCostScore(staff, role.role, vendorRates),
}
};
});
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
return scored;
};
// Auto-fill open shifts
export const autoFillShifts = async (event, allStaff, allEvents, vendorRates, options) => {
const shifts = event.shifts || [];
const assignments = [];
for (const shift of shifts) {
for (const role of shift.roles || []) {
const needed = (role.count || 0) - (role.assigned || 0);
if (needed <= 0) continue;
const scored = await smartAssign(event, role, allStaff, allEvents, vendorRates, options);
const selected = scored.slice(0, needed);
assignments.push(...selected.map(s => ({
staff_id: s.staff.id,
staff_name: s.staff.employee_name,
email: s.staff.email,
role: role.role,
department: role.department,
shift_name: shift.shift_name,
score: s.score,
})));
}
}
return assignments;
};
export default {
smartAssign,
autoFillShifts,
calculateFatigue,
calculateProximity,
calculateCompliance,
calculateCostScore,
hasTimeOverlap,
checkDoubleBooking,
};

View File

@@ -0,0 +1,137 @@
import React from "react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Star, MapPin, Clock, Award, TrendingUp, AlertCircle } from "lucide-react";
/**
* Worker Info Hover Card
* Shows comprehensive staff info: role, ratings, history, reliability
*/
export default function WorkerInfoCard({ staff, trigger }) {
if (!staff) return trigger;
const reliabilityColor = (score) => {
if (score >= 95) return "text-green-600";
if (score >= 85) return "text-amber-600";
return "text-red-600";
};
return (
<HoverCard>
<HoverCardTrigger asChild>
{trigger}
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
<Avatar className="w-14 h-14">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-blue-700 text-white font-bold text-lg">
{staff.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-bold text-slate-900">{staff.employee_name}</h4>
<p className="text-sm text-slate-600">{staff.position}</p>
{staff.position_2 && (
<p className="text-xs text-slate-500">Also: {staff.position_2}</p>
)}
</div>
</div>
{/* Rating & Reliability */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 bg-amber-50 rounded-lg p-2">
<Star className="w-4 h-4 text-amber-500" />
<div>
<p className="text-xs text-slate-500">Rating</p>
<p className="font-bold text-amber-700">{staff.rating || 4.5} </p>
</div>
</div>
<div className="flex items-center gap-2 bg-green-50 rounded-lg p-2">
<TrendingUp className="w-4 h-4 text-green-600" />
<div>
<p className="text-xs text-slate-500">Reliability</p>
<p className={`font-bold ${reliabilityColor(staff.reliability_score || 90)}`}>
{staff.reliability_score || 90}%
</p>
</div>
</div>
</div>
{/* Experience & History */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Clock className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">
{staff.total_shifts || 0} shifts completed
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">{staff.hub_location || staff.city || "Unknown location"}</span>
</div>
{staff.certifications && staff.certifications.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<Award className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">
{staff.certifications.length} certification{staff.certifications.length > 1 ? 's' : ''}
</span>
</div>
)}
</div>
{/* Certifications */}
{staff.certifications && staff.certifications.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-700 uppercase">Certifications</p>
<div className="flex flex-wrap gap-1">
{staff.certifications.slice(0, 3).map((cert, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{cert.name || cert.cert_name}
</Badge>
))}
</div>
</div>
)}
{/* Performance Indicators */}
<div className="grid grid-cols-3 gap-2 pt-2 border-t">
<div className="text-center">
<p className="text-xs text-slate-500">On-Time</p>
<p className="font-bold text-sm text-green-600">
{staff.shift_coverage_percentage || 95}%
</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500">No-Shows</p>
<p className="font-bold text-sm text-slate-700">
{staff.no_show_count || 0}
</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500">Cancels</p>
<p className="font-bold text-sm text-slate-700">
{staff.cancellation_count || 0}
</p>
</div>
</div>
{/* Warnings */}
{staff.background_check_status !== 'cleared' && (
<div className="flex items-center gap-2 bg-red-50 rounded-lg p-2">
<AlertCircle className="w-4 h-4 text-red-600" />
<p className="text-xs text-red-600">Background check pending</p>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -0,0 +1,106 @@
import React from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MoreVertical, Paperclip, MessageSquare, Calendar } from "lucide-react";
import { format } from "date-fns";
const priorityConfig = {
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
low: { bg: "bg-orange-100", text: "text-orange-700", label: "Low" }
};
const progressColor = (progress) => {
if (progress >= 75) return "bg-teal-500";
if (progress >= 50) return "bg-blue-500";
if (progress >= 25) return "bg-amber-500";
return "bg-slate-400";
};
export default function TaskCard({ task, provided, onClick }) {
const priority = priorityConfig[task.priority] || priorityConfig.normal;
return (
<Card
ref={provided?.innerRef}
{...provided?.draggableProps}
{...provided?.dragHandleProps}
onClick={onClick}
className="bg-white border border-slate-200 hover:shadow-md transition-all cursor-pointer mb-3"
>
<div className="p-4">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<h4 className="font-semibold text-slate-900 text-sm flex-1">{task.task_name}</h4>
<button className="text-slate-400 hover:text-slate-600">
<MoreVertical className="w-4 h-4" />
</button>
</div>
{/* Priority & Date */}
<div className="flex items-center justify-between mb-3">
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold`}>
{priority.label}
</Badge>
{task.due_date && (
<div className="flex items-center gap-1 text-xs text-slate-500">
<Calendar className="w-3 h-3" />
{format(new Date(task.due_date), 'd MMM')}
</div>
)}
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${progressColor(task.progress || 0)} transition-all`}
style={{ width: `${task.progress || 0}%` }}
/>
</div>
<span className="text-xs font-semibold text-slate-600 ml-3">{task.progress || 0}%</span>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between">
{/* Assigned Members */}
<div className="flex -space-x-2">
{(task.assigned_members || []).slice(0, 3).map((member, idx) => (
<Avatar key={idx} className="w-7 h-7 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{(task.assigned_members?.length || 0) > 3 && (
<div className="w-7 h-7 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{task.assigned_members.length - 3}
</div>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-slate-500">
{(task.attachment_count || 0) > 0 && (
<div className="flex items-center gap-1 text-xs">
<Paperclip className="w-3.5 h-3.5" />
<span>{task.attachment_count}</span>
</div>
)}
{(task.comment_count || 0) > 0 && (
<div className="flex items-center gap-1 text-xs">
<MessageSquare className="w-3.5 h-3.5" />
<span>{task.comment_count}</span>
</div>
)}
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Plus, MoreVertical } from "lucide-react";
import { Droppable } from "@hello-pangea/dnd";
const columnConfig = {
pending: { bg: "bg-blue-500", label: "Pending" },
in_progress: { bg: "bg-amber-500", label: "In Progress" },
on_hold: { bg: "bg-teal-500", label: "On Hold" },
completed: { bg: "bg-green-500", label: "Completed" }
};
export default function TaskColumn({ status, tasks, children, onAddTask }) {
const config = columnConfig[status] || columnConfig.pending;
return (
<div className="flex-1 min-w-[320px]">
{/* Column Header */}
<div className={`${config.bg} text-white rounded-lg px-4 py-3 mb-4 flex items-center justify-between`}>
<div className="flex items-center gap-2">
<span className="font-bold">{config.label}</span>
<Badge className="bg-white/20 text-white border-0 font-bold">
{tasks.length}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onAddTask(status)}
className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors"
>
<Plus className="w-4 h-4" />
</button>
<button className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
{/* Droppable Area */}
<Droppable droppableId={status}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`min-h-[500px] rounded-lg p-3 transition-colors ${
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-dashed border-blue-300' : 'bg-slate-50/50'
}`}
>
{children}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
);
}

View File

@@ -0,0 +1,526 @@
import React, { useState, useRef, useEffect } 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 { Badge } from "@/components/ui/badge";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Calendar, Paperclip, Send, Upload, FileText, Download, AtSign, Smile, Plus, Home, Activity, Mail, Clock, Zap, PauseCircle, CheckCircle } from "lucide-react";
import { format } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
const priorityConfig = {
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
low: { bg: "bg-amber-100", text: "text-amber-700", label: "Low" }
};
export default function TaskDetailModal({ task, open, onClose }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [comment, setComment] = useState("");
const [uploading, setUploading] = useState(false);
const [status, setStatus] = useState(task?.status || "pending");
const [activeTab, setActiveTab] = useState("updates");
const [emailNotification, setEmailNotification] = useState(false);
const fileInputRef = useRef(null);
// Auto-calculate progress based on activity
const calculateProgress = () => {
if (!task) return 0;
let progressScore = 0;
// Status contributes to progress
if (task.status === "completed") return 100;
if (task.status === "in_progress") progressScore += 40;
if (task.status === "on_hold") progressScore += 20;
// Comments/updates show activity
if (task.comment_count > 0) progressScore += Math.min(task.comment_count * 5, 20);
// Files attached show work done
if (task.attachment_count > 0) progressScore += Math.min(task.attachment_count * 10, 20);
// Assigned members
if (task.assigned_members?.length > 0) progressScore += 20;
return Math.min(progressScore, 100);
};
const currentProgress = calculateProgress();
useEffect(() => {
if (task && currentProgress !== task.progress) {
updateTaskMutation.mutate({
id: task.id,
data: { ...task, progress: currentProgress }
});
}
}, [currentProgress]);
const { data: user } = useQuery({
queryKey: ['current-user-task-modal'],
queryFn: () => base44.auth.me(),
});
const { data: comments = [] } = useQuery({
queryKey: ['task-comments', task?.id],
queryFn: () => base44.entities.TaskComment.filter({ task_id: task?.id }),
enabled: !!task?.id,
initialData: [],
});
const addCommentMutation = useMutation({
mutationFn: (commentData) => base44.entities.TaskComment.create(commentData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['task-comments', task?.id] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setComment("");
toast({
title: "✅ Comment Added",
description: "Your comment has been posted",
});
},
});
const updateTaskMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
toast({
title: "✅ Task Updated",
description: "Changes saved successfully",
});
},
});
const handleFileUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
const newFile = {
file_name: file.name,
file_url: file_url,
file_size: file.size,
uploaded_by: user?.full_name || user?.email || "User",
uploaded_at: new Date().toISOString(),
};
const updatedFiles = [...(task.files || []), newFile];
await updateTaskMutation.mutateAsync({
id: task.id,
data: {
...task,
files: updatedFiles,
attachment_count: updatedFiles.length,
}
});
// Add system comment
await addCommentMutation.mutateAsync({
task_id: task.id,
author_id: user?.id,
author_name: user?.full_name || user?.email || "User",
author_avatar: user?.profile_picture,
comment: `Uploaded file: ${file.name}`,
is_system: true,
});
toast({
title: "✅ File Uploaded",
description: `${file.name} added successfully`,
});
} catch (error) {
toast({
title: "❌ Upload Failed",
description: error.message,
variant: "destructive",
});
} finally {
setUploading(false);
}
};
const handleStatusChange = async (newStatus) => {
setStatus(newStatus);
await updateTaskMutation.mutateAsync({
id: task.id,
data: { ...task, status: newStatus }
});
// Add system comment
await addCommentMutation.mutateAsync({
task_id: task.id,
author_id: user?.id,
author_name: "System",
author_avatar: "",
comment: `Status changed to ${newStatus.replace('_', ' ')}`,
is_system: true,
});
};
const handleAddComment = async () => {
if (!comment.trim()) return;
await addCommentMutation.mutateAsync({
task_id: task.id,
author_id: user?.id,
author_name: user?.full_name || user?.email || "User",
author_avatar: user?.profile_picture,
comment: comment.trim(),
is_system: false,
});
// Update comment count
await updateTaskMutation.mutateAsync({
id: task.id,
data: {
...task,
comment_count: (task.comment_count || 0) + 1,
}
});
// Send email notifications if enabled
if (emailNotification && task.assigned_members) {
for (const member of task.assigned_members) {
try {
await base44.integrations.Core.SendEmail({
to: member.member_email || `${member.member_name}@example.com`,
subject: `New update on task: ${task.task_name}`,
body: `${user?.full_name || "A team member"} posted an update:\n\n"${comment.trim()}"\n\nView task details in the app.`
});
} catch (error) {
console.error("Failed to send email:", error);
}
}
toast({
title: "✅ Update Sent",
description: "Email notifications sent to team members",
});
}
};
const handleMention = () => {
const textarea = document.querySelector('textarea');
if (textarea) {
const cursorPos = textarea.selectionStart;
const textBefore = comment.substring(0, cursorPos);
const textAfter = comment.substring(cursorPos);
setComment(textBefore + '@' + textAfter);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
}, 0);
}
};
const handleEmoji = () => {
const emojis = ['👍', '❤️', '😊', '🎉', '✅', '🔥', '💪', '🚀'];
const randomEmoji = emojis[Math.floor(Math.random() * emojis.length)];
setComment(comment + randomEmoji);
};
if (!task) return null;
const priority = priorityConfig[task.priority] || priorityConfig.normal;
const sortedComments = [...comments].sort((a, b) =>
new Date(a.created_date) - new Date(b.created_date)
);
const getProgressColor = () => {
if (currentProgress === 100) return "bg-green-500";
if (currentProgress >= 70) return "bg-blue-500";
if (currentProgress >= 40) return "bg-amber-500";
return "bg-slate-400";
};
const statusOptions = [
{ value: "pending", label: "Pending", icon: Clock, color: "bg-slate-100 text-slate-700 border-slate-300" },
{ value: "in_progress", label: "In Progress", icon: Zap, color: "bg-blue-100 text-blue-700 border-blue-300" },
{ value: "on_hold", label: "On Hold", icon: PauseCircle, color: "bg-orange-100 text-orange-700 border-orange-300" },
{ value: "completed", label: "Completed", icon: CheckCircle, color: "bg-green-100 text-green-700 border-green-300" },
];
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-0">
{/* Header with Task Info */}
<div className="p-6 pb-4 border-b">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h2 className="text-2xl font-bold text-slate-900 mb-2">{task.task_name}</h2>
<div className="flex items-center gap-3 flex-wrap">
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold px-3 py-1`}>
{priority.label} Priority
</Badge>
{task.due_date && (
<div className="flex items-center gap-1.5 text-sm text-slate-600 bg-slate-100 px-3 py-1 rounded-full">
<Calendar className="w-3.5 h-3.5" />
{format(new Date(task.due_date), 'MMM d, yyyy')}
</div>
)}
<div className="flex items-center gap-2">
<div className={`h-2 w-24 bg-slate-200 rounded-full overflow-hidden`}>
<div className={`h-full ${getProgressColor()} transition-all duration-500`} style={{ width: `${currentProgress}%` }}></div>
</div>
<span className="text-sm font-semibold text-slate-700">{currentProgress}%</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{statusOptions.map((option) => {
const IconComponent = option.icon;
return (
<button
key={option.value}
onClick={() => handleStatusChange(option.value)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 font-semibold text-sm transition-all ${
status === option.value
? `${option.color} shadow-md scale-105`
: "bg-white text-slate-400 border-slate-200 hover:border-slate-300 hover:text-slate-600"
}`}
>
<IconComponent className="w-4 h-4" />
{option.label}
</button>
);
})}
</div>
</div>
{/* Assigned Members */}
{task.assigned_members && task.assigned_members.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-slate-500">ASSIGNED:</span>
<div className="flex -space-x-2">
{task.assigned_members.slice(0, 5).map((member, idx) => (
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
title={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{task.assigned_members.length > 5 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-bold text-slate-600">
+{task.assigned_members.length - 5}
</div>
)}
</div>
</div>
)}
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-6 h-12">
<TabsTrigger value="updates" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
<Home className="w-4 h-4" />
Updates
</TabsTrigger>
<TabsTrigger value="files" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
<Paperclip className="w-4 h-4" />
Files ({task.files?.length || 0})
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
<Activity className="w-4 h-4" />
Activity Log
</TabsTrigger>
</TabsList>
{/* Updates Tab */}
<TabsContent value="updates" className="flex-1 overflow-y-auto m-0 p-6 space-y-4">
<div className="bg-white border-2 border-slate-200 rounded-xl overflow-hidden">
<div className="p-4 border-b bg-slate-50 flex items-center justify-between">
<span className="text-sm font-semibold text-slate-600">Write an update</span>
<Button
variant="ghost"
size="sm"
onClick={() => setEmailNotification(!emailNotification)}
className={emailNotification ? "text-[#0A39DF] bg-blue-50" : "text-slate-500 hover:text-[#0A39DF]"}
>
<Mail className="w-4 h-4 mr-2" />
{emailNotification ? "Email enabled ✓" : "Update via email"}
</Button>
</div>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Write an update and mention others with @"
rows={4}
className="border-0 resize-none focus-visible:ring-0 text-base"
/>
<div className="p-3 bg-slate-50 flex items-center justify-between border-t">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleMention}
className="text-slate-500 hover:text-[#0A39DF]"
title="Mention someone"
>
<AtSign className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-slate-500 hover:text-[#0A39DF]"
title="Attach file"
>
<Paperclip className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleEmoji}
className="text-slate-500 hover:text-[#0A39DF]"
title="Add emoji"
>
<Smile className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" onChange={handleFileUpload} className="hidden" />
</div>
<Button
onClick={handleAddComment}
disabled={!comment.trim() || addCommentMutation.isPending}
className="bg-[#0A39DF] hover:bg-blue-700"
>
<Send className="w-4 h-4 mr-2" />
{addCommentMutation.isPending ? "Posting..." : "Post Update"}
</Button>
</div>
</div>
{/* Comments Feed */}
<div className="space-y-3">
{sortedComments.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Home className="w-16 h-16 mx-auto mb-3 opacity-20" />
<p className="text-sm">No updates yet. Be the first to post!</p>
</div>
) : (
sortedComments.map((commentItem) => (
<div key={commentItem.id} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex gap-3">
<Avatar className="w-10 h-10 flex-shrink-0">
<img
src={commentItem.author_avatar || `https://i.pravatar.cc/150?u=${encodeURIComponent(commentItem.author_name)}`}
alt={commentItem.author_name}
className="w-full h-full object-cover"
/>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-slate-900">{commentItem.author_name}</span>
{commentItem.is_system && (
<Badge variant="outline" className="text-xs">System</Badge>
)}
<span className="text-xs text-slate-400"></span>
<span className="text-xs text-slate-500">
{format(new Date(commentItem.created_date), 'MMM d, h:mm a')}
</span>
</div>
<p className="text-slate-700 leading-relaxed">{commentItem.comment}</p>
</div>
</div>
</div>
))
)}
</div>
</TabsContent>
{/* Files Tab */}
<TabsContent value="files" className="flex-1 overflow-y-auto m-0 p-6">
<div className="mb-4">
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="bg-[#0A39DF] hover:bg-blue-700"
>
<Upload className="w-4 h-4 mr-2" />
{uploading ? "Uploading..." : "Upload File"}
</Button>
</div>
{task.files && task.files.length > 0 ? (
<div className="grid grid-cols-1 gap-3">
{task.files.map((file, idx) => (
<div key={idx} className="flex items-center justify-between p-4 bg-white border-2 border-slate-200 rounded-xl hover:shadow-md transition-shadow">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 truncate">{file.file_name}</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
<span>{(file.file_size / 1024).toFixed(1)} KB</span>
<span></span>
<span>{file.uploaded_by}</span>
<span></span>
<span>{format(new Date(file.uploaded_at), 'MMM d, h:mm a')}</span>
</div>
</div>
</div>
<a href={file.file_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</a>
</div>
))}
</div>
) : (
<div className="text-center py-16 border-2 border-dashed border-slate-200 rounded-xl">
<Paperclip className="w-16 h-16 mx-auto mb-3 text-slate-300" />
<p className="text-slate-500 font-medium mb-2">No files attached yet</p>
<p className="text-sm text-slate-400">Upload files to share with your team</p>
</div>
)}
</TabsContent>
{/* Activity Log Tab */}
<TabsContent value="activity" className="flex-1 overflow-y-auto m-0 p-6">
<div className="space-y-2">
{sortedComments.filter(c => c.is_system).length === 0 ? (
<div className="text-center py-16 text-slate-400">
<Activity className="w-16 h-16 mx-auto mb-3 opacity-20" />
<p className="text-sm">No activity logged yet</p>
</div>
) : (
<div className="relative">
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200"></div>
{sortedComments.filter(c => c.is_system).map((activity) => (
<div key={activity.id} className="relative pl-10 pb-6">
<div className="absolute left-2.5 w-3 h-3 bg-[#0A39DF] rounded-full border-2 border-white"></div>
<div className="bg-white border border-slate-200 rounded-lg p-3">
<p className="text-sm text-slate-700">{activity.comment}</p>
<p className="text-xs text-slate-400 mt-1">
{format(new Date(activity.created_date), 'MMM d, yyyy • h:mm a')}
</p>
</div>
</div>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,21 +1,18 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
className={`relative flex w-full touch-none select-none items-center ${className}`}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-100">
<SliderPrimitive.Range className="absolute h-full bg-[#0A39DF]" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-[#0A39DF] bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
export { Slider }

View File

@@ -1,41 +1,33 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
className={`inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500 ${className}`}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
className={`inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-950 data-[state=active]:shadow-sm ${className}`}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
className={`mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 ${className}`}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,358 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Award, Star, MapPin, Users, TrendingUp, CheckCircle2, Edit2, X, Sparkles, Shield } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
export default function PreferredVendorPanel({ user, compact = false }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [showChangeDialog, setShowChangeDialog] = useState(false);
// Fetch preferred vendor details
const { data: preferredVendor, isLoading } = useQuery({
queryKey: ['preferred-vendor', user?.preferred_vendor_id],
queryFn: async () => {
if (!user?.preferred_vendor_id) return null;
const vendors = await base44.entities.Vendor.list();
return vendors.find(v => v.id === user.preferred_vendor_id);
},
enabled: !!user?.preferred_vendor_id,
});
// Fetch all approved vendors for comparison
const { data: allVendors } = useQuery({
queryKey: ['all-vendors'],
queryFn: () => base44.entities.Vendor.filter({
approval_status: 'approved',
is_active: true
}),
initialData: [],
});
// Remove preferred vendor mutation
const removePreferredMutation = useMutation({
mutationFn: () => base44.auth.updateMe({
preferred_vendor_id: null,
preferred_vendor_name: null
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] });
toast({
title: "✅ Preferred Vendor Removed",
description: "You can now select a new preferred vendor",
});
},
});
// Set preferred vendor mutation
const setPreferredMutation = useMutation({
mutationFn: (vendor) => base44.auth.updateMe({
preferred_vendor_id: vendor.id,
preferred_vendor_name: vendor.legal_name || vendor.doing_business_as
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] });
queryClient.invalidateQueries({ queryKey: ['preferred-vendor'] });
toast({
title: "✅ Preferred Vendor Set",
description: "All new orders will route to this vendor by default",
});
setShowChangeDialog(false);
},
});
if (isLoading) {
return (
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="animate-pulse flex items-center gap-3">
<div className="w-12 h-12 bg-slate-200 rounded-lg" />
<div className="flex-1">
<div className="h-4 bg-slate-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-slate-200 rounded w-1/3" />
</div>
</div>
</CardContent>
</Card>
);
}
if (!preferredVendor && !compact) {
return (
<Card className="border-2 border-dashed border-blue-300 bg-blue-50/50">
<CardContent className="p-6 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-blue-100 rounded-2xl flex items-center justify-center">
<Star className="w-8 h-8 text-blue-600" />
</div>
<h3 className="font-bold text-lg text-slate-900 mb-2">
No Preferred Vendor Selected
</h3>
<p className="text-sm text-slate-600 mb-4">
Pick your go-to vendor for faster ordering and consistent service
</p>
<Button
onClick={() => navigate(createPageUrl("VendorMarketplace"))}
className="bg-blue-600 hover:bg-blue-700"
>
<Star className="w-4 h-4 mr-2" />
Choose Preferred Vendor
</Button>
</CardContent>
</Card>
);
}
if (!preferredVendor) return null;
if (compact) {
return (
<div className="flex items-center gap-3 p-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-lg">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
<Award className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="text-xs font-bold text-blue-600 uppercase tracking-wider">Preferred Vendor</p>
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">PRIMARY</Badge>
</div>
<p className="font-bold text-sm text-slate-900 truncate">
{preferredVendor.doing_business_as || preferredVendor.legal_name}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowChangeDialog(true)}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-100"
>
<Edit2 className="w-4 h-4" />
</Button>
</div>
);
}
return (
<>
<Card className="border-2 border-blue-300 bg-gradient-to-br from-blue-50 via-white to-indigo-50 shadow-lg">
<CardHeader className="border-b border-blue-200 pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center">
<Award className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-lg font-bold text-slate-900">Your Preferred Vendor</CardTitle>
<p className="text-xs text-slate-600 mt-0.5">All orders route here by default</p>
</div>
</div>
<Badge className="bg-blue-600 text-white font-bold px-3 py-1.5 shadow-md">
PRIMARY
</Badge>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{/* Vendor Info */}
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-slate-100 to-slate-200 rounded-xl flex items-center justify-center flex-shrink-0">
<Users className="w-8 h-8 text-slate-600" />
</div>
<div className="flex-1">
<h3 className="font-bold text-xl text-slate-900 mb-1">
{preferredVendor.doing_business_as || preferredVendor.legal_name}
</h3>
<div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-emerald-100 text-emerald-700 text-xs">
<CheckCircle2 className="w-3 h-3 mr-1" />
Approved
</Badge>
{preferredVendor.region && (
<Badge variant="outline" className="text-xs">
<MapPin className="w-3 h-3 mr-1" />
{preferredVendor.region}
</Badge>
)}
{preferredVendor.service_specialty && (
<Badge variant="outline" className="text-xs">
{preferredVendor.service_specialty}
</Badge>
)}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-slate-200">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">
{preferredVendor.workforce_count || 0}
</p>
<p className="text-xs text-slate-500 mt-1">Staff Available</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1">
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
<p className="text-2xl font-bold text-slate-900">4.9</p>
</div>
<p className="text-xs text-slate-500 mt-1">Rating</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-emerald-600">98%</p>
<p className="text-xs text-slate-500 mt-1">Fill Rate</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setShowChangeDialog(true)}
>
<Edit2 className="w-4 h-4 mr-2" />
Switch Vendor
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => navigate(createPageUrl("VendorMarketplace"))}
>
<Sparkles className="w-4 h-4 mr-2" />
View Market
</Button>
<Button
variant="ghost"
onClick={() => removePreferredMutation.mutate()}
disabled={removePreferredMutation.isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-4 h-4 mr-2" />
Remove
</Button>
</div>
{/* Benefits Badge */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-sm">
<Shield className="w-4 h-4 text-green-600 flex-shrink-0" />
<p className="text-green-800 font-medium">
<strong>Priority Support:</strong> Faster response times and dedicated account management
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Change Vendor Dialog */}
<Dialog open={showChangeDialog} onOpenChange={setShowChangeDialog}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<Star className="w-6 h-6 text-blue-600" />
Select New Preferred Vendor
</DialogTitle>
<DialogDescription>
Choose a vendor to route all your future orders to by default
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-4">
{allVendors.map((vendor) => {
const isCurrentPreferred = vendor.id === preferredVendor?.id;
return (
<div
key={vendor.id}
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
isCurrentPreferred
? 'border-blue-400 bg-blue-50'
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
}`}
onClick={() => !isCurrentPreferred && setPreferredMutation.mutate(vendor)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-bold text-lg text-slate-900">
{vendor.doing_business_as || vendor.legal_name}
</h3>
{isCurrentPreferred && (
<Badge className="bg-blue-600 text-white">
Current Preferred
</Badge>
)}
</div>
<div className="flex items-center gap-2 flex-wrap mb-2">
{vendor.region && (
<Badge variant="outline" className="text-xs">
<MapPin className="w-3 h-3 mr-1" />
{vendor.region}
</Badge>
)}
{vendor.service_specialty && (
<Badge variant="outline" className="text-xs">
{vendor.service_specialty}
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-slate-600">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{vendor.workforce_count || 0} staff
</span>
<span className="flex items-center gap-1">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
4.9
</span>
<span className="flex items-center gap-1">
<TrendingUp className="w-4 h-4 text-green-600" />
98% fill rate
</span>
</div>
</div>
{!isCurrentPreferred && (
<Button
onClick={(e) => {
e.stopPropagation();
setPreferredMutation.mutate(vendor);
}}
disabled={setPreferredMutation.isPending}
className="bg-blue-600 hover:bg-blue-700"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Select
</Button>
)}
</div>
</div>
);
})}
{allVendors.length === 0 && (
<div className="text-center py-8 text-slate-400">
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="font-medium">No vendors available</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}