Merge branch 'dev' into 24-web-connect-events-page-to-dev-backend-poc

This commit is contained in:
José Salazar
2025-11-19 11:37:16 -05:00
1623 changed files with 128526 additions and 5376 deletions

View File

@@ -63,6 +63,10 @@ export const TeamMemberInvite = base44.entities.TeamMemberInvite;
export const VendorDocumentReview = base44.entities.VendorDocumentReview;
export const Task = base44.entities.Task;
export const TaskComment = base44.entities.TaskComment;
// auth sdk:

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,282 +1,508 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Calendar as CalendarIcon, MapPin, Users, Clock, DollarSign, FileText, Plus, RefreshCw, Zap } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { format, addDays } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tabs, // New import
TabsList, // New import
TabsTrigger, // New import
} from "@/components/ui/tabs"; // New import
import {
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus
} from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import QuickReorderModal from "@/components/events/QuickReorderModal";
import { format, parseISO, isValid } from "date-fns";
const safeParseDate = (dateString) => {
if (!dateString) return null;
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch { return null; }
};
const safeFormatDate = (dateString, formatString) => {
const date = safeParseDate(dateString);
return date ? format(date, formatString) : '—';
};
const convertTo12Hour = (time24) => {
if (!time24) return "-";
try {
const [hours, minutes] = time24.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
} catch {
return time24;
}
};
const getStatusBadge = (event) => {
if (event.is_rapid) {
return (
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
<Zap className="w-3.5 h-3.5 fill-white" />
RAPID
</div>
);
}
const statusConfig = {
'Draft': { bg: 'bg-slate-500', icon: FileText },
'Pending': { bg: 'bg-amber-500', icon: Clock },
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
'Active': { bg: 'bg-blue-500', icon: Users },
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
'Canceled': { bg: 'bg-red-500', icon: X },
};
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
const Icon = config.icon;
return (
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
<Icon className="w-3.5 h-3.5" />
{event.status}
</div>
);
};
export default function ClientOrders() {
const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState("all");
const [reorderModalOpen, setReorderModalOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const queryClient = useQueryClient();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
const { data: user } = useQuery({
queryKey: ['current-user'],
queryKey: ['current-user-client-orders'],
queryFn: () => base44.auth.me(),
});
const { data: events } = useQuery({
queryKey: ['client-events'],
const { data: allEvents = [] } = useQuery({
queryKey: ['all-events-client'],
queryFn: () => base44.entities.Event.list('-date'),
initialData: [],
});
// Filter events by current client
const clientEvents = events.filter(e =>
e.client_email === user?.email || e.created_by === user?.email
);
const clientEvents = useMemo(() => {
return allEvents.filter(e =>
e.client_email === user?.email ||
e.business_name === user?.company_name ||
e.created_by === user?.email
);
}, [allEvents, user]);
const filteredEvents = statusFilter === "all"
? clientEvents
: clientEvents.filter(e => {
if (statusFilter === "rapid_request") return e.is_rapid_request;
if (statusFilter === "pending") return e.status?.toLowerCase() === "pending" || e.status?.toLowerCase() === "draft";
return e.status?.toLowerCase() === statusFilter;
const cancelOrderMutation = useMutation({
mutationFn: (orderId) => base44.entities.Event.update(orderId, { status: "Canceled" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
toast({
title: "✅ Order Canceled",
description: "Your order has been canceled successfully",
});
setCancelDialogOpen(false); // Updated
setOrderToCancel(null); // Updated
},
onError: () => {
toast({
title: "❌ Failed to Cancel",
description: "Could not cancel order. Please try again.",
variant: "destructive",
});
},
});
const getStatusColor = (status) => {
const colors = {
'pending': 'bg-yellow-100 text-yellow-700',
'draft': 'bg-gray-100 text-gray-700',
'confirmed': 'bg-green-100 text-green-700',
'active': 'bg-blue-100 text-blue-700',
'completed': 'bg-slate-100 text-slate-700',
'canceled': 'bg-red-100 text-red-700',
'cancelled': 'bg-red-100 text-red-700',
const filteredOrders = useMemo(() => { // Renamed from filteredEvents
let filtered = clientEvents;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(e =>
e.event_name?.toLowerCase().includes(lower) ||
e.business_name?.toLowerCase().includes(lower) ||
e.hub?.toLowerCase().includes(lower) ||
e.event_location?.toLowerCase().includes(lower) // Added event_location to search
);
}
const now = new Date();
// Reset time for comparison to only compare dates
now.setHours(0, 0, 0, 0);
filtered = filtered.filter(e => {
const eventDate = safeParseDate(e.date);
const isCompleted = e.status === "Completed";
const isCanceled = e.status === "Canceled";
const isFutureOrPresent = eventDate && eventDate >= now;
if (statusFilter === "active") {
return !isCompleted && !isCanceled && isFutureOrPresent;
} else if (statusFilter === "completed") {
return isCompleted;
}
return true; // For "all" or other statuses
});
return filtered;
}, [clientEvents, searchTerm, statusFilter]);
const activeOrders = clientEvents.filter(e =>
e.status !== "Completed" && e.status !== "Canceled"
).length;
const completedOrders = clientEvents.filter(e => e.status === "Completed").length;
const totalSpent = clientEvents
.filter(e => e.status === "Completed")
.reduce((sum, e) => sum + (e.total || 0), 0);
const handleCancelOrder = (order) => {
setOrderToCancel(order); // Updated
setCancelDialogOpen(true); // Updated
};
const confirmCancel = () => {
if (orderToCancel) { // Updated
cancelOrderMutation.mutate(orderToCancel.id); // Updated
}
};
const canEditOrder = (order) => {
const eventDate = safeParseDate(order.date);
const now = new Date();
return order.status !== "Completed" &&
order.status !== "Canceled" &&
eventDate && eventDate > now; // Ensure eventDate is valid before comparison
};
const canCancelOrder = (order) => {
return order.status !== "Completed" && order.status !== "Canceled";
};
const getAssignmentStatus = (event) => {
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
const assigned = event.assigned_staff?.length || 0;
const percentage = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
let badgeClass = 'bg-slate-100 text-slate-600'; // Default: no staff, or no roles requested
if (assigned > 0 && assigned < totalRequested) {
badgeClass = 'bg-orange-500 text-white'; // Partial Staffed
} else if (assigned >= totalRequested && totalRequested > 0) {
badgeClass = 'bg-emerald-500 text-white'; // Fully Staffed
} else if (assigned === 0 && totalRequested > 0) {
badgeClass = 'bg-red-500 text-white'; // Requested but 0 assigned
} else if (assigned > 0 && totalRequested === 0) {
badgeClass = 'bg-blue-500 text-white'; // Staff assigned but no roles explicitly requested (e.g., event set up, staff assigned, but roles not detailed or count is 0)
}
return {
badgeClass,
assigned,
requested: totalRequested,
percentage,
};
return colors[status?.toLowerCase()] || 'bg-slate-100 text-slate-700';
};
const stats = {
total: clientEvents.length,
rapidRequest: clientEvents.filter(e => e.is_rapid_request).length,
pending: clientEvents.filter(e => e.status === 'Pending' || e.status === 'Draft').length,
confirmed: clientEvents.filter(e => e.status === 'Confirmed').length,
completed: clientEvents.filter(e => e.status === 'Completed').length,
};
const getEventTimes = (event) => {
const firstShift = event.shifts?.[0];
const rolesInFirstShift = firstShift?.roles || [];
const handleQuickReorder = (event) => {
setSelectedEvent(event);
setReorderModalOpen(true);
let startTime = null;
let endTime = null;
if (rolesInFirstShift.length > 0) {
startTime = rolesInFirstShift[0].start_time || null;
endTime = rolesInFirstShift[0].end_time || null;
}
return {
startTime: startTime ? convertTo12Hour(startTime) : "-",
endTime: endTime ? convertTo12Hour(endTime) : "-"
};
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-[#1C323E]">My Orders</h1>
<p className="text-slate-500 mt-1">View and manage your event orders</p>
<div className="max-w-[1800px] mx-auto space-y-6">
<div className=""> {/* Removed mb-6 */}
<h1 className="text-2xl font-bold text-slate-900">My Orders</h1>
<p className="text-sm text-slate-500 mt-1">View and manage all your orders</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {/* Removed mb-6 from here as it's now part of space-y-6 */}
<Card className="border border-blue-200 bg-blue-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<Package className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-blue-600 font-semibold uppercase">TOTAL</p>
<p className="text-2xl font-bold text-blue-700">{clientEvents.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-orange-200 bg-orange-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-orange-600 font-semibold uppercase">ACTIVE</p>
<p className="text-2xl font-bold text-orange-700">{activeOrders}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-green-200 bg-green-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-green-600 font-semibold uppercase">COMPLETED</p>
<p className="text-2xl font-bold text-green-700">{completedOrders}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-purple-200 bg-purple-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-purple-600 font-semibold uppercase">TOTAL SPENT</p>
<p className="text-2xl font-bold text-purple-700">${Math.round(totalSpent / 1000)}k</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl p-4 flex items-center gap-4 border shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" /> {/* Icon size updated */}
<Input
placeholder="Search orders..." // Placeholder text updated
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-slate-300 h-10" // Class updated
/>
</div>
<Button
onClick={() => navigate(createPageUrl("CreateEvent"))}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<Plus className="w-4 h-4 mr-2" />
New Order
</Button>
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="w-fit"> {/* Replaced Select with Tabs */}
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="completed">Completed</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 mb-8">
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<FileText className="w-8 h-8 text-[#0A39DF]" />
</div>
<p className="text-sm text-slate-500">Total Orders</p>
<p className="text-3xl font-bold text-[#1C323E]">{stats.total}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-sm"> {/* Card class updated */}
<CardContent className="p-0"> {/* CardContent padding updated */}
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50"> {/* TableRow class updated */}
<TableHead className="font-semibold text-slate-700">Order</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Date</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Location</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Time</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Status</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700 text-center">Staff</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700 text-center">Invoice</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700 text-center">Actions</TableHead> {/* Updated */}
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders.length === 0 ? ( // Using filteredOrders
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-slate-500"> {/* Colspan updated */}
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" /> {/* Icon updated */}
<p className="font-medium">No orders found</p>
</TableCell>
</TableRow>
) : (
filteredOrders.map((order) => { // Using filteredOrders, renamed event to order
const assignment = getAssignmentStatus(order);
const { startTime, endTime } = getEventTimes(order);
const invoiceReady = order.status === "Completed";
// const eventDate = safeParseDate(order.date); // Not directly used here, safeFormatDate handles it.
<Card className="border-slate-200 bg-gradient-to-br from-red-50 to-white">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Zap className="w-8 h-8 text-red-600" />
</div>
<p className="text-sm text-slate-500">Rapid Requests</p>
<p className="text-3xl font-bold text-red-600">{stats.rapidRequest}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Clock className="w-8 h-8 text-yellow-600" />
</div>
<p className="text-sm text-slate-500">Pending</p>
<p className="text-3xl font-bold text-yellow-600">{stats.pending}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<CalendarIcon className="w-8 h-8 text-green-600" />
</div>
<p className="text-sm text-slate-500">Confirmed</p>
<p className="text-3xl font-bold text-green-600">{stats.confirmed}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Users className="w-8 h-8 text-blue-600" />
</div>
<p className="text-sm text-slate-500">Completed</p>
<p className="text-3xl font-bold text-blue-600">{stats.completed}</p>
</CardContent>
</Card>
</div>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6 flex-wrap">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
className={statusFilter === "all" ? "bg-[#0A39DF]" : ""}
>
All
</Button>
<Button
variant={statusFilter === "rapid_request" ? "default" : "outline"}
onClick={() => setStatusFilter("rapid_request")}
className={statusFilter === "rapid_request" ? "bg-red-600 hover:bg-red-700" : ""}
>
<Zap className="w-4 h-4 mr-2" />
Rapid Request
</Button>
<Button
variant={statusFilter === "pending" ? "default" : "outline"}
onClick={() => setStatusFilter("pending")}
className={statusFilter === "pending" ? "bg-[#0A39DF]" : ""}
>
Pending
</Button>
<Button
variant={statusFilter === "confirmed" ? "default" : "outline"}
onClick={() => setStatusFilter("confirmed")}
className={statusFilter === "confirmed" ? "bg-[#0A39DF]" : ""}
>
Confirmed
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
onClick={() => setStatusFilter("completed")}
className={statusFilter === "completed" ? "bg-[#0A39DF]" : ""}
>
Completed
</Button>
</div>
{/* Orders List */}
<div className="grid grid-cols-1 gap-6">
{filteredEvents.length > 0 ? (
filteredEvents.map((event) => (
<Card key={event.id} className="border-slate-200 hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-xl font-bold text-[#1C323E]">{event.event_name}</h3>
<Badge className={getStatusColor(event.status)}>
{event.status}
</Badge>
{event.is_rapid_request && (
<Badge className="bg-red-100 text-red-700 border-red-200 border">
<Zap className="w-3 h-3 mr-1" />
Rapid Request
</Badge>
)}
{event.include_backup && (
<Badge className="bg-green-100 text-green-700 border-green-200 border">
🛡 {event.backup_staff_count || 0} Backup Staff
</Badge>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-slate-600 mb-4">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{event.date ? format(new Date(event.date), 'PPP') : 'Date TBD'}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span>{event.event_location || event.hub || 'Location TBD'}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{event.assigned || 0} of {event.requested || 0} staff</span>
</div>
{event.total && (
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4" />
<span className="font-semibold">${event.total.toLocaleString()}</span>
return (
<TableRow key={order.id} className="hover:bg-slate-50">
<TableCell> {/* Order cell */}
<div>
<p className="font-semibold text-slate-900">{order.event_name}</p>
<p className="text-xs text-slate-500">{order.business_name || "—"}</p>
</div>
)}
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => navigate(createPageUrl("EventDetail") + `?id=${event.id}`)}
variant="outline"
className="hover:bg-[#0A39DF] hover:text-white"
>
View Details
</Button>
<Button
onClick={() => handleQuickReorder(event)}
className="bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white shadow-lg"
size="lg"
>
<RefreshCw className="w-5 h-5 mr-2" />
Reorder
</Button>
</div>
</div>
{event.notes && (
<div className="mt-3 p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">{event.notes}</p>
</div>
)}
</CardContent>
</Card>
))
) : (
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<FileText className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-lg font-semibold text-slate-700 mb-2">No orders found</h3>
<p className="text-slate-500 mb-6">Get started by creating your first order</p>
<Button
onClick={() => navigate(createPageUrl("CreateEvent"))}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<Plus className="w-4 h-4 mr-2" />
Create Order
</Button>
</CardContent>
</Card>
)}
</div>
{/* Quick Reorder Modal */}
{selectedEvent && (
<QuickReorderModal
event={selectedEvent}
open={reorderModalOpen}
onOpenChange={setReorderModalOpen}
/>
)}
</TableCell>
<TableCell> {/* Date cell */}
<div>
<p className="font-semibold text-slate-900">
{safeFormatDate(order.date, 'MMM dd, yyyy')}
</p>
<p className="text-xs text-slate-500">
{safeFormatDate(order.date, 'EEEE')}
</p>
</div>
</TableCell>
<TableCell> {/* Location cell */}
<div className="flex items-center gap-1.5 text-sm text-slate-600">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
{order.hub || order.event_location || "—"}
</div>
</TableCell>
<TableCell> {/* Time cell */}
<div className="flex items-center gap-1 text-sm text-slate-600">
<Clock className="w-3.5 h-3.5 text-slate-400" />
{startTime} - {endTime}
</div>
</TableCell>
<TableCell> {/* Status cell */}
{getStatusBadge(order)}
</TableCell>
<TableCell className="text-center"> {/* Staff cell */}
<div className="flex flex-col items-center gap-1">
<Badge className={assignment.badgeClass}>
{assignment.assigned} / {assignment.requested}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">
{assignment.percentage}%
</span>
</div>
</TableCell>
<TableCell className="text-center"> {/* Invoice cell */}
<div className="flex items-center justify-center">
<Button // Changed from a div to a Button for better accessibility
variant="ghost"
size="icon"
onClick={() => invoiceReady && navigate(createPageUrl('Invoices'))}
className={`w-10 h-10 rounded-full flex items-center justify-center ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'} ${invoiceReady ? 'cursor-pointer hover:bg-blue-200' : 'cursor-not-allowed opacity-50'}`}
disabled={!invoiceReady}
title={invoiceReady ? "View Invoice" : "Invoice not available"}
>
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
</Button>
</div>
</TableCell>
<TableCell> {/* Actions cell */}
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${order.id}`))}
className="hover:bg-slate-100"
title="View details"
>
<Eye className="w-4 h-4" />
</Button>
{canEditOrder(order) && (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))}
className="hover:bg-slate-100"
title="Edit order"
>
<Edit className="w-4 h-4" /> {/* Changed from Edit2 */}
</Button>
)}
{canCancelOrder(order) && (
<Button
variant="ghost"
size="icon"
onClick={() => handleCancelOrder(order)} // Updated
className="hover:bg-red-50 hover:text-red-600"
title="Cancel order"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="w-5 h-5" />
Cancel Order?
</DialogTitle>
<DialogDescription>
Are you sure you want to cancel this order? This action cannot be undone.
</DialogDescription>
</DialogHeader>
{orderToCancel && ( // Using orderToCancel
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<p className="font-bold text-slate-900">{orderToCancel.event_name}</p>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
{orderToCancel.date ? format(new Date(orderToCancel.date), "MMMM d, yyyy") : "—"}
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<MapPin className="w-4 h-4" />
{orderToCancel.hub || orderToCancel.event_location}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialogOpen(false)} // Updated
>
Keep Order
</Button>
<Button
variant="destructive"
onClick={confirmCancel}
disabled={cancelOrderMutation.isPending}
>
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
}

View File

@@ -1,31 +1,38 @@
import React, { useState } from "react";
import React from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import EventFormWizard from "@/components/events/EventFormWizard";
import AIOrderAssistant from "@/components/events/AIOrderAssistant";
import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button";
import { Sparkles, FileText, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { X, AlertTriangle } from "lucide-react";
import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/ConflictDetection";
import { Card, CardContent } from "@/components/ui/card";
export default function CreateEvent() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [useAI, setUseAI] = useState(false);
const [aiExtractedData, setAiExtractedData] = useState(null);
const [pendingEvent, setPendingEvent] = React.useState(null);
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
const { data: currentUser } = useQuery({
queryKey: ['current-user-create-event'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['events-for-conflict-check'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const createEventMutation = useMutation({
mutationFn: (eventData) => base44.entities.Event.create(eventData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['client-events'] });
toast({
title: "✅ Event Created",
description: "Your event has been created successfully.",
@@ -42,107 +49,98 @@ export default function CreateEvent() {
});
const handleSubmit = (eventData) => {
createEventMutation.mutate(eventData);
// Detect conflicts before creating
const conflicts = detectAllConflicts(eventData, allEvents);
if (conflicts.length > 0) {
setPendingEvent({ ...eventData, detected_conflicts: conflicts });
setShowConflictWarning(true);
} else {
createEventMutation.mutate(eventData);
}
};
const handleAIDataExtracted = (extractedData) => {
setAiExtractedData(extractedData);
setUseAI(false);
const handleConfirmWithConflicts = () => {
if (pendingEvent) {
createEventMutation.mutate(pendingEvent);
setShowConflictWarning(false);
setPendingEvent(null);
}
};
const handleCancelConflicts = () => {
setShowConflictWarning(false);
setPendingEvent(null);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="max-w-7xl mx-auto p-4 md:p-8">
{/* Header with AI Toggle */}
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-[#1C323E]">Create New Order</h1>
<h1 className="text-3xl font-bold text-[#1C323E]">Create Standard Order</h1>
<p className="text-slate-600 mt-1">
{useAI ? "Use AI to create your order naturally" : "Fill out the form to create your order"}
Fill out the details for your planned event
</p>
</div>
<div className="flex gap-2">
<Button
variant={useAI ? "default" : "outline"}
onClick={() => setUseAI(true)}
className={useAI ? "bg-gradient-to-r from-[#0A39DF] to-purple-600" : ""}
>
<Sparkles className="w-4 h-4 mr-2" />
AI Assistant
</Button>
<Button
variant={!useAI ? "default" : "outline"}
onClick={() => setUseAI(false)}
className={!useAI ? "bg-[#1C323E]" : ""}
>
<FileText className="w-4 h-4 mr-2" />
Form
</Button>
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("Events"))}
>
<X className="w-4 h-4" />
</Button>
</div>
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("ClientDashboard"))}
>
<X className="w-4 h-4" />
</Button>
</div>
{/* AI Assistant Interface */}
<AnimatePresence>
{useAI && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
>
<AIOrderAssistant
onOrderDataExtracted={handleAIDataExtracted}
onClose={() => setUseAI(false)}
/>
</motion.div>
)}
</AnimatePresence>
{/* Wizard Form */}
{!useAI && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{aiExtractedData && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-5 h-5 text-green-600" />
<span className="font-semibold text-green-900">AI Pre-filled Data</span>
{/* Conflict Warning Modal */}
{showConflictWarning && pendingEvent && (
<Card className="mb-6 border-2 border-orange-500">
<CardContent className="p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-orange-600" />
</div>
<p className="text-sm text-green-700 mb-3">
The form has been pre-filled with information from your conversation. Review and edit as needed.
</p>
<div>
<h3 className="font-bold text-lg text-slate-900 mb-1">
Scheduling Conflicts Detected
</h3>
<p className="text-sm text-slate-600">
This event has {pendingEvent.detected_conflicts.length} potential conflict{pendingEvent.detected_conflicts.length !== 1 ? 's' : ''}
with existing bookings. Review the conflicts below and decide how to proceed.
</p>
</div>
</div>
<div className="mb-6">
<ConflictAlert conflicts={pendingEvent.detected_conflicts} />
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setAiExtractedData(null);
setUseAI(true);
}}
className="border-green-300 text-green-700 hover:bg-green-100"
onClick={handleCancelConflicts}
>
<Sparkles className="w-4 h-4 mr-2" />
Chat with AI Again
Go Back & Edit
</Button>
<Button
onClick={handleConfirmWithConflicts}
className="bg-orange-600 hover:bg-orange-700"
>
Create Anyway
</Button>
</div>
)}
<EventFormWizard
event={aiExtractedData}
onSubmit={handleSubmit}
isSubmitting={createEventMutation.isPending}
currentUser={currentUser}
onCancel={() => navigate(createPageUrl("Events"))}
/>
</motion.div>
</CardContent>
</Card>
)}
<EventFormWizard
event={null}
onSubmit={handleSubmit}
isSubmitting={createEventMutation.isPending}
currentUser={currentUser}
onCancel={() => navigate(createPageUrl("ClientDashboard"))}
/>
</div>
</div>
);

View File

@@ -6,11 +6,104 @@ import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf, Eye, Edit, Sparkles, Zap, Clock, AlertTriangle, CheckCircle, FileText, X } from "lucide-react";
import StatsCard from "@/components/staff/StatsCard";
import EcosystemWheel from "@/components/dashboard/EcosystemWheel";
import QuickMetrics from "@/components/dashboard/QuickMetrics";
import PageHeader from "@/components/common/PageHeader";
import { format, parseISO, isValid, isSameDay, startOfDay } from "date-fns";
const safeParseDate = (dateString) => {
if (!dateString) return null;
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch { return null; }
};
const safeFormatDate = (dateString, formatStr) => {
const date = safeParseDate(dateString);
if (!date) return "-";
try { return format(date, formatStr); } catch { return "-"; }
};
const convertTo12Hour = (time24) => {
if (!time24) return "-";
try {
const [hours, minutes] = time24.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
} catch {
return time24;
}
};
const getStatusBadge = (event) => {
if (event.is_rapid) {
return (
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
<Zap className="w-3.5 h-3.5 fill-white" />
RAPID
</div>
);
}
const statusConfig = {
'Draft': { bg: 'bg-slate-500', icon: FileText },
'Pending': { bg: 'bg-amber-500', icon: Clock },
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
'Active': { bg: 'bg-blue-500', icon: Users },
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
'Canceled': { bg: 'bg-red-500', icon: X },
};
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
const Icon = config.icon;
return (
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
<Icon className="w-3.5 h-3.5" />
{event.status}
</div>
);
};
const getEventTimes = (event) => {
const firstShift = event.shifts?.[0];
const rolesInFirstShift = firstShift?.roles || [];
let startTime = null;
let endTime = null;
if (rolesInFirstShift.length > 0) {
startTime = rolesInFirstShift[0].start_time || null;
endTime = rolesInFirstShift[0].end_time || null;
}
return {
startTime: startTime ? convertTo12Hour(startTime) : "-",
endTime: endTime ? convertTo12Hour(endTime) : "-"
};
};
const getAssignmentStatus = (event) => {
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
const assigned = event.assigned_staff?.length || 0;
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
if (assigned === 0) return { color: 'bg-slate-200 text-slate-600', text: '0', percent: '0%', status: 'empty' };
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: '0%', status: 'partial' };
};
export default function Dashboard() {
const navigate = useNavigate();
@@ -28,6 +121,13 @@ export default function Dashboard() {
initialData: [],
});
// Filter events for today only
const today = startOfDay(new Date());
const todaysEvents = events.filter(event => {
const eventDate = safeParseDate(event.date);
return eventDate && isSameDay(eventDate, today);
});
const recentStaff = staff.slice(0, 6);
const uniqueDepartments = [...new Set(staff.map(s => s.department).filter(Boolean))];
const uniqueLocations = [...new Set(staff.map(s => s.hub_location).filter(Boolean))];
@@ -105,7 +205,7 @@ export default function Dashboard() {
<Link to={createPageUrl("Events")}>
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
<Calendar className="w-5 h-5 mr-2" />
View All Events
View All Orders
</Button>
</Link>
</>
@@ -143,6 +243,133 @@ export default function Dashboard() {
/>
</div>
{/* Today's Orders Section */}
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Calendar className="w-6 h-6 text-[#0A39DF]" />
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
</div>
<Link to={createPageUrl("Events")}>
<Button variant="outline" className="border-slate-300">
View All Orders
</Button>
</Link>
</div>
</CardHeader>
<CardContent className="p-0">
{todaysEvents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders scheduled for today</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todaysEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
return (
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</div>
</div>
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
{assignmentStatus.text}
</div>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{event.invoice_id && (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View Invoice"
>
<FileText className="w-4 h-4 text-blue-600" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Ecosystem Puzzle */}
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">

View File

@@ -1,52 +1,48 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Bell, RefreshCw } from "lucide-react";
import { format } from "date-fns";
import ShiftCard from "@/components/events/ShiftCard";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
import ShiftCard from "@/components/events/ShiftCard";
import OrderStatusBadge from "@/components/orders/OrderStatusBadge";
import { useToast } from "@/components/ui/use-toast";
import { format } from "date-fns";
const statusColors = {
Draft: "bg-gray-100 text-gray-800",
Active: "bg-green-100 text-green-800",
Pending: "bg-purple-100 text-purple-800",
Confirmed: "bg-blue-100 text-blue-800",
Completed: "bg-slate-100 text-slate-800",
Canceled: "bg-red-100 text-red-800" // Added Canceled status for completeness
};
// Safe date formatter
const safeFormatDate = (dateString, formatStr) => {
if (!dateString) return "-";
const safeFormatDate = (dateString) => {
if (!dateString) return "—";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return "-";
return format(date, formatStr);
return format(new Date(dateString), "MMMM d, yyyy");
} catch {
return "-";
return "";
}
};
export default function EventDetail() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showNotifyDialog, setShowNotifyDialog] = useState(false);
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('id');
const { toast } = useToast();
const [notifyDialog, setNotifyDialog] = useState(false);
const [cancelDialog, setCancelDialog] = useState(false);
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get("id");
const { data: user } = useQuery({
queryKey: ['current-user-event-detail'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents, isLoading } = useQuery({
queryKey: ['events'],
@@ -54,208 +50,314 @@ export default function EventDetail() {
initialData: [],
});
const { data: shifts } = useQuery({
queryKey: ['shifts', eventId],
queryFn: () => base44.entities.Shift.filter({ event_id: eventId }),
initialData: [],
enabled: !!eventId
});
const event = allEvents.find(e => e.id === eventId);
const handleReorder = () => {
if (!event) return; // Should not happen if event is loaded, but for safety
// Cancel order mutation
const cancelOrderMutation = useMutation({
mutationFn: () => base44.entities.Event.update(eventId, { status: "Canceled" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
toast({
title: "✅ Order Canceled",
description: "Your order has been canceled successfully",
});
setCancelDialog(false);
navigate(createPageUrl("ClientOrders"));
},
onError: () => {
toast({
title: "❌ Failed to Cancel",
description: "Could not cancel order. Please try again.",
variant: "destructive",
});
},
});
const reorderData = {
event_name: event.event_name,
business_id: event.business_id,
business_name: event.business_name,
hub: event.hub,
event_location: event.event_location,
event_type: event.event_type,
requested: event.requested,
client_name: event.client_name,
client_email: event.client_email,
client_phone: event.client_phone,
client_address: event.client_address,
notes: event.notes,
};
sessionStorage.setItem('reorderData', JSON.stringify(reorderData));
const handleNotifyStaff = async () => {
const assignedStaff = event?.assigned_staff || [];
toast({
title: "Reordering Event",
description: `Creating new order based on "${event.event_name}"`,
});
for (const staff of assignedStaff) {
try {
await base44.integrations.Core.SendEmail({
to: staff.email || `${staff.staff_name}@example.com`,
subject: `Shift Update: ${event.event_name}`,
body: `You have an update for: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.event_location || event.hub}\n\nPlease check the platform for details.`
});
} catch (error) {
console.error("Failed to send email:", error);
}
}
navigate(createPageUrl("CreateEvent") + "?reorder=true");
toast({
title: "✅ Notifications Sent",
description: `Notified ${assignedStaff.length} staff members`,
});
setNotifyDialog(false);
};
if (isLoading || !event) {
const isClient = user?.user_role === 'client' ||
event?.created_by === user?.email ||
event?.client_email === user?.email;
const canEditOrder = () => {
if (!event) return false;
const eventDate = new Date(event.date);
const now = new Date();
return isClient &&
event.status !== "Completed" &&
event.status !== "Canceled" &&
eventDate > now;
};
const canCancelOrder = () => {
if (!event) return false;
return isClient &&
event.status !== "Completed" &&
event.status !== "Canceled";
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full" />
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
if (!event) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-xl font-semibold text-slate-900 mb-4">Event not found</p>
<Link to={createPageUrl("Events")}>
<Button variant="outline">Back to Events</Button>
</Link>
</div>
);
}
// Get shifts from event.shifts array (primary source)
const eventShifts = event.shifts || [];
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1600px] mx-auto">
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" onClick={() => navigate(createPageUrl("Events"))}>
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="text-2xl font-bold">{event.event_name}</h1>
<div className="flex items-center gap-2 ml-auto">
{(event.status === "Completed" || event.status === "Canceled") && (
<Button
onClick={handleReorder}
className="bg-green-600 hover:bg-green-700 text-white"
<div className="p-4 md:p-8">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-3xl font-bold text-slate-900">{event.event_name}</h1>
<p className="text-slate-600 mt-1">Order Details & Information</p>
</div>
</div>
<div className="flex items-center gap-3">
<OrderStatusBadge order={event} />
{canEditOrder() && (
<button
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-blue-50 border-2 border-blue-200 rounded-full text-blue-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
>
<RefreshCw className="w-4 h-4 mr-2" />
Reorder
<Edit3 className="w-5 h-5" />
Edit
</button>
)}
{canCancelOrder() && (
<button
onClick={() => setCancelDialog(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-red-50 border-2 border-red-200 rounded-full text-red-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
>
<X className="w-5 h-5" />
cancel
</button>
)}
{!isClient && event.assigned_staff?.length > 0 && (
<Button
onClick={() => setNotifyDialog(true)}
className="bg-blue-600 hover:bg-blue-700"
>
<Send className="w-4 h-4 mr-2" />
Notify Staff
</Button>
)}
<Bell className="w-5 h-5" />
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">
M
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base">Order Details</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div>
<p className="text-xs text-slate-500">PO number</p>
<p className="font-medium">{event.po_number || event.po || "#RC-36559419"}</p>
</div>
<div>
<p className="text-xs text-slate-500">Data</p>
<p className="font-medium">{safeFormatDate(event.date, "dd.MM.yyyy")}</p>
</div>
<div>
<p className="text-xs text-slate-500">Status</p>
<Badge className={`${statusColors[event.status]} font-medium mt-1`}>
{event.status}
</Badge>
</div>
<div className="flex gap-2 pt-4">
<Button variant="outline" className="flex-1 text-sm">
Edit Order
</Button>
<Button variant="outline" className="flex-1 text-sm text-red-600 hover:text-red-700">
Cancel Order
</Button>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 lg:col-span-2">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base">Client info</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-xs text-slate-500 mb-1">Client name</p>
<p className="font-medium">{event.client_name || "Legendary"}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Number</p>
<p className="font-medium">{event.client_phone || "(408) 815-9180"}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-slate-500 mb-1">Address</p>
<p className="font-medium">{event.client_address || event.event_location || "848 E Dash Rd, Ste 264 E San Jose, CA 95122"}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-slate-500 mb-1">Email</p>
<p className="font-medium">{event.client_email || "order@legendarysweetssf.com"}</p>
</div>
</div>
</CardContent>
</Card>
</div>
<Card className="border-slate-200 mb-6">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base">Event: {event.event_name}</CardTitle>
{/* Order Details Card */}
<Card className="bg-white border border-slate-200 shadow-md">
<CardHeader className="border-b border-slate-100">
<CardTitle className="text-lg font-bold text-slate-900">Order Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<p className="text-slate-500">Hub</p>
<p className="font-medium">{event.hub || "Hub Name"}</p>
<div className="grid grid-cols-4 gap-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<Calendar className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-xs text-slate-500">Event Date</p>
<p className="font-bold text-slate-900">{safeFormatDate(event.date)}</p>
</div>
</div>
<div>
<p className="text-slate-500">Name of Department</p>
<p className="font-medium">Department name</p>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500">Location</p>
<p className="font-bold text-slate-900">{event.hub || event.event_location || "—"}</p>
</div>
</div>
<div className="col-span-2">
<p className="text-slate-500 mb-2">Order Addons</p>
<div className="flex gap-2">
<Badge variant="outline" className="text-xs">Title</Badge>
<Badge variant="outline" className="text-xs">Travel Time</Badge>
<Badge variant="outline" className="text-xs">Meal Provided</Badge>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-xs text-slate-500">Staff Assigned</p>
<p className="font-bold text-slate-900">
{event.assigned_staff?.length || 0} / {event.requested || 0}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-amber-600" />
</div>
<div>
<p className="text-xs text-slate-500">Total Cost</p>
<p className="font-bold text-slate-900">${(event.total || 0).toLocaleString()}</p>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-6">
{shifts.length > 0 ? (
shifts.map((shift, idx) => (
<ShiftCard
key={shift.id}
shift={shift}
onNotifyStaff={() => setShowNotifyDialog(true)}
/>
{/* Client Information (if not client viewing) */}
{!isClient && (
<Card className="bg-white border border-slate-200 shadow-md">
<CardHeader className="border-b border-slate-100">
<CardTitle className="text-lg font-bold text-slate-900">Client Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-3 gap-6">
<div>
<p className="text-xs text-slate-500 mb-1">Business Name</p>
<p className="font-bold text-slate-900">{event.business_name || "—"}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Contact Name</p>
<p className="font-bold text-slate-900">{event.client_name || "—"}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Contact Email</p>
<p className="font-bold text-slate-900">{event.client_email || "—"}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Shifts - Using event.shifts array */}
<div className="space-y-4">
<h2 className="text-xl font-bold text-slate-900">Event Shifts & Staff Assignment</h2>
{eventShifts.length > 0 ? (
eventShifts.map((shift, idx) => (
<ShiftCard key={idx} shift={shift} event={event} />
))
) : (
<ShiftCard
shift={{
shift_name: "Shift 1",
assigned_staff: event.assigned_staff || [],
location: event.event_location,
unpaid_break: 0,
price: 23,
amount: 120
}}
onNotifyStaff={() => setShowNotifyDialog(true)}
/>
<Card className="bg-white border border-slate-200">
<CardContent className="p-12 text-center">
<Users className="w-12 h-12 mx-auto mb-4 text-slate-400" />
<p className="text-slate-600 font-medium mb-2">No shifts defined for this event</p>
<p className="text-slate-500 text-sm">Add roles and staff requirements to get started</p>
</CardContent>
</Card>
)}
</div>
<Dialog open={showNotifyDialog} onOpenChange={setShowNotifyDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="w-12 h-12 bg-pink-500 rounded-full flex items-center justify-center text-white font-bold text-xl">
L
</div>
</div>
<DialogTitle className="text-center">Notification Name</DialogTitle>
<p className="text-center text-sm text-slate-600">
Order #5 Admin (cancelled/replace) Want to proceed?
</p>
</DialogHeader>
<DialogFooter className="flex gap-3 sm:justify-center">
<Button variant="outline" onClick={() => setShowNotifyDialog(false)} className="flex-1">
Cancel
</Button>
<Button onClick={() => setShowNotifyDialog(false)} className="flex-1 bg-blue-600 hover:bg-blue-700">
Proceed
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Notes */}
{event.notes && (
<Card className="bg-white border border-slate-200 shadow-md">
<CardHeader className="border-b border-slate-100">
<CardTitle className="text-lg font-bold text-slate-900">Additional Notes</CardTitle>
</CardHeader>
<CardContent className="p-6">
<p className="text-slate-700 whitespace-pre-wrap">{event.notes}</p>
</CardContent>
</Card>
)}
</div>
{/* Notify Staff Dialog */}
<Dialog open={notifyDialog} onOpenChange={setNotifyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Notify Assigned Staff</DialogTitle>
<DialogDescription>
Send notification to all {event.assigned_staff?.length || 0} assigned staff members about this event.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setNotifyDialog(false)}>
Cancel
</Button>
<Button onClick={handleNotifyStaff} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-4 h-4 mr-2" />
Send Notifications
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancel Order Dialog */}
<Dialog open={cancelDialog} onOpenChange={setCancelDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="w-5 h-5" />
Cancel Order?
</DialogTitle>
<DialogDescription>
Are you sure you want to cancel this order? This action cannot be undone and the vendor will be notified immediately.
</DialogDescription>
</DialogHeader>
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<p className="font-bold text-slate-900">{event.event_name}</p>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
{safeFormatDate(event.date)}
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<MapPin className="w-4 h-4" />
{event.hub || event.event_location}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialog(false)}
>
Keep Order
</Button>
<Button
variant="destructive"
onClick={() => cancelOrderMutation.mutate()}
disabled={cancelOrderMutation.isPending}
>
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ const statusColors = {
'Overdue': 'bg-red-500 text-white',
'Resolved': 'bg-blue-500 text-white',
'Paid': 'bg-green-500 text-white',
'Reconciled': 'bg-yellow-600 text-white',
'Reconciled': 'bg-amber-600 text-white', // Changed from bg-yellow-600
'Disputed': 'bg-gray-500 text-white',
'Verified': 'bg-teal-500 text-white',
'Pending': 'bg-amber-500 text-white',
@@ -161,7 +161,7 @@ export default function Invoices() {
<Button
onClick={() => setShowPaymentDialog(true)}
variant="outline"
className="bg-yellow-400 hover:bg-yellow-500 text-slate-900 border-0 font-semibold"
className="bg-amber-500 hover:bg-amber-600 text-white border-0 font-semibold" // Changed className
>
Record Payment
</Button>

View File

@@ -1,5 +1,4 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { createPageUrl } from "@/utils";
@@ -10,7 +9,7 @@ import {
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
Building2, Sparkles, CheckSquare, UserCheck, Store
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -32,6 +31,7 @@ import { Badge } from "@/components/ui/badge";
import ChatBubble from "@/components/chat/ChatBubble";
import RoleSwitcher from "@/components/dev/RoleSwitcher";
import NotificationPanel from "@/components/notifications/NotificationPanel";
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
import { Toaster } from "@/components/ui/toaster";
// Navigation items for each role
@@ -44,7 +44,9 @@ const roleNavigationMap = {
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
@@ -57,13 +59,14 @@ const roleNavigationMap = {
],
procurement: [
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
{ title: "Orders", url: createPageUrl("Events"), icon: Clipboard },
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
@@ -71,25 +74,27 @@ const roleNavigationMap = {
],
operator: [
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
sector: [
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
@@ -101,6 +106,7 @@ const roleNavigationMap = {
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
@@ -108,16 +114,17 @@ const roleNavigationMap = {
],
vendor: [
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
{ title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
{ title: "Tasks", url: createPageUrl("ActivityLog"), icon: CheckSquare },
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
@@ -125,8 +132,10 @@ const roleNavigationMap = {
],
workforce: [
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
@@ -281,200 +290,34 @@ export default function Layout({ children }) {
--muted: 241 245 249;
}
/* Calendar styling kept as is */
.rdp * {
border-color: transparent !important;
}
.rdp-day {
font-size: 0.875rem !important;
min-width: 36px !important;
height: 36px !important;
border-radius: 50% !important;
transition: all 0.2s ease !important;
font-weight: 500 !important;
position: relative !important;
}
.rdp-day button {
width: 100% !important;
height: 100% !important;
border-radius: 50% !important;
background-color: transparent !important;
}
.rdp-day_range_start,
.rdp-day_range_start > button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
color: white !important;
font-weight: 700 !important;
border-radius: 50% !important;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
}
.rdp-day_range_end,
.rdp-day_range_end > button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
color: white !important;
font-weight: 700 !important;
border-radius: 50% !important;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
}
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end),
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
color: white !important;
font-weight: 700 !important;
border-radius: 50% !important;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
}
.rdp-day_selected,
.rdp-day_selected > button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
color: white !important;
font-weight: 700 !important;
border-radius: 50% !important;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
}
.rdp-day_range_middle,
.rdp-day_range_middle > button {
background-color: #dbeafe !important;
background: #dbeafe !important;
color: #2563eb !important;
font-weight: 600 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.rdp-day_range_start.rdp-day_range_end,
.rdp-day_range_start.rdp-day_range_end > button {
border-radius: 50% !important;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
}
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button {
background-color: #eff6ff !important;
background: #eff6ff !important;
color: #2563eb !important;
border-radius: 50% !important;
}
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after {
content: '' !important;
position: absolute !important;
bottom: 4px !important;
left: 50% !important;
transform: translateX(-50%) !important;
width: 4px !important;
height: 4px !important;
background-color: #ec4899 !important;
border-radius: 50% !important;
z-index: 10 !important;
}
.rdp-day_today.rdp-day_selected,
.rdp-day_today.rdp-day_range_start,
.rdp-day_today.rdp-day_range_end {
color: white !important;
}
.rdp-day_today.rdp-day_selected > button,
.rdp-day_today.rdp-day_range_start > button,
.rdp-day_today.rdp-day_range_end > button {
color: white !important;
}
.rdp-day_outside,
.rdp-day_outside > button {
color: #cbd5e1 !important;
opacity: 0.5 !important;
}
.rdp-day_disabled,
.rdp-day_disabled > button {
opacity: 0.3 !important;
cursor: not-allowed !important;
}
.rdp-day_selected,
.rdp-day_range_start,
.rdp-day_range_end,
.rdp-day_range_middle {
opacity: 1 !important;
visibility: visible !important;
z-index: 5 !important;
}
.rdp-head_cell {
color: #64748b !important;
font-weight: 600 !important;
font-size: 0.75rem !important;
text-transform: uppercase !important;
padding: 8px 0 !important;
}
.rdp-caption_label {
font-size: 1rem !important;
font-weight: 700 !important;
color: #0f172a !important;
}
.rdp-nav_button {
width: 32px !important;
height: 32px !important;
border-radius: 6px !important;
transition: all 0.2s ease !important;
}
.rdp-nav_button:hover {
background-color: #eff6ff !important;
color: #2563eb !important;
}
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before {
content: '' !important;
position: absolute !important;
top: 4px !important;
right: 4px !important;
width: 4px !important;
height: 4px !important;
background-color: #2563eb !important;
border-radius: 50% !important;
}
.rdp-day_selected.has-events::before,
.rdp-day_range_start.has-events::before,
.rdp-day_range_end.has-events::before {
background-color: white !important;
}
.rdp-day_range_middle.has-events::before {
background-color: #2563eb !important;
}
.rdp-months {
gap: 2rem !important;
}
.rdp-month {
padding: 0.75rem !important;
}
.rdp-table {
border-spacing: 0 !important;
margin-top: 1rem !important;
}
.rdp-cell {
padding: 2px !important;
}
.rdp-day[style*="background"] {
background: transparent !important;
}
.rdp * { border-color: transparent !important; }
.rdp-day { font-size: 0.875rem !important; min-width: 36px !important; height: 36px !important; border-radius: 50% !important; transition: all 0.2s ease !important; font-weight: 500 !important; position: relative !important; }
.rdp-day button { width: 100% !important; height: 100% !important; border-radius: 50% !important; background-color: transparent !important; }
.rdp-day_range_start, .rdp-day_range_start > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_range_end, .rdp-day_range_end > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end), .rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_selected, .rdp-day_selected > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_range_middle, .rdp-day_range_middle > button { background-color: #dbeafe !important; background: #dbeafe !important; color: #2563eb !important; font-weight: 600 !important; border-radius: 0 !important; box-shadow: none !important; }
.rdp-day_range_start.rdp-day_range_end, .rdp-day_range_start.rdp-day_range_end > button { border-radius: 50% !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; }
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button { background-color: #eff6ff !important; background: #eff6ff !important; color: #2563eb !important; border-radius: 50% !important; }
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after { content: '' !important; position: absolute !important; bottom: 4px !important; left: 50% !important; transform: translateX(-50%) !important; width: 4px !important; height: 4px !important; background-color: #ec4899 !important; border-radius: 50% !important; z-index: 10 !important; }
.rdp-day_today.rdp-day_selected, .rdp-day_today.rdp-day_range_start, .rdp-day_today.rdp-day_range_end { color: white !important; }
.rdp-day_today.rdp-day_selected > button, .rdp-day_today.rdp-day_range_start > button, .rdp-day_today.rdp-day_range_end > button { color: white !important; }
.rdp-day_outside, .rdp-day_outside > button { color: #cbd5e1 !important; opacity: 0.5 !important; }
.rdp-day_disabled, .rdp-day_disabled > button { opacity: 0.3 !important; cursor: not-allowed !important; }
.rdp-day_selected, .rdp-day_range_start, .rdp-day_range_end, .rdp-day_range_middle { opacity: 1 !important; visibility: visible !important; z-index: 5 !important; }
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before { content: '' !important; position: absolute !important; top: 4px !important; right: 4px !important; width: 4px !important; height: 4px !important; background-color: #2563eb !important; border-radius: 50% !important; }
.rdp-day_selected.has-events::before, .rdp-day_range_start.has-events::before, .rdp-day_range_end.has-events::before { background-color: white !important; }
.rdp-day_range_middle.has-events::before { background-color: #2563eb !important; }
.rdp-head_cell { color: #64748b !important; font-weight: 600 !important; font-size: 0.75rem !important; text-transform: uppercase !important; padding: 8px 0 !important; }
.rdp-caption_label { font-size: 1rem !important; font-weight: 700 !important; color: #0f172a !important; }
.rdp-nav_button { width: 32px !important; height: 32px !important; border-radius: 6px !important; transition: all 0.2s ease !important; }
.rdp-nav_button:hover { background-color: #eff6ff !important; color: #2563eb !important; }
.rdp-months { gap: 2rem !important; }
.rdp-month { padding: 0.75rem !important; }
.rdp-table { border-spacing: 0 !important; margin-top: 1rem !important; }
.rdp-cell { padding: 2px !important; }
.rdp-day[style*="background"] { background: transparent !important; }
`}</style>
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
@@ -490,20 +333,14 @@ export default function Layout({ children }) {
<div className="border-b border-slate-200 p-6">
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
<div className="w-8 h-8 flex items-center justify-center">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
alt="KROW Logo"
className="w-full h-full object-contain"
/>
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
</div>
<h2 className="font-bold text-[#1C323E]">KROW</h2>
</Link>
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
<Avatar className="w-10 h-10">
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
{userInitial}
</AvatarFallback>
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">{userInitial}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
@@ -515,13 +352,8 @@ export default function Layout({ children }) {
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
</div>
<div className="p-3 border-t border-slate-200">
<Button
variant="ghost"
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => {handleLogout(); setMobileMenuOpen(false);}}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
<LogOut className="w-4 h-4 mr-2" />Logout
</Button>
</div>
</SheetContent>
@@ -529,11 +361,7 @@ export default function Layout({ children }) {
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<div className="w-8 h-8 flex items-center justify-center">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
alt="KROW Logo"
className="w-full h-full object-contain"
/>
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
</div>
<div className="hidden sm:block">
<h1 className="text-base font-bold text-[#1C323E]">KROW Workforce Control Tower</h1>
@@ -543,39 +371,22 @@ export default function Layout({ children }) {
<div className="hidden md:flex flex-1 max-w-xl">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Find employees, menu items, settings, and more..."
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent"
/>
<input type="text" placeholder="Find employees, menu items, settings, and more..." className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group"
title="Unpublished changes - Click to refresh"
>
<button onClick={handleRefresh} className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group" title="Unpublished changes - Click to refresh">
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
</button>
<Button
variant="ghost"
size="icon"
className="md:hidden hover:bg-slate-100"
title="Search"
>
<Button variant="ghost" size="icon" className="md:hidden hover:bg-slate-100" title="Search">
<Search className="w-5 h-5 text-slate-600" />
</Button>
<button
onClick={() => setShowNotifications(true)}
className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors"
title="Notifications"
>
<button onClick={() => setShowNotifications(true)} className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors" title="Notifications">
<Bell className="w-5 h-5 text-slate-600" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
@@ -609,22 +420,21 @@ export default function Layout({ children }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("NotificationSettings")}>
<Bell className="w-4 h-4 mr-2" />Notification Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
<SettingsIcon className="w-4 h-4 mr-2" />
Settings
<SettingsIcon className="w-4 h-4 mr-2" />Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
<FileText className="w-4 h-4 mr-2" />
Reports
<FileText className="w-4 h-4 mr-2" />Reports
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
<Activity className="w-4 h-4 mr-2" />
Activity Log
<Activity className="w-4 h-4 mr-2" />Activity Log
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
<LogOut className="w-4 h-4 mr-2" />
Logout
<LogOut className="w-4 h-4 mr-2" />Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -634,9 +444,7 @@ export default function Layout({ children }) {
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
<Avatar className="w-8 h-8">
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">
{userInitial}
</AvatarFallback>
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">{userInitial}</AvatarFallback>
</Avatar>
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
</button>
@@ -651,12 +459,10 @@ export default function Layout({ children }) {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
<Home className="w-4 h-4 mr-2" />
Dashboard
<Home className="w-4 h-4 mr-2" />Dashboard
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
<User className="w-4 h-4 mr-2" />
My Profile
<User className="w-4 h-4 mr-2" />My Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
@@ -686,15 +492,11 @@ export default function Layout({ children }) {
</div>
</div>
<NotificationPanel
isOpen={showNotifications}
onClose={() => setShowNotifications(false)}
/>
<NotificationPanel isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
<NotificationEngine />
<ChatBubble />
<RoleSwitcher />
<Toaster />
</div>
);
}

View File

@@ -0,0 +1,271 @@
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 { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Bell, Mail, Calendar, Briefcase, AlertCircle, CheckCircle } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { Badge } from "@/components/ui/badge";
export default function NotificationSettings() {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: currentUser } = useQuery({
queryKey: ['current-user-notification-settings'],
queryFn: () => base44.auth.me(),
});
const [preferences, setPreferences] = useState(
currentUser?.notification_preferences || {
email_notifications: true,
in_app_notifications: true,
shift_assignments: true,
shift_reminders: true,
shift_changes: true,
upcoming_events: true,
new_leads: true,
invoice_updates: true,
system_alerts: true,
}
);
const updatePreferencesMutation = useMutation({
mutationFn: (prefs) => base44.auth.updateMe({ notification_preferences: prefs }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user-notification-settings'] });
toast({
title: "✅ Settings Updated",
description: "Your notification preferences have been saved",
});
},
onError: (error) => {
toast({
title: "❌ Update Failed",
description: error.message,
variant: "destructive",
});
},
});
const handleToggle = (key) => {
setPreferences(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = () => {
updatePreferencesMutation.mutate(preferences);
};
const userRole = currentUser?.role || currentUser?.user_role || 'admin';
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Notification Settings</h1>
<p className="text-sm text-slate-500 mt-1">
Configure how and when you receive notifications
</p>
</div>
<div className="space-y-4">
{/* Global Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
Global Notification Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-blue-600" />
<div>
<Label className="font-semibold">In-App Notifications</Label>
<p className="text-sm text-slate-500">Receive notifications in the app</p>
</div>
</div>
<Switch
checked={preferences.in_app_notifications}
onCheckedChange={() => handleToggle('in_app_notifications')}
/>
</div>
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-purple-600" />
<div>
<Label className="font-semibold">Email Notifications</Label>
<p className="text-sm text-slate-500">Receive notifications via email</p>
</div>
</div>
<Switch
checked={preferences.email_notifications}
onCheckedChange={() => handleToggle('email_notifications')}
/>
</div>
</CardContent>
</Card>
{/* Staff/Workforce Notifications */}
{(userRole === 'workforce' || userRole === 'admin' || userRole === 'vendor') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Shift Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Shift Assignments</Label>
<p className="text-sm text-slate-500">When you're assigned to a new shift</p>
</div>
<Switch
checked={preferences.shift_assignments}
onCheckedChange={() => handleToggle('shift_assignments')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Shift Reminders</Label>
<p className="text-sm text-slate-500">24 hours before your shift starts</p>
</div>
<Switch
checked={preferences.shift_reminders}
onCheckedChange={() => handleToggle('shift_reminders')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Shift Changes</Label>
<p className="text-sm text-slate-500">When shift details are modified</p>
</div>
<Switch
checked={preferences.shift_changes}
onCheckedChange={() => handleToggle('shift_changes')}
/>
</div>
</CardContent>
</Card>
)}
{/* Client Notifications */}
{(userRole === 'client' || userRole === 'admin') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="w-5 h-5" />
Event Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Upcoming Events</Label>
<p className="text-sm text-slate-500">Reminders 3 days before your event</p>
</div>
<Switch
checked={preferences.upcoming_events}
onCheckedChange={() => handleToggle('upcoming_events')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Staff Updates</Label>
<p className="text-sm text-slate-500">When staff are assigned or changed</p>
</div>
<Switch
checked={preferences.shift_changes}
onCheckedChange={() => handleToggle('shift_changes')}
/>
</div>
</CardContent>
</Card>
)}
{/* Vendor Notifications */}
{(userRole === 'vendor' || userRole === 'admin') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="w-5 h-5" />
Business Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">New Leads</Label>
<p className="text-sm text-slate-500">When new staffing opportunities are available</p>
</div>
<Switch
checked={preferences.new_leads}
onCheckedChange={() => handleToggle('new_leads')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Invoice Updates</Label>
<p className="text-sm text-slate-500">Invoice status changes and payments</p>
</div>
<Switch
checked={preferences.invoice_updates}
onCheckedChange={() => handleToggle('invoice_updates')}
/>
</div>
</CardContent>
</Card>
)}
{/* System Notifications */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
System Notifications
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">System Alerts</Label>
<p className="text-sm text-slate-500">Important platform updates and announcements</p>
</div>
<Switch
checked={preferences.system_alerts}
onCheckedChange={() => handleToggle('system_alerts')}
/>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setPreferences(currentUser?.notification_preferences || {})}
>
Reset
</Button>
<Button
onClick={handleSave}
disabled={updatePreferencesMutation.isPending}
className="bg-[#0A39DF]"
>
{updatePreferencesMutation.isPending ? "Saving..." : "Save Preferences"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,505 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles, Mic, X, Calendar as CalendarIcon, ArrowLeft } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
import { format } from "date-fns";
// Helper function to convert 24-hour time to 12-hour format
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 RapidOrder() {
const navigate = useNavigate();
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 [isListening, setIsListening] = useState(false);
const [submissionTime, setSubmissionTime] = useState(null);
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'] });
queryClient.invalidateQueries({ queryKey: ['client-events'] });
const now = new Date();
setSubmissionTime(now);
toast({
title: "✅ RAPID Order Created",
description: "Order sent to preferred vendor with priority notification",
});
// Show success message in chat
setConversation(prev => [...prev, {
role: 'assistant',
content: `🚀 **Order Submitted Successfully!**\n\nOrder Number: **${data.id?.slice(-8) || 'RAPID-001'}**\nSubmitted: **${format(now, 'h:mm:ss a')}**\n\nYour preferred vendor has been notified and will assign staff shortly.`,
isSuccess: true
}]);
// Reset after delay
setTimeout(() => {
navigate(createPageUrl("ClientDashboard"));
}, 3000);
},
});
const analyzeMessage = async (msg) => {
setIsProcessing(true);
setConversation(prev => [...prev, { role: 'user', content: msg }]);
try {
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, parse the number correctly - e.g., "5 cooks" = 5, "need 3 servers" = 3)
4. End time (if mentioned, extract the time - e.g., "until 5am" = "05:00", "until 11pm" = "23:00", "until midnight" = "00:00")
5. Location (if mentioned, otherwise use first available location)
IMPORTANT:
- Make sure to correctly extract the number of staff from phrases like "need 5 cooks" or "I need 3 bartenders"
- If end time is mentioned (e.g., "until 5am", "till 11pm"), extract it in 24-hour format (e.g., "05:00", "23:00")
- If no end time is mentioned, leave it as null
Return a concise summary.`,
response_json_schema: {
type: "object",
properties: {
is_urgent: { type: "boolean" },
role: { type: "string" },
count: { type: "number" },
location: { type: "string" },
end_time: { type: "string" }
}
}
});
const parsed = response;
const primaryLocation = businesses[0]?.business_name || "Primary Location";
// Ensure count is properly set - default to 1 if not detected
const staffCount = parsed.count && parsed.count > 0 ? parsed.count : 1;
// Get current time for start_time (when ASAP)
const now = new Date();
const currentTime = format(now, 'HH:mm');
// Handle end_time - use parsed end time or current time as confirmation time
const endTime = parsed.end_time || currentTime;
const order = {
is_rapid: parsed.is_urgent || true,
role: parsed.role || "Staff Member",
count: staffCount,
location: parsed.location || primaryLocation,
start_time: currentTime, // Always use current time for ASAP orders (24-hour format for storage)
end_time: endTime, // Use parsed end time or current time (24-hour format for storage)
start_time_display: convertTo12Hour(currentTime), // For display
end_time_display: convertTo12Hour(endTime), // For display
business_name: primaryLocation,
hub: businesses[0]?.hub_building || "Main Hub",
submission_time: now // Store the actual submission time
};
setDetectedOrder(order);
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nStart Time: ${order.start_time_display}\nEnd Time: ${order.end_time_display}`;
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 handleVoiceInput = () => {
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
toast({
title: "Voice not supported",
description: "Your browser doesn't support voice input",
variant: "destructive",
});
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.onstart = () => setIsListening(true);
recognition.onend = () => setIsListening(false);
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
setMessage(transcript);
analyzeMessage(transcript);
};
recognition.onerror = () => {
setIsListening(false);
toast({
title: "Voice input failed",
description: "Please try typing instead",
variant: "destructive",
});
};
recognition.start();
};
const handleConfirmOrder = () => {
if (!detectedOrder) return;
const now = new Date();
const confirmTime = format(now, 'HH:mm');
const confirmTime12Hour = convertTo12Hour(confirmTime);
// Create comprehensive order data with proper requested field and actual times
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: Number(detectedOrder.count), // Ensure it's a number
client_name: user?.full_name,
client_email: user?.email,
notes: `RAPID ORDER - Submitted at ${detectedOrder.start_time_display} - Confirmed at ${confirmTime12Hour}\nStart: ${detectedOrder.start_time_display} | End: ${detectedOrder.end_time_display}`,
shifts: [{
shift_name: "Emergency Shift",
location: detectedOrder.location,
roles: [{
role: detectedOrder.role,
count: Number(detectedOrder.count), // Ensure it's a number
start_time: detectedOrder.start_time, // Store in 24-hour format
end_time: detectedOrder.end_time // Store in 24-hour format
}]
}]
};
console.log('Creating RAPID order with data:', orderData); // Debug log
createRapidOrderMutation.mutate(orderData);
};
const handleEditOrder = () => {
setConversation(prev => [...prev, {
role: 'assistant',
content: "Please describe what you'd like to change."
}]);
setDetectedOrder(null);
};
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 p-6">
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl("ClientDashboard"))}
className="hover:bg-white/50"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<div className="flex items-center gap-3 mb-2">
<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>
<h1 className="text-3xl font-bold text-red-700 flex items-center gap-2">
<Sparkles className="w-6 h-6" />
RAPID Order
</h1>
<p className="text-sm text-red-600 mt-1">Emergency staffing in minutes</p>
</div>
</div>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-2 text-sm text-slate-600 mb-1">
<CalendarIcon className="w-4 h-4" />
<span>{format(new Date(), 'EEEE, MMMM d, yyyy')}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Clock className="w-4 h-4" />
<span>{format(new Date(), 'h:mm a')}</span>
</div>
</div>
</div>
<Card className="bg-white border-2 border-red-300 shadow-2xl">
<CardHeader className="border-b border-red-200 bg-gradient-to-r from-red-50 to-orange-50">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold text-red-700">
Tell us what you need
</CardTitle>
<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-[500px] overflow-y-auto">
{conversation.length === 0 && (
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-2xl">
<Zap className="w-10 h-10 text-white" />
</div>
<h3 className="font-bold text-2xl text-slate-900 mb-3">Need staff urgently?</h3>
<p className="text-base text-slate-600 mb-6">Type or speak what you need, I'll handle the rest</p>
<div className="text-left max-w-lg mx-auto space-y-3">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-4 rounded-xl border-2 border-blue-200 text-sm">
<strong className="text-blue-900">Example:</strong> <span className="text-slate-700">"We had a call out. Need 2 cooks ASAP"</span>
</div>
<div className="bg-gradient-to-r from-purple-50 to-pink-50 p-4 rounded-xl border-2 border-purple-200 text-sm">
<strong className="text-purple-900">Example:</strong> <span className="text-slate-700">"Need 5 bartenders ASAP until 5am"</span>
</div>
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4 rounded-xl border-2 border-green-200 text-sm">
<strong className="text-green-900">Example:</strong> <span className="text-slate-700">"Emergency! Need 3 servers right now till midnight"</span>
</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-[85%] ${
msg.role === 'user'
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
: msg.isSuccess
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300'
: 'bg-white border-2 border-red-200'
} rounded-2xl p-5 shadow-lg`}>
{msg.role === 'assistant' && !msg.isSuccess && (
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
</div>
<span className="text-xs font-bold text-red-600">AI Assistant</span>
</div>
)}
<p className={`text-base whitespace-pre-line ${
msg.role === 'user' ? 'text-white' :
msg.isSuccess ? 'text-green-900' :
'text-slate-900'
}`}>
{msg.content}
</p>
{msg.showConfirm && detectedOrder && (
<div className="mt-5 space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 bg-gradient-to-br from-slate-50 to-blue-50 rounded-xl border-2 border-blue-300">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-slate-500 font-semibold">Staff Needed</p>
<p className="font-bold text-base text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-slate-500 font-semibold">Location</p>
<p className="font-bold text-base text-slate-900">{detectedOrder.location}</p>
</div>
</div>
<div className="flex items-center gap-3 col-span-2">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-slate-500 font-semibold">Time</p>
<p className="font-bold text-base text-slate-900">
Start: {detectedOrder.start_time_display} | End: {detectedOrder.end_time_display}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<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-xl text-base py-6"
>
<Check className="w-5 h-5 mr-2" />
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
</Button>
<Button
onClick={handleEditOrder}
variant="outline"
className="border-2 border-red-300 hover:bg-red-50 text-base py-6"
>
<Edit3 className="w-5 h-5 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-5 shadow-lg">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
<Sparkles className="w-4 h-4 text-white" />
</div>
<span className="text-base text-slate-600">Processing your request...</span>
</div>
</div>
</motion.div>
)}
</div>
{/* Input */}
<div className="space-y-4">
<div className="flex gap-2">
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder="Type or speak... (e.g., 'Need 5 cooks ASAP until 5am')"
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base resize-none"
rows={3}
disabled={isProcessing}
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleVoiceInput}
disabled={isProcessing || isListening}
variant="outline"
className={`border-2 ${isListening ? 'border-red-500 bg-red-50' : 'border-red-300'} hover:bg-red-50 text-base py-6 px-6`}
>
<Mic className={`w-5 h-5 mr-2 ${isListening ? 'animate-pulse text-red-600' : ''}`} />
{isListening ? 'Listening...' : 'Speak'}
</Button>
<Button
onClick={handleSendMessage}
disabled={!message.trim() || isProcessing}
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-xl text-base py-6"
>
<Send className="w-5 h-5 mr-2" />
Send Message
</Button>
</div>
</div>
{/* Helper Text */}
<div className="mt-4 p-4 bg-blue-50 border-2 border-blue-200 rounded-xl">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
Optionally add end time like "until 5am" or "till midnight".
AI will auto-detect your location and send to your preferred vendor with priority notification.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, Sparkles } from "lucide-react";
export default function SmartScheduler() {
const navigate = useNavigate();
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen flex items-center justify-center">
<Card className="max-w-2xl w-full">
<CardContent className="p-12 text-center">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-4">
Smart Scheduling is Now Part of Orders
</h1>
<p className="text-lg text-slate-600 mb-8">
All smart assignment, automation, and scheduling features have been unified into the main Order Management view for a consistent experience.
</p>
<Button
size="lg"
onClick={() => navigate(createPageUrl("Events"))}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
Go to Order Management
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckCircle, Circle } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import ProfileSetupStep from "@/components/onboarding/ProfileSetupStep";
import DocumentUploadStep from "@/components/onboarding/DocumentUploadStep";
import TrainingStep from "@/components/onboarding/TrainingStep";
import CompletionStep from "@/components/onboarding/CompletionStep";
const steps = [
{ id: 1, name: "Profile Setup", description: "Basic information" },
{ id: 2, name: "Documents", description: "Upload required documents" },
{ id: 3, name: "Training", description: "Complete compliance training" },
{ id: 4, name: "Complete", description: "Finish onboarding" },
];
export default function StaffOnboarding() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(1);
const [onboardingData, setOnboardingData] = useState({
profile: {},
documents: [],
training: { completed: [] },
});
const { data: currentUser } = useQuery({
queryKey: ['current-user-onboarding'],
queryFn: () => base44.auth.me(),
});
const createStaffMutation = useMutation({
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
toast({
title: "✅ Onboarding Complete",
description: "Welcome to KROW! Your profile is now active.",
});
navigate(createPageUrl("WorkforceDashboard"));
},
onError: (error) => {
toast({
title: "❌ Onboarding Failed",
description: error.message,
variant: "destructive",
});
},
});
const handleNext = (stepData) => {
setOnboardingData(prev => ({
...prev,
[stepData.type]: stepData.data,
}));
if (currentStep < steps.length) {
setCurrentStep(currentStep + 1);
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleComplete = () => {
const staffData = {
employee_name: onboardingData.profile.full_name,
email: onboardingData.profile.email || currentUser?.email,
phone: onboardingData.profile.phone,
address: onboardingData.profile.address,
city: onboardingData.profile.city,
position: onboardingData.profile.position,
department: onboardingData.profile.department,
hub_location: onboardingData.profile.hub_location,
employment_type: onboardingData.profile.employment_type,
english: onboardingData.profile.english_level,
certifications: onboardingData.documents.filter(d => d.type === 'certification').map(d => ({
name: d.name,
issued_date: d.issued_date,
expiry_date: d.expiry_date,
document_url: d.url,
})),
background_check_status: onboardingData.documents.some(d => d.type === 'background_check') ? 'pending' : 'not_required',
notes: `Onboarding completed: ${new Date().toISOString()}. Training modules completed: ${onboardingData.training.completed.length}`,
};
createStaffMutation.mutate(staffData);
};
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<ProfileSetupStep
data={onboardingData.profile}
onNext={handleNext}
currentUser={currentUser}
/>
);
case 2:
return (
<DocumentUploadStep
data={onboardingData.documents}
onNext={handleNext}
onBack={handleBack}
/>
);
case 3:
return (
<TrainingStep
data={onboardingData.training}
onNext={handleNext}
onBack={handleBack}
/>
);
case 4:
return (
<CompletionStep
data={onboardingData}
onComplete={handleComplete}
onBack={handleBack}
isSubmitting={createStaffMutation.isPending}
/>
);
default:
return null;
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 md:p-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-900 mb-2">
Welcome to KROW! 👋
</h1>
<p className="text-slate-600">
Let's get you set up in just a few steps
</p>
</div>
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{steps.map((step, idx) => (
<React.Fragment key={step.id}>
<div className="flex flex-col items-center flex-1">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
currentStep > step.id
? "bg-green-500 text-white"
: currentStep === step.id
? "bg-[#0A39DF] text-white"
: "bg-slate-200 text-slate-400"
}`}>
{currentStep > step.id ? (
<CheckCircle className="w-6 h-6" />
) : (
<span className="font-bold">{step.id}</span>
)}
</div>
<p className={`text-sm font-medium mt-2 ${
currentStep >= step.id ? "text-slate-900" : "text-slate-400"
}`}>
{step.name}
</p>
<p className="text-xs text-slate-500">{step.description}</p>
</div>
{idx < steps.length - 1 && (
<div className={`flex-1 h-1 ${
currentStep > step.id ? "bg-green-500" : "bg-slate-200"
}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Step Content */}
<Card>
<CardContent className="p-6 md:p-8">
{renderStep()}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,466 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, 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 { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
import { Link2, Plus, Users } from "lucide-react";
import TaskCard from "@/components/tasks/TaskCard";
import TaskColumn from "@/components/tasks/TaskColumn";
import TaskDetailModal from "@/components/tasks/TaskDetailModal";
import { useToast } from "@/components/ui/use-toast";
export default function TaskBoard() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [createDialog, setCreateDialog] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
const [selectedStatus, setSelectedStatus] = useState("pending");
const [newTask, setNewTask] = useState({
task_name: "",
description: "",
priority: "normal",
due_date: "",
progress: 0,
assigned_members: []
});
const [selectedMembers, setSelectedMembers] = useState([]);
const { data: user } = useQuery({
queryKey: ['current-user-taskboard'],
queryFn: () => base44.auth.me(),
});
const { data: teams = [] } = useQuery({
queryKey: ['teams'],
queryFn: () => base44.entities.Team.list(),
initialData: [],
});
const { data: teamMembers = [] } = useQuery({
queryKey: ['team-members'],
queryFn: () => base44.entities.TeamMember.list(),
initialData: [],
});
const { data: tasks = [] } = useQuery({
queryKey: ['tasks'],
queryFn: () => base44.entities.Task.list(),
initialData: [],
});
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
const teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
const regularMembers = currentTeamMembers.filter(m => m.role === 'member');
// Get unique departments from team members
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
const tasksByStatus = useMemo(() => ({
pending: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
}), [teamTasks]);
const overallProgress = useMemo(() => {
if (teamTasks.length === 0) return 0;
const totalProgress = teamTasks.reduce((sum, t) => sum + (t.progress || 0), 0);
return Math.round(totalProgress / teamTasks.length);
}, [teamTasks]);
const createTaskMutation = useMutation({
mutationFn: (taskData) => base44.entities.Task.create(taskData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setCreateDialog(false);
setNewTask({
task_name: "",
description: "",
priority: "normal",
due_date: "",
progress: 0,
assigned_members: []
});
setSelectedMembers([]);
toast({
title: "✅ Task Created",
description: "New task added to the board",
});
},
});
const updateTaskMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
const handleDragEnd = (result) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId === destination.droppableId && source.index === destination.index) {
return;
}
const task = teamTasks.find(t => t.id === draggableId);
if (!task) return;
const newStatus = destination.droppableId;
updateTaskMutation.mutate({
id: task.id,
data: {
...task,
status: newStatus,
order_index: destination.index
}
});
};
const handleCreateTask = () => {
if (!newTask.task_name.trim()) {
toast({
title: "Task name required",
variant: "destructive",
});
return;
}
createTaskMutation.mutate({
...newTask,
team_id: userTeam?.id,
status: selectedStatus,
order_index: tasksByStatus[selectedStatus]?.length || 0,
assigned_members: selectedMembers.map(m => ({
member_id: m.id,
member_name: m.member_name,
avatar_url: m.avatar_url
})),
assigned_department: selectedMembers.length > 0 && selectedMembers[0].department ? selectedMembers[0].department : null
});
};
return (
<div className="p-6 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-600">Lead</span>
<div className="flex -space-x-2">
{leadMembers.slice(0, 3).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}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{leadMembers.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{leadMembers.length - 3}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-600">Team</span>
<div className="flex -space-x-2">
{regularMembers.slice(0, 3).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}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{regularMembers.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{regularMembers.length - 3}
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="border-slate-300">
<Link2 className="w-4 h-4 mr-2" />
Share
</Button>
<Button
onClick={() => {
setSelectedStatus("pending");
setCreateDialog(true);
}}
className="bg-[#0A39DF] hover:bg-blue-700"
>
<Plus className="w-4 h-4 mr-2" />
Create List
</Button>
</div>
</div>
{/* Overall Progress */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#0A39DF] to-blue-600 transition-all"
style={{ width: `${overallProgress}%` }}
/>
</div>
<span className="text-sm font-bold text-slate-900 ml-4">{overallProgress}%</span>
</div>
</div>
</div>
{/* Kanban Board */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
{['pending', 'in_progress', 'on_hold', 'completed'].map((status) => (
<TaskColumn
key={status}
status={status}
tasks={tasksByStatus[status]}
onAddTask={(status) => {
setSelectedStatus(status);
setCreateDialog(true);
}}
>
{tasksByStatus[status].map((task, index) => (
<Draggable key={task.id} draggableId={task.id} index={index}>
{(provided) => (
<TaskCard
task={task}
provided={provided}
onClick={() => setSelectedTask(task)}
/>
)}
</Draggable>
))}
</TaskColumn>
))}
</div>
</DragDropContext>
{teamTasks.length === 0 && (
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-300">
<div className="w-16 h-16 mx-auto mb-4 bg-slate-100 rounded-xl flex items-center justify-center">
<Plus className="w-8 h-8 text-slate-400" />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-2">No tasks yet</h3>
<p className="text-slate-600 mb-5">Create your first task to get started</p>
<Button onClick={() => setCreateDialog(true)} className="bg-[#0A39DF]">
<Plus className="w-4 h-4 mr-2" />
Create Task
</Button>
</div>
)}
</div>
{/* Create Task Dialog */}
<Dialog open={createDialog} onOpenChange={setCreateDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Task Name *</Label>
<Input
value={newTask.task_name}
onChange={(e) => setNewTask({ ...newTask, task_name: e.target.value })}
placeholder="e.g., Website Design"
className="mt-1"
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
placeholder="Task details..."
rows={3}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Priority</Label>
<Select value={newTask.priority} onValueChange={(val) => setNewTask({ ...newTask, priority: val })}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Due Date</Label>
<Input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
className="mt-1"
/>
</div>
</div>
<div>
<Label>Initial Progress (%)</Label>
<Input
type="number"
min="0"
max="100"
value={newTask.progress}
onChange={(e) => setNewTask({ ...newTask, progress: parseInt(e.target.value) || 0 })}
className="mt-1"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label>Assign Team Members</Label>
{departments.length > 0 && (
<Select onValueChange={(dept) => {
const deptMembers = currentTeamMembers.filter(m => m.department === dept);
setSelectedMembers(deptMembers);
}}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Assign entire department" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => {
const count = currentTeamMembers.filter(m => m.department === dept).length;
return (
<SelectItem key={dept} value={dept}>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
{dept} ({count} members)
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
</div>
<div className="mt-2 space-y-2">
{currentTeamMembers.length === 0 ? (
<p className="text-sm text-slate-500">No team members available</p>
) : (
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{currentTeamMembers.map((member) => {
const isSelected = selectedMembers.some(m => m.id === member.id);
return (
<div
key={member.id}
onClick={() => {
if (isSelected) {
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
} else {
setSelectedMembers([...selectedMembers, member]);
}
}}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all ${
isSelected ? 'bg-blue-50 border-2 border-[#0A39DF]' : 'hover:bg-slate-50 border-2 border-transparent'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="w-4 h-4 rounded text-[#0A39DF] focus:ring-[#0A39DF]"
/>
<Avatar className="w-8 h-8">
<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>
<div className="flex-1">
<p className="text-sm font-medium text-slate-900">{member.member_name}</p>
<p className="text-xs text-slate-500">
{member.department ? `${member.department}` : ''}{member.role || 'Member'}
</p>
</div>
</div>
);
})}
</div>
)}
{selectedMembers.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-slate-50 rounded-lg">
{selectedMembers.map((member) => (
<Badge key={member.id} className="bg-[#0A39DF] text-white flex items-center gap-1">
{member.member_name}
<button
onClick={(e) => {
e.stopPropagation();
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
}}
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
>
×
</button>
</Badge>
))}
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialog(false)}>
Cancel
</Button>
<Button
onClick={handleCreateTask}
disabled={createTaskMutation.isPending}
className="bg-[#0A39DF]"
>
Create Task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Task Detail Modal with Comments */}
<TaskDetailModal
task={selectedTask}
open={!!selectedTask}
onClose={() => setSelectedTask(null)}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,16 @@ import VendorDocumentReview from "./VendorDocumentReview";
import VendorMarketplace from "./VendorMarketplace";
import RapidOrder from "./RapidOrder";
import SmartScheduler from "./SmartScheduler";
import StaffOnboarding from "./StaffOnboarding";
import NotificationSettings from "./NotificationSettings";
import TaskBoard from "./TaskBoard";
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = {
@@ -244,6 +254,16 @@ const PAGES = {
VendorMarketplace: VendorMarketplace,
RapidOrder: RapidOrder,
SmartScheduler: SmartScheduler,
StaffOnboarding: StaffOnboarding,
NotificationSettings: NotificationSettings,
TaskBoard: TaskBoard,
}
function _getCurrentPage(url) {
@@ -391,6 +411,16 @@ function PagesContent() {
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
<Route path="/RapidOrder" element={<RapidOrder />} />
<Route path="/SmartScheduler" element={<SmartScheduler />} />
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
<Route path="/NotificationSettings" element={<NotificationSettings />} />
<Route path="/TaskBoard" element={<TaskBoard />} />
</Routes>
</Layout>
);