Merge branch 'dev' into 24-web-connect-events-page-to-dev-backend-poc
This commit is contained in:
@@ -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:
|
||||
|
||||
396
frontend-web/src/components/dashboard/DashboardCustomizer.jsx
Normal file
396
frontend-web/src/components/dashboard/DashboardCustomizer.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
235
frontend-web/src/components/events/AssignedStaffManager.jsx
Normal file
235
frontend-web/src/components/events/AssignedStaffManager.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal file
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
340
frontend-web/src/components/events/VendorRoutingPanel.jsx
Normal file
340
frontend-web/src/components/events/VendorRoutingPanel.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
247
frontend-web/src/components/notifications/NotificationEngine.jsx
Normal file
247
frontend-web/src/components/notifications/NotificationEngine.jsx
Normal 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;
|
||||
141
frontend-web/src/components/onboarding/CompletionStep.jsx
Normal file
141
frontend-web/src/components/onboarding/CompletionStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
frontend-web/src/components/onboarding/DocumentUploadStep.jsx
Normal file
159
frontend-web/src/components/onboarding/DocumentUploadStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
frontend-web/src/components/onboarding/ProfileSetupStep.jsx
Normal file
193
frontend-web/src/components/onboarding/ProfileSetupStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend-web/src/components/onboarding/TrainingStep.jsx
Normal file
173
frontend-web/src/components/onboarding/TrainingStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend-web/src/components/orders/OrderStatusBadge.jsx
Normal file
149
frontend-web/src/components/orders/OrderStatusBadge.jsx
Normal 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);
|
||||
});
|
||||
}
|
||||
332
frontend-web/src/components/orders/RapidOrderChat.jsx
Normal file
332
frontend-web/src/components/orders/RapidOrderChat.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
374
frontend-web/src/components/orders/SmartAssignModal.jsx
Normal file
374
frontend-web/src/components/orders/SmartAssignModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend-web/src/components/orders/WorkerConfirmationCard.jsx
Normal file
150
frontend-web/src/components/orders/WorkerConfirmationCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal file
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal file
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal file
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal file
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
frontend-web/src/components/scheduling/AutomationEngine.jsx
Normal file
211
frontend-web/src/components/scheduling/AutomationEngine.jsx
Normal 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;
|
||||
314
frontend-web/src/components/scheduling/ConflictDetection.jsx
Normal file
314
frontend-web/src/components/scheduling/ConflictDetection.jsx
Normal 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,
|
||||
};
|
||||
255
frontend-web/src/components/scheduling/DragDropScheduler.jsx
Normal file
255
frontend-web/src/components/scheduling/DragDropScheduler.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx
Normal file
274
frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx
Normal 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,
|
||||
};
|
||||
137
frontend-web/src/components/scheduling/WorkerInfoCard.jsx
Normal file
137
frontend-web/src/components/scheduling/WorkerInfoCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
frontend-web/src/components/tasks/TaskCard.jsx
Normal file
106
frontend-web/src/components/tasks/TaskCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend-web/src/components/tasks/TaskColumn.jsx
Normal file
56
frontend-web/src/components/tasks/TaskColumn.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
526
frontend-web/src/components/tasks/TaskDetailModal.jsx
Normal file
526
frontend-web/src/components/tasks/TaskDetailModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
358
frontend-web/src/components/vendor/PreferredVendorPanel.jsx
vendored
Normal file
358
frontend-web/src/components/vendor/PreferredVendorPanel.jsx
vendored
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
271
frontend-web/src/pages/NotificationSettings.jsx
Normal file
271
frontend-web/src/pages/NotificationSettings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
505
frontend-web/src/pages/RapidOrder.jsx
Normal file
505
frontend-web/src/pages/RapidOrder.jsx
Normal 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
36
frontend-web/src/pages/SmartScheduler.jsx
Normal file
36
frontend-web/src/pages/SmartScheduler.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
frontend-web/src/pages/StaffOnboarding.jsx
Normal file
197
frontend-web/src/pages/StaffOnboarding.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
466
frontend-web/src/pages/TaskBoard.jsx
Normal file
466
frontend-web/src/pages/TaskBoard.jsx
Normal 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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user