+
+
+ Staff Assignment
+ {isRapid && requestedCount > 0 && (
+
+ RAPID: {requestedCount} {requestedCount === 1 ? 'position' : 'positions'}
+
+ )}
+ {!isRapid && requestedCount > 0 && (
+
+ {assignedStaff.length} / {requestedCount}
+ {isFull && " ✓ Full"}
+
+ )}
+
+
+ {canAssignStaff && assignedStaff.length > 0 && (
+
+ )}
+ {canAssignStaff && (
+
+
+
+
+
+
+
+
Assign Staff Members
+
+ {assignedStaff.length} / {requestedCount || "∞"}
+
+
+
+ {remainingSlots > 0 && !isFull && (
+
+
+ {remainingSlots} more staff member{remainingSlots !== 1 ? 's' : ''} needed to fill all positions
+
+
+ )}
+
+ {isFull && requestedCount > 0 && (
+
+
+
+ Event fully staffed - all {requestedCount} positions filled
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedStaff.length > 0 && !isFull && (
+
+ )}
+
+
+
+
+ No available staff found.
+
+ {filteredAvailableStaff.length === 0 ? (
+
+ {availableStaff.length === 0
+ ? "All staff members are already assigned"
+ : "No staff match the selected filters"}
+
+ ) : (
+ filteredAvailableStaff.map((staff) => {
+ const isSelected = selectedStaff.includes(staff.id);
+ const canSelect = !isFull || isSelected;
+
+ return (
+ {
+ if (!canSelect) return;
+
+ if (isSelected) {
+ setSelectedStaff(prev => prev.filter(id => id !== staff.id));
+ } else {
+ if (requestedCount > 0 && selectedStaff.length >= remainingSlots) {
+ toast({
+ title: "Selection Limit",
+ description: `You can only select ${remainingSlots} more staff member${remainingSlots !== 1 ? 's' : ''}`,
+ variant: "destructive"
+ });
+ return;
+ }
+ setSelectedStaff(prev => [...prev, staff.id]);
+ }
+ }}
+ className={`cursor-pointer ${!canSelect ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={!canSelect}
+ >
+
+
{}}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ {staff.initial || staff.employee_name?.charAt(0)}
+
+
+
+ {staff.employee_name}
+
+
+ {staff.position && {staff.position}}
+ {staff.department && (
+ <>
+ •
+ {staff.department}
+ >
+ )}
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {assignedStaff.length}
+ / {requestedCount || "∞"} assigned
+
+ {assignedStaff.length > 0 && (
+
+ {confirmedCount}
+ confirmed
+
+ )}
+
+
+ {allConfirmed && assignedStaff.length > 0 && (
+
+
+ All Confirmed
+
+ )}
+
+
+ {assignedStaff.length === 0 ? (
+
+
+
No staff assigned yet
+
Click "Add Staff" to assign team members
+
+ ) : (
+
+ {assignedStaff.map((staff, index) => (
+
+
+ {staff.staff_name?.charAt(0) || "?"}
+
+
+
+
{staff.staff_name}
+
+ {staff.position && (
+
{staff.position}
+ )}
+ {staff.notified && (
+
+
+ Notified
+
+ )}
+
+
+
+
+ {canAssignStaff && !staff.notified && (
+
+ )}
+ {canAssignStaff && (
+
+ )}
+ {!canAssignStaff && staff.confirmed && (
+
+
+ Confirmed
+
+ )}
+
+ {canAssignStaff && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/events/StatusCard.jsx b/frontend-web-free/src/components/events/StatusCard.jsx
new file mode 100644
index 00000000..c14ff1ed
--- /dev/null
+++ b/frontend-web-free/src/components/events/StatusCard.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { Card } from "@/components/ui/card";
+
+export default function StatusCard({ status, count, percentage, color }) {
+ const colorClasses = {
+ blue: "from-[#0A39DF] to-[#0A39DF]/80",
+ purple: "from-purple-600 to-purple-700",
+ green: "from-emerald-600 to-emerald-700",
+ gray: "from-slate-600 to-slate-700",
+ yellow: "from-amber-500 to-amber-600"
+ };
+
+ return (
+
+
+
+
{status}
+
+ {count}
+
+
+
+
{percentage}%
+
of total
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/events/VendorRoutingPanel.jsx b/frontend-web-free/src/components/events/VendorRoutingPanel.jsx
new file mode 100644
index 00000000..b45ac725
--- /dev/null
+++ b/frontend-web-free/src/components/events/VendorRoutingPanel.jsx
@@ -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 (
+ <>
+
+
+
+ {/* Header */}
+
+
+
+ {isRapid ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'}
+
+
+ {routingMode === 'multi'
+ ? `Sending to ${selectedVendors.length} vendors`
+ : 'Default vendor routing'}
+
+
+
+
+ {routingMode === 'multi' && (
+
+ MULTI-VENDOR
+
+ )}
+
+
+ {/* Selected Vendor(s) */}
+
+ {selectedVendors.length === 0 && !preferredVendor && (
+
+
+
+
+ No vendor selected. Please choose a vendor.
+
+
+
+ )}
+
+ {selectedVendors.map((vendor) => {
+ const isPreferred = vendor.id === preferredVendor?.id;
+
+ return (
+
+
+
+
+
+ {vendor.doing_business_as || vendor.legal_name}
+
+ {isPreferred && (
+
+
+ Preferred
+
+ )}
+
+
+ {vendor.region && (
+
+
+ {vendor.region}
+
+ )}
+
+
+ {vendor.workforce_count || 0} staff
+
+
+
+ {routingMode === 'multi' && (
+
+ )}
+
+
+ );
+ })}
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+ {/* Info Banner */}
+ {routingMode === 'multi' && (
+
+
+ Multi-Vendor Mode: Order sent to all selected vendors.
+ First to confirm gets the job.
+
+
+ )}
+
+ {isRapid && (
+
+
+ RAPID Priority: This order will be marked urgent with priority notification.
+
+
+ )}
+
+
+
+
+ {/* Vendor Selector Dialog */}
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/invoices/AutoInvoiceGenerator.jsx b/frontend-web-free/src/components/invoices/AutoInvoiceGenerator.jsx
new file mode 100644
index 00000000..a7929560
--- /dev/null
+++ b/frontend-web-free/src/components/invoices/AutoInvoiceGenerator.jsx
@@ -0,0 +1,175 @@
+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 { format, addDays } from "date-fns";
+
+/**
+ * Auto Invoice Generator Component
+ * Monitors completed events and automatically generates invoices
+ * when all staff have ended their shifts
+ */
+export default function AutoInvoiceGenerator() {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+
+ const { data: events = [] } = useQuery({
+ queryKey: ['events-for-invoice-generation'],
+ queryFn: () => base44.entities.Event.list(),
+ refetchInterval: 60000, // Check every minute
+ });
+
+ const { data: invoices = [] } = useQuery({
+ queryKey: ['invoices'],
+ queryFn: () => base44.entities.Invoice.list(),
+ });
+
+ const createInvoiceMutation = useMutation({
+ mutationFn: (invoiceData) => base44.entities.Invoice.create(invoiceData),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['invoices'] });
+ },
+ });
+
+ useEffect(() => {
+ if (!events || !invoices) return;
+
+ // Find completed events that don't have invoices yet
+ const completedEvents = events.filter(event =>
+ event.status === "Completed" &&
+ !invoices.some(inv => inv.event_id === event.id)
+ );
+
+ completedEvents.forEach(async (event) => {
+ try {
+ // Group staff by role and generate detailed entries
+ const roleGroups = {};
+
+ if (event.assigned_staff && event.shifts) {
+ event.shifts.forEach(shift => {
+ shift.roles?.forEach(role => {
+ const assignedForRole = event.assigned_staff.filter(
+ s => s.role === role.role
+ );
+
+ if (!roleGroups[role.role]) {
+ roleGroups[role.role] = {
+ role_name: role.role,
+ staff_entries: [],
+ role_subtotal: 0
+ };
+ }
+
+ assignedForRole.forEach(staff => {
+ const workedHours = role.hours || 8;
+ const baseRate = role.cost_per_hour || role.rate_per_hour || 0;
+
+ // Calculate regular, OT, and DT hours
+ const regularHours = Math.min(workedHours, 8);
+ const otHours = Math.max(0, Math.min(workedHours - 8, 4));
+ const dtHours = Math.max(0, workedHours - 12);
+
+ // Calculate rates (OT = 1.5x, DT = 2x)
+ const regularRate = baseRate;
+ const otRate = baseRate * 1.5;
+ const dtRate = baseRate * 2;
+
+ // Calculate values
+ const regularValue = regularHours * regularRate;
+ const otValue = otHours * otRate;
+ const dtValue = dtHours * dtRate;
+ const total = regularValue + otValue + dtValue;
+
+ const entry = {
+ staff_name: staff.staff_name,
+ staff_id: staff.staff_id,
+ date: event.date,
+ position: role.role,
+ check_in: role.start_time || "09:00 AM",
+ check_out: role.end_time || "05:00 PM",
+ worked_hours: workedHours,
+ regular_hours: regularHours,
+ ot_hours: otHours,
+ dt_hours: dtHours,
+ regular_rate: regularRate,
+ ot_rate: otRate,
+ dt_rate: dtRate,
+ regular_value: regularValue,
+ ot_value: otValue,
+ dt_value: dtValue,
+ rate: baseRate,
+ total: total
+ };
+
+ roleGroups[role.role].staff_entries.push(entry);
+ roleGroups[role.role].role_subtotal += total;
+ });
+ });
+ });
+ }
+
+ const roles = Object.values(roleGroups);
+ const subtotal = roles.reduce((sum, role) => sum + role.role_subtotal, 0);
+ const otherCharges = 0;
+ const total = subtotal + otherCharges;
+
+ // Generate invoice number
+ const invoiceNumber = `INV-${Math.floor(Math.random() * 10000)}`;
+
+ // Get vendor and client info
+ const vendorInfo = {
+ name: event.vendor_name || "Legendary",
+ address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
+ email: "orders@legendaryeventstaff.com",
+ phone: "(408) 936-0180"
+ };
+
+ const clientInfo = {
+ name: event.business_name || "Client Company",
+ address: event.event_location || "Address",
+ email: event.client_email || "",
+ manager: event.client_name || event.manager_name || "Manager",
+ phone: event.client_phone || "",
+ vendor_id: "Vendor #"
+ };
+
+ // Create invoice
+ const invoiceData = {
+ invoice_number: invoiceNumber,
+ event_id: event.id,
+ event_name: event.event_name,
+ event_date: event.date,
+ po_reference: event.po_reference,
+ from_company: vendorInfo,
+ to_company: clientInfo,
+ business_name: event.business_name,
+ manager_name: event.client_name || event.business_name,
+ vendor_name: event.vendor_name,
+ vendor_id: event.vendor_id,
+ hub: event.hub,
+ cost_center: event.po_reference,
+ roles: roles,
+ subtotal: subtotal,
+ other_charges: otherCharges,
+ amount: total,
+ status: "Pending Review",
+ issue_date: format(new Date(), 'yyyy-MM-dd'),
+ due_date: format(addDays(new Date(), 30), 'yyyy-MM-dd'),
+ is_auto_generated: true,
+ notes: `Automatically generated invoice for ${event.event_name}`,
+ };
+
+ await createInvoiceMutation.mutateAsync(invoiceData);
+
+ toast({
+ title: "✅ Invoice Generated",
+ description: `Invoice ${invoiceNumber} created for ${event.event_name}`,
+ });
+ } catch (error) {
+ console.error('Failed to generate invoice:', error);
+ }
+ });
+ }, [events, invoices]);
+
+ return null; // This is a background component
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/invoices/CreateInvoiceModal.jsx b/frontend-web-free/src/components/invoices/CreateInvoiceModal.jsx
new file mode 100644
index 00000000..ef3f8d0e
--- /dev/null
+++ b/frontend-web-free/src/components/invoices/CreateInvoiceModal.jsx
@@ -0,0 +1,200 @@
+import React, { useState } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+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 { Textarea } from "@/components/ui/textarea";
+import { useToast } from "@/components/ui/use-toast";
+import { base44 } from "@/api/base44Client";
+import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
+import { format, addDays } from "date-fns";
+import { Plus, Trash2, FileEdit } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { createPageUrl } from "@/utils";
+
+export default function CreateInvoiceModal({ open, onClose }) {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+
+ const { data: events = [] } = useQuery({
+ queryKey: ['events-for-invoice'],
+ queryFn: () => base44.entities.Event.list(),
+ enabled: open,
+ });
+
+ const navigate = useNavigate();
+
+ const handleAdvancedEditor = () => {
+ onClose();
+ navigate(createPageUrl('InvoiceEditor'));
+ };
+
+ const createMutation = useMutation({
+ mutationFn: async (data) => {
+ const selectedEvent = events.find(e => e.id === data.event_id);
+ if (!selectedEvent) throw new Error("Event not found");
+
+ // Generate roles and staff entries from event
+ const roleGroups = {};
+
+ if (selectedEvent.assigned_staff && selectedEvent.shifts) {
+ selectedEvent.shifts.forEach(shift => {
+ shift.roles?.forEach(role => {
+ const assignedForRole = selectedEvent.assigned_staff.filter(
+ s => s.role === role.role
+ );
+
+ if (!roleGroups[role.role]) {
+ roleGroups[role.role] = {
+ role_name: role.role,
+ staff_entries: [],
+ role_subtotal: 0
+ };
+ }
+
+ assignedForRole.forEach(staff => {
+ const workedHours = role.hours || 8;
+ const baseRate = role.cost_per_hour || role.rate_per_hour || 0;
+
+ const regularHours = Math.min(workedHours, 8);
+ const otHours = Math.max(0, Math.min(workedHours - 8, 4));
+ const dtHours = Math.max(0, workedHours - 12);
+
+ const regularRate = baseRate;
+ const otRate = baseRate * 1.5;
+ const dtRate = baseRate * 2;
+
+ const regularValue = regularHours * regularRate;
+ const otValue = otHours * otRate;
+ const dtValue = dtHours * dtRate;
+ const total = regularValue + otValue + dtValue;
+
+ const entry = {
+ staff_name: staff.staff_name,
+ staff_id: staff.staff_id,
+ date: selectedEvent.date,
+ position: role.role,
+ check_in: role.start_time || "09:00 AM",
+ check_out: role.end_time || "05:00 PM",
+ worked_hours: workedHours,
+ regular_hours: regularHours,
+ ot_hours: otHours,
+ dt_hours: dtHours,
+ regular_rate: regularRate,
+ ot_rate: otRate,
+ dt_rate: dtRate,
+ regular_value: regularValue,
+ ot_value: otValue,
+ dt_value: dtValue,
+ rate: baseRate,
+ total: total
+ };
+
+ roleGroups[role.role].staff_entries.push(entry);
+ roleGroups[role.role].role_subtotal += total;
+ });
+ });
+ });
+ }
+
+ const roles = Object.values(roleGroups);
+ const subtotal = roles.reduce((sum, role) => sum + role.role_subtotal, 0);
+ const otherCharges = parseFloat(data.other_charges) || 0;
+ const total = subtotal + otherCharges;
+
+ const invoiceNumber = `INV-${Math.floor(Math.random() * 10000)}`;
+
+ const vendorInfo = {
+ name: selectedEvent.vendor_name || "Legendary",
+ address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
+ email: "orders@legendaryeventstaff.com",
+ phone: "(408) 936-0180"
+ };
+
+ const clientInfo = {
+ name: selectedEvent.business_name || "Client Company",
+ address: selectedEvent.event_location || "Address",
+ email: selectedEvent.client_email || "",
+ manager: selectedEvent.client_name || selectedEvent.manager_name || "Manager",
+ phone: selectedEvent.client_phone || "",
+ vendor_id: "Vendor #"
+ };
+
+ return base44.entities.Invoice.create({
+ invoice_number: invoiceNumber,
+ event_id: selectedEvent.id,
+ event_name: selectedEvent.event_name,
+ event_date: selectedEvent.date,
+ po_reference: data.po_reference || selectedEvent.po_reference,
+ from_company: vendorInfo,
+ to_company: clientInfo,
+ business_name: selectedEvent.business_name,
+ manager_name: selectedEvent.client_name || selectedEvent.business_name,
+ vendor_name: selectedEvent.vendor_name,
+ vendor_id: selectedEvent.vendor_id,
+ hub: selectedEvent.hub,
+ cost_center: data.po_reference || selectedEvent.po_reference,
+ roles: roles,
+ subtotal: subtotal,
+ other_charges: otherCharges,
+ amount: total,
+ status: "Draft",
+ issue_date: format(new Date(), 'yyyy-MM-dd'),
+ due_date: format(addDays(new Date(), 30), 'yyyy-MM-dd'),
+ is_auto_generated: false,
+ notes: data.notes,
+ });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['invoices'] });
+ toast({
+ title: "✅ Invoice Created",
+ description: "Invoice has been created successfully",
+ });
+ onClose();
+ setFormData({ event_id: "", po_reference: "", other_charges: 0, notes: "" });
+ },
+ });
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!formData.event_id) {
+ toast({
+ title: "Error",
+ description: "Please select an event",
+ variant: "destructive",
+ });
+ return;
+ }
+ createMutation.mutate(formData);
+ };
+
+ const completedEvents = events.filter(e => e.status === "Completed");
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/invoices/InvoiceDetailModal.jsx b/frontend-web-free/src/components/invoices/InvoiceDetailModal.jsx
new file mode 100644
index 00000000..a7505e06
--- /dev/null
+++ b/frontend-web-free/src/components/invoices/InvoiceDetailModal.jsx
@@ -0,0 +1,444 @@
+import React, { useState } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { useToast } from "@/components/ui/use-toast";
+import { base44 } from "@/api/base44Client";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ FileText, Download, Mail, Printer, CheckCircle,
+ XCircle, AlertTriangle, DollarSign, Calendar, Building2,
+ User, CreditCard, Edit3, Flag, CheckCheck
+} from "lucide-react";
+import { format, parseISO } from "date-fns";
+
+const statusColors = {
+ 'Draft': 'bg-slate-500',
+ 'Pending Review': 'bg-amber-500',
+ 'Approved': 'bg-green-500',
+ 'Disputed': 'bg-red-500',
+ 'Under Review': 'bg-orange-500',
+ 'Resolved': 'bg-blue-500',
+ 'Overdue': 'bg-red-600',
+ 'Paid': 'bg-emerald-500',
+ 'Reconciled': 'bg-purple-500',
+ 'Cancelled': 'bg-slate-400',
+};
+
+export default function InvoiceDetailModal({ open, onClose, invoice, userRole }) {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const [disputeMode, setDisputeMode] = useState(false);
+ const [disputeReason, setDisputeReason] = useState("");
+ const [disputeDetails, setDisputeDetails] = useState("");
+ const [paymentMethod, setPaymentMethod] = useState("");
+ const [paymentRef, setPaymentRef] = useState("");
+ const [selectedItems, setSelectedItems] = useState([]);
+
+ const updateInvoiceMutation = useMutation({
+ mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['invoices'] });
+ toast({
+ title: "✅ Invoice Updated",
+ description: "Invoice has been updated successfully",
+ });
+ onClose();
+ },
+ });
+
+ const handleApprove = async () => {
+ const user = await base44.auth.me();
+ updateInvoiceMutation.mutate({
+ id: invoice.id,
+ data: {
+ status: "Approved",
+ approved_by: user.email,
+ approved_date: new Date().toISOString(),
+ }
+ });
+ };
+
+ const handleDispute = async () => {
+ const user = await base44.auth.me();
+ updateInvoiceMutation.mutate({
+ id: invoice.id,
+ data: {
+ status: "Disputed",
+ dispute_reason: disputeReason,
+ dispute_details: disputeDetails,
+ disputed_items: selectedItems,
+ disputed_by: user.email,
+ disputed_date: new Date().toISOString(),
+ }
+ });
+ };
+
+ const handlePay = async () => {
+ updateInvoiceMutation.mutate({
+ id: invoice.id,
+ data: {
+ status: "Paid",
+ paid_date: new Date().toISOString().split('T')[0],
+ payment_method: paymentMethod,
+ payment_reference: paymentRef,
+ }
+ });
+ };
+
+ const handleDownloadPDF = () => {
+ window.print();
+ };
+
+ const handleEmailInvoice = async () => {
+ const user = await base44.auth.me();
+ await base44.integrations.Core.SendEmail({
+ to: invoice.business_name || user.email,
+ subject: `Invoice ${invoice.invoice_number}`,
+ body: `Please find attached invoice ${invoice.invoice_number} for ${invoice.event_name}. Amount: $${invoice.amount}. Due: ${invoice.due_date}`,
+ });
+ toast({
+ title: "✅ Email Sent",
+ description: "Invoice has been emailed successfully",
+ });
+ };
+
+ const toggleItemSelection = (index) => {
+ setSelectedItems(prev =>
+ prev.includes(index) ? prev.filter(i => i !== index) : [...prev, index]
+ );
+ };
+
+ if (!invoice) return null;
+
+ const isClient = userRole === "client";
+ const isVendor = userRole === "vendor";
+ const canApprove = isClient && invoice.status === "Pending Review";
+ const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status);
+ const canPay = isClient && ["Approved", "Overdue"].includes(invoice.status);
+ const canEdit = isVendor && ["Draft", "Disputed"].includes(invoice.status);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/invoices/InvoiceDetailView.jsx b/frontend-web-free/src/components/invoices/InvoiceDetailView.jsx
new file mode 100644
index 00000000..cc86a6a4
--- /dev/null
+++ b/frontend-web-free/src/components/invoices/InvoiceDetailView.jsx
@@ -0,0 +1,447 @@
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useToast } from "@/components/ui/use-toast";
+import { base44 } from "@/api/base44Client";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ Printer, Flag, CheckCircle, MoreVertical, FileText, Edit, CreditCard
+} from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { createPageUrl } from "@/utils";
+import { format, parseISO } from "date-fns";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+
+const statusColors = {
+ 'Draft': 'bg-slate-500',
+ 'Pending Review': 'bg-amber-500',
+ 'Approved': 'bg-green-500',
+ 'Disputed': 'bg-red-500',
+ 'Under Review': 'bg-orange-500',
+ 'Resolved': 'bg-blue-500',
+ 'Overdue': 'bg-red-600',
+ 'Paid': 'bg-emerald-500',
+ 'Reconciled': 'bg-purple-500',
+ 'Cancelled': 'bg-slate-400',
+};
+
+export default function InvoiceDetailView({ invoice, userRole, onClose }) {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const [showDisputeDialog, setShowDisputeDialog] = useState(false);
+ const [disputeReason, setDisputeReason] = useState("");
+ const [disputeDetails, setDisputeDetails] = useState("");
+ const [selectedItems, setSelectedItems] = useState([]);
+
+ const updateInvoiceMutation = useMutation({
+ mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['invoices'] });
+ toast({
+ title: "✅ Invoice Updated",
+ description: "Invoice has been updated successfully",
+ });
+ if (onClose) onClose();
+ },
+ });
+
+ const handleApprove = async () => {
+ const user = await base44.auth.me();
+ updateInvoiceMutation.mutate({
+ id: invoice.id,
+ data: {
+ status: "Approved",
+ approved_by: user.email,
+ approved_date: new Date().toISOString(),
+ }
+ });
+ };
+
+ const handleMarkPaid = async () => {
+ const user = await base44.auth.me();
+ updateInvoiceMutation.mutate({
+ id: invoice.id,
+ data: {
+ status: "Paid",
+ paid_date: new Date().toISOString().split('T')[0],
+ payment_method: "Credit Card",
+ payment_reference: `PAY-${Date.now()}`,
+ }
+ });
+ };
+
+ const handleEditInvoice = () => {
+ navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`));
+ };
+
+ const handleDispute = async () => {
+ const user = await base44.auth.me();
+ updateInvoiceMutation.mutate({
+ id: invoice.id,
+ data: {
+ status: "Disputed",
+ dispute_reason: disputeReason,
+ dispute_details: disputeDetails,
+ disputed_items: selectedItems,
+ disputed_by: user.email,
+ disputed_date: new Date().toISOString(),
+ }
+ });
+ setShowDisputeDialog(false);
+ };
+
+ const handlePrint = () => {
+ window.print();
+ };
+
+ const toggleItemSelection = (roleIndex, staffIndex) => {
+ const itemId = { role_index: roleIndex, staff_index: staffIndex };
+ setSelectedItems(prev => {
+ const exists = prev.some(item => item.role_index === roleIndex && item.staff_index === staffIndex);
+ if (exists) {
+ return prev.filter(item => !(item.role_index === roleIndex && item.staff_index === staffIndex));
+ }
+ return [...prev, itemId];
+ });
+ };
+
+ if (!invoice) return null;
+
+ const isClient = userRole === "client";
+ const isVendor = userRole === "vendor";
+ const isAdmin = userRole === "admin";
+ const canEdit = (isVendor || isAdmin) && ["Draft", "Pending Review", "Disputed"].includes(invoice.status);
+ const canApprove = isClient && invoice.status === "Pending Review";
+ const canPay = isClient && invoice.status === "Approved";
+ const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
{invoice.invoice_number}
+
+ {invoice.status}
+
+
+
+
+
+ {canEdit && (
+
+ )}
+ {canDispute && (
+
+ )}
+ {canApprove && (
+
+ )}
+ {canPay && (
+
+ )}
+
+
+
+ {/* Event Info */}
+
+
+ Event Name: {invoice.event_name}
+
+
+ PO#: {invoice.po_reference || "N/A"}
+
+
+ Date: {invoice.event_date ? format(parseISO(invoice.event_date), 'M.d.yyyy') : '—'}
+
+
+ Due date: {format(parseISO(invoice.due_date), 'M.d.yyyy')}
+
+
+
+
+ {/* KROW Logo */}
+
+

+
+
+ {/* From and To */}
+
+
+
+
+
{invoice.from_company?.name || invoice.vendor_name || "Vendor Name"}
+ {(invoice.from_company?.address || invoice.vendor_address) && (
+
+
Address
+
{invoice.from_company?.address || invoice.vendor_address}
+
+ )}
+ {(invoice.from_company?.email || invoice.vendor_email) && (
+
+
Email
+
{invoice.from_company?.email || invoice.vendor_email}
+
+ )}
+ {(invoice.from_company?.phone || invoice.vendor_phone) && (
+
+
Phone
+
{invoice.from_company?.phone || invoice.vendor_phone}
+
+ )}
+ {(invoice.from_company?.contact || invoice.vendor_contact) && (
+
+
Point of Contact
+
{invoice.from_company?.contact || invoice.vendor_contact}
+
+ )}
+
+
+
+
+
+
+
{invoice.to_company?.name || invoice.business_name}
+
{invoice.to_company?.address}
+
{invoice.to_company?.email}
+
+
+
Main Kitchen
+
{invoice.to_company?.manager || invoice.manager_name}
+
+
+
Manager Name
+
{invoice.to_company?.phone}
+
+
+
{invoice.to_company?.vendor_id || "Vendor #"}
+
+
+
+
+ {/* Staff Charges Table */}
+
+
+
+
+
+ | # |
+ Date |
+ Position |
+ Worked Hours |
+ Reg Hours |
+ OT Hours |
+ DT Hours |
+ Reg Value |
+ OT Value |
+ DT Value |
+ Total |
+ Actions |
+
+
+
+ {invoice.roles?.map((roleGroup, roleIdx) => (
+
+ {roleGroup.staff_entries?.map((entry, entryIdx) => (
+
+ | {roleIdx + 1} |
+ {entry.date ? format(parseISO(entry.date), 'M/d/yyyy') : '—'} |
+ {entry.position} |
+ {entry.worked_hours?.toFixed(2) || '0.00'} |
+ {entry.regular_hours?.toFixed(2) || '0.00'} |
+ {entry.ot_hours?.toFixed(2) || '0.00'} |
+ {entry.dt_hours?.toFixed(2) || '0.00'} |
+ ${entry.regular_value?.toFixed(2) || '0.00'} |
+ ${entry.ot_value?.toFixed(2) || '0.00'} |
+ ${entry.dt_value?.toFixed(2) || '0.00'} |
+ ${entry.total?.toFixed(2) || '0.00'} |
+
+
+
+
+
+
+ View Details
+ Flag Entry
+
+
+ |
+
+ ))}
+
+ | Total |
+ ${roleGroup.role_subtotal?.toFixed(2)} |
+ |
+
+
+ ))}
+
+
+
+
+
+ {/* Other Charges */}
+
+
+
Other charges
+
+
+
+
+ | # |
+ Charge |
+ QTY |
+ Rate |
+ Amount |
+
+
+
+ {(!invoice.other_charges || invoice.other_charges === 0) ? (
+
+ |
+ No additional charges
+ |
+
+ ) : (
+
+ | 1 |
+ Additional Charges |
+ 1 |
+ ${invoice.other_charges?.toFixed(2)} |
+ ${invoice.other_charges?.toFixed(2)} |
+
+ )}
+
+
+
+
+ {/* Totals */}
+
+
+
+ Sub-total:
+ ${invoice.subtotal?.toFixed(2)}
+
+
+ Other charges:
+ ${(invoice.other_charges || 0)?.toFixed(2)}
+
+
+ Grand total:
+ ${invoice.amount?.toFixed(2)}
+
+
+
+
+ {/* Footer */}
+
+

+
Page 1
+
+
+
+ {/* Dispute Dialog */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/invoices/InvoiceExportPanel.jsx b/frontend-web-free/src/components/invoices/InvoiceExportPanel.jsx
new file mode 100644
index 00000000..d2fdf456
--- /dev/null
+++ b/frontend-web-free/src/components/invoices/InvoiceExportPanel.jsx
@@ -0,0 +1,316 @@
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Download, FileText, FileSpreadsheet, Code, Send, Check, Loader2, Building2, Link2 } from "lucide-react";
+
+const ERP_SYSTEMS = {
+ "SAP Ariba": { format: "cXML", color: "bg-blue-100 text-blue-700" },
+ "Fieldglass": { format: "CSV", color: "bg-purple-100 text-purple-700" },
+ "CrunchTime": { format: "JSON", color: "bg-orange-100 text-orange-700" },
+ "Coupa": { format: "cXML", color: "bg-teal-100 text-teal-700" },
+ "Oracle NetSuite": { format: "CSV", color: "bg-red-100 text-red-700" },
+ "Workday": { format: "JSON", color: "bg-green-100 text-green-700" },
+};
+
+export default function InvoiceExportPanel({ invoice, business, onExport }) {
+ const [exportFormat, setExportFormat] = useState(business?.edi_format || "CSV");
+ const [isExporting, setIsExporting] = useState(false);
+ const [exportSuccess, setExportSuccess] = useState(false);
+
+ const erpSystem = business?.erp_system || "None";
+ const erpInfo = ERP_SYSTEMS[erpSystem];
+
+ const generateEDI810 = () => {
+ // EDI 810 Invoice format
+ const segments = [
+ `ISA*00* *00* *ZZ*KROW *ZZ*${business?.erp_vendor_id || 'CLIENT'} *${new Date().toISOString().slice(2,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,5).replace(':','')}*U*00401*000000001*0*P*>~`,
+ `GS*IN*KROW*${business?.business_name?.substring(0,15) || 'CLIENT'}*${new Date().toISOString().slice(0,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,4).replace(':','')}*1*X*004010~`,
+ `ST*810*0001~`,
+ `BIG*${invoice.issue_date?.replace(/-/g,'')}*${invoice.invoice_number}*${invoice.event_date?.replace(/-/g,'')}*${invoice.po_reference || ''}~`,
+ `N1*BT*${business?.business_name || invoice.business_name}~`,
+ `N1*ST*${invoice.hub || business?.hub_building || ''}~`,
+ `ITD*01*3*****${invoice.due_date?.replace(/-/g,'')}~`,
+ `TDS*${Math.round((invoice.amount || 0) * 100)}~`,
+ `SE*8*0001~`,
+ `GE*1*1~`,
+ `IEA*1*000000001~`
+ ];
+ return segments.join('\n');
+ };
+
+ const generateCXML = () => {
+ return `
+
+
+
+ KROW
+ ${business?.erp_vendor_id || 'CLIENT'}
+ KROW
+
+
+
+
+
+ ${invoice.business_name}
+
+
+
+
+
+ EA
+ ${invoice.amount}
+ ${invoice.event_name}
+
+
+
+ ${invoice.subtotal || invoice.amount}
+ ${invoice.tax_amount || 0}
+ ${invoice.amount}
+
+ ${invoice.amount}
+ ${invoice.amount}
+
+
+
+`;
+ };
+
+ const generateCSV = () => {
+ const headers = [
+ "Invoice Number", "Invoice Date", "Due Date", "PO Number", "Vendor ID",
+ "Client Name", "Hub", "Event Name", "Cost Center", "Subtotal", "Tax", "Total Amount", "Status"
+ ];
+ const row = [
+ invoice.invoice_number,
+ invoice.issue_date,
+ invoice.due_date,
+ invoice.po_reference || "",
+ business?.erp_vendor_id || "",
+ invoice.business_name,
+ invoice.hub || "",
+ invoice.event_name,
+ business?.erp_cost_center || "",
+ invoice.subtotal || invoice.amount,
+ invoice.tax_amount || 0,
+ invoice.amount,
+ invoice.status
+ ];
+
+ // Add line items if available
+ let lineItems = "\n\nLine Item Details\nRole,Staff Name,Date,Hours,Rate,Amount\n";
+ if (invoice.roles) {
+ invoice.roles.forEach(role => {
+ role.staff_entries?.forEach(entry => {
+ lineItems += `${role.role_name},${entry.staff_name},${entry.date},${entry.worked_hours},${entry.rate},${entry.total}\n`;
+ });
+ });
+ }
+
+ return headers.join(",") + "\n" + row.join(",") + lineItems;
+ };
+
+ const generateJSON = () => {
+ return JSON.stringify({
+ invoice: {
+ invoice_number: invoice.invoice_number,
+ issue_date: invoice.issue_date,
+ due_date: invoice.due_date,
+ po_reference: invoice.po_reference,
+ vendor: {
+ id: business?.erp_vendor_id,
+ name: "KROW Workforce"
+ },
+ client: {
+ name: invoice.business_name,
+ hub: invoice.hub,
+ cost_center: business?.erp_cost_center
+ },
+ event: {
+ name: invoice.event_name,
+ date: invoice.event_date
+ },
+ amounts: {
+ subtotal: invoice.subtotal || invoice.amount,
+ tax: invoice.tax_amount || 0,
+ total: invoice.amount
+ },
+ line_items: invoice.roles?.flatMap(role =>
+ role.staff_entries?.map(entry => ({
+ role: role.role_name,
+ staff_name: entry.staff_name,
+ date: entry.date,
+ hours: entry.worked_hours,
+ rate: entry.rate,
+ amount: entry.total
+ })) || []
+ ) || [],
+ status: invoice.status
+ }
+ }, null, 2);
+ };
+
+ const handleExport = async (format) => {
+ setIsExporting(true);
+
+ let content, filename, mimeType;
+
+ switch (format) {
+ case "EDI 810":
+ content = generateEDI810();
+ filename = `${invoice.invoice_number}_EDI810.edi`;
+ mimeType = "text/plain";
+ break;
+ case "cXML":
+ content = generateCXML();
+ filename = `${invoice.invoice_number}.xml`;
+ mimeType = "application/xml";
+ break;
+ case "JSON":
+ content = generateJSON();
+ filename = `${invoice.invoice_number}.json`;
+ mimeType = "application/json";
+ break;
+ case "CSV":
+ default:
+ content = generateCSV();
+ filename = `${invoice.invoice_number}.csv`;
+ mimeType = "text/csv";
+ break;
+ }
+
+ // Create and download file
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ setIsExporting(false);
+ setExportSuccess(true);
+ setTimeout(() => setExportSuccess(false), 3000);
+
+ if (onExport) onExport(format);
+ };
+
+ return (
+
+
+
+
+ ERP / EDI Export
+
+
+
+ {/* ERP System Info */}
+ {erpSystem !== "None" && (
+
+
+
+
+
Connected ERP
+
Vendor ID: {business?.erp_vendor_id || "Not configured"}
+
+
+
+ {erpSystem}
+
+
+ )}
+
+ {/* Export Format Selection */}
+
+
+
+
+
+ {/* Export Buttons */}
+
+
+
+ {business?.invoice_email && (
+
+ )}
+
+
+ {/* Quick Export Buttons */}
+
+
Quick Export
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/messaging/ConversationList.jsx b/frontend-web-free/src/components/messaging/ConversationList.jsx
new file mode 100644
index 00000000..4bb800c5
--- /dev/null
+++ b/frontend-web-free/src/components/messaging/ConversationList.jsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { format } from "date-fns";
+import { MessageSquare, Users } from "lucide-react";
+
+export default function ConversationList({ conversations, selectedId, onSelect }) {
+ const getTypeColor = (type) => {
+ const colors = {
+ "client-vendor": "bg-purple-100 text-purple-700",
+ "staff-client": "bg-blue-100 text-blue-700",
+ "staff-admin": "bg-slate-100 text-slate-700",
+ "vendor-admin": "bg-amber-100 text-amber-700",
+ "client-admin": "bg-green-100 text-green-700",
+ "group-staff": "bg-indigo-100 text-indigo-700",
+ "group-event-staff": "bg-pink-100 text-pink-700"
+ };
+ return colors[type] || "bg-slate-100 text-slate-700";
+ };
+
+ if (conversations.length === 0) {
+ return (
+
+
+
No conversations yet
+
+ );
+ }
+
+ return (
+
+ {conversations.map((conversation) => {
+ const isSelected = conversation.id === selectedId;
+ const otherParticipant = conversation.participants?.[1] || conversation.participants?.[0] || {};
+ const isGroup = conversation.is_group;
+
+ return (
+
onSelect(conversation)}
+ >
+
+
+ {isGroup ? (
+
+
+
+ ) : (
+
+
+ {otherParticipant.name?.charAt(0) || '?'}
+
+
+ )}
+
+
+
+
+
+ {isGroup ? conversation.group_name : conversation.subject || otherParticipant.name || "Conversation"}
+
+
+ {isGroup && (
+
+
+ {conversation.participants?.length || 0} members
+
+ )}
+
+ {conversation.conversation_type?.replace('-', ' → ').replace('group-', '')}
+
+ {conversation.related_type && (
+
+ {conversation.related_type}
+
+ )}
+
+
+
+ {conversation.unread_count > 0 && (
+
+ {conversation.unread_count}
+
+ )}
+
+
+
+ {conversation.last_message || "No messages yet"}
+
+
+ {conversation.last_message_at && (
+
+ {format(new Date(conversation.last_message_at), "MMM d, h:mm a")}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/messaging/MessageInput.jsx b/frontend-web-free/src/components/messaging/MessageInput.jsx
new file mode 100644
index 00000000..03046678
--- /dev/null
+++ b/frontend-web-free/src/components/messaging/MessageInput.jsx
@@ -0,0 +1,70 @@
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Send, Paperclip, Loader2 } from "lucide-react";
+import { base44 } from "@/api/base44Client";
+
+export default function MessageInput({ conversationId, onMessageSent, currentUser }) {
+ const [message, setMessage] = useState("");
+ const [sending, setSending] = useState(false);
+
+ const handleSend = async () => {
+ if (!message.trim() || sending) return;
+
+ setSending(true);
+ try {
+ await base44.entities.Message.create({
+ conversation_id: conversationId,
+ sender_id: currentUser.id,
+ sender_name: currentUser.full_name || currentUser.email,
+ sender_role: currentUser.role || "admin",
+ content: message.trim(),
+ read_by: [currentUser.id]
+ });
+
+ await base44.entities.Conversation.update(conversationId, {
+ last_message: message.trim().substring(0, 100),
+ last_message_at: new Date().toISOString()
+ });
+
+ setMessage("");
+ onMessageSent?.();
+ } catch (error) {
+ console.error("Failed to send message:", error);
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/messaging/MessageThread.jsx b/frontend-web-free/src/components/messaging/MessageThread.jsx
new file mode 100644
index 00000000..c4633fbf
--- /dev/null
+++ b/frontend-web-free/src/components/messaging/MessageThread.jsx
@@ -0,0 +1,99 @@
+
+import React, { useRef, useEffect } from "react";
+import { Card } from "@/components/ui/card";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { format } from "date-fns";
+import { FileText } from "lucide-react";
+
+// Safe date formatter
+const safeFormatDate = (dateString, formatStr) => {
+ if (!dateString) return "";
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return "";
+ return format(date, formatStr);
+ } catch {
+ return "";
+ }
+};
+
+export default function MessageThread({ messages, currentUserId }) {
+ const messagesEndRef = useRef(null);
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages]);
+
+ const getRoleColor = (role) => {
+ const colors = {
+ client: "bg-purple-100 text-purple-700",
+ vendor: "bg-amber-100 text-amber-700",
+ staff: "bg-blue-100 text-blue-700",
+ admin: "bg-slate-100 text-slate-700"
+ };
+ return colors[role] || "bg-slate-100 text-slate-700";
+ };
+
+ return (
+
+ {messages.map((message) => {
+ const isOwnMessage = message.sender_id === currentUserId || message.created_by === currentUserId;
+
+ return (
+
+
+
+
+ {message.sender_name?.charAt(0) || '?'}
+
+
+
+
+
+ {message.sender_name}
+
+ {message.sender_role}
+
+
+
+
+ {message.content}
+
+ {message.attachments && message.attachments.length > 0 && (
+
+ )}
+
+
+
+ {safeFormatDate(message.created_date, "MMM d, h:mm a")}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend-web-free/src/components/notifications/NotificationEngine.jsx b/frontend-web-free/src/components/notifications/NotificationEngine.jsx
new file mode 100644
index 00000000..f2d62ce7
--- /dev/null
+++ b/frontend-web-free/src/components/notifications/NotificationEngine.jsx
@@ -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({
+ userId: vendor.id,
+ activityType: '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;
\ No newline at end of file
diff --git a/frontend-web-free/src/components/notifications/NotificationPanel.jsx b/frontend-web-free/src/components/notifications/NotificationPanel.jsx
new file mode 100644
index 00000000..452ae2d7
--- /dev/null
+++ b/frontend-web-free/src/components/notifications/NotificationPanel.jsx
@@ -0,0 +1,622 @@
+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 { createPageUrl } from "@/utils";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ X,
+ Bell,
+ Calendar,
+ UserPlus,
+ FileText,
+ MessageSquare,
+ AlertCircle,
+ CheckCircle,
+ ArrowRight,
+ MoreVertical,
+ CheckSquare,
+ Package
+} from "lucide-react";
+import { motion, AnimatePresence } from "framer-motion";
+import { formatDistanceToNow, format, isToday, isYesterday, isThisWeek, startOfDay } from "date-fns";
+
+const iconMap = {
+ calendar: Calendar,
+ user: UserPlus,
+ invoice: FileText,
+ message: MessageSquare,
+ alert: AlertCircle,
+ check: CheckCircle,
+};
+
+const colorMap = {
+ blue: "bg-blue-100 text-blue-600",
+ red: "bg-red-100 text-red-600",
+ green: "bg-green-100 text-green-600",
+ yellow: "bg-yellow-100 text-yellow-600",
+ purple: "bg-purple-100 text-purple-600",
+};
+
+export default function NotificationPanel({ isOpen, onClose }) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [activeFilter, setActiveFilter] = useState('all');
+
+ const { data: user } = useQuery({
+ queryKey: ['current-user-notifications'],
+ queryFn: () => base44.auth.me(),
+ });
+
+ const { data: notifications = [] } = useQuery({
+ queryKey: ['activity-logs', user?.id],
+ queryFn: async () => {
+ if (!user?.id) return [];
+
+ // Create sample notifications if none exist
+ const existing = await base44.entities.ActivityLog.filter({ userId: user.id }, '-created_date', 50);
+
+ if (existing.length === 0 && user?.id) {
+ // Create initial sample notifications
+ await base44.entities.ActivityLog.bulkCreate([
+ {
+ title: "Event Rescheduled",
+ description: "Team Meeting was moved to July 15, 3:00 PM",
+ activity_type: "event_rescheduled",
+ related_entity_type: "event",
+ action_label: "View Event",
+ icon_type: "calendar",
+ icon_color: "blue",
+ is_read: false,
+ user_id: user.id
+ },
+ {
+ title: "Event Canceled",
+ description: "Product Demo scheduled for May 20 has been canceled",
+ activity_type: "event_canceled",
+ related_entity_type: "event",
+ action_label: "View Event",
+ icon_type: "calendar",
+ icon_color: "red",
+ is_read: false,
+ user_id: user.id
+ },
+ {
+ title: "Invoice Paid",
+ description: "You've been added to Client Kickoff on June 8, 10:00 AM",
+ activity_type: "invoice_paid",
+ related_entity_type: "invoice",
+ action_label: "View Invoice",
+ icon_type: "invoice",
+ icon_color: "green",
+ is_read: false,
+ user_id: user.id
+ },
+ {
+ title: "Staff Selected",
+ description: "10 staff members selected to fill remaining 10 slots",
+ activity_type: "staff_assigned",
+ related_entity_type: "event",
+ icon_type: "user",
+ icon_color: "purple",
+ is_read: true,
+ user_id: user.id
+ }
+ ]);
+
+ return await base44.entities.ActivityLog.filter({ userId: user.id }, '-created_date', 50);
+ }
+
+ return existing;
+ },
+ enabled: !!user?.id,
+ initialData: [],
+ });
+
+ const markAsReadMutation = useMutation({
+ mutationFn: ({ id }) => base44.entities.ActivityLog.update(id, { is_read: true }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: ({ id }) => base44.entities.ActivityLog.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
+ },
+ });
+
+ // Categorize by type
+ const categorizeByType = (notif) => {
+ const type = notif.activity_type || '';
+ const title = (notif.title || '').toLowerCase();
+
+ if (type.includes('message') || title.includes('message') || title.includes('comment') || title.includes('mentioned')) {
+ return 'mentions';
+ } else if (type.includes('staff_assigned') || type.includes('user') || title.includes('invited') || title.includes('followed')) {
+ return 'invites';
+ } else {
+ return 'all';
+ }
+ };
+
+ // Filter notifications based on active filter
+ const filteredNotifications = notifications.filter(notif => {
+ if (activeFilter === 'all') return true;
+ return categorizeByType(notif) === activeFilter;
+ });
+
+ // Group by day
+ const groupByDay = (notifList) => {
+ const groups = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ older: []
+ };
+
+ notifList.forEach(notif => {
+ const date = new Date(notif.created_date);
+ if (isToday(date)) {
+ groups.today.push(notif);
+ } else if (isYesterday(date)) {
+ groups.yesterday.push(notif);
+ } else if (isThisWeek(date)) {
+ groups.thisWeek.push(notif);
+ } else {
+ groups.older.push(notif);
+ }
+ });
+
+ return groups;
+ };
+
+ const groupedNotifications = groupByDay(filteredNotifications);
+
+ // Count by type
+ const allCount = notifications.length;
+ const mentionsCount = notifications.filter(n => categorizeByType(n) === 'mentions').length;
+ const invitesCount = notifications.filter(n => categorizeByType(n) === 'invites').length;
+
+ const handleAction = (notification) => {
+ // Mark as read when clicking
+ if (!notification.is_read) {
+ markAsReadMutation.mutate({ id: notification.id });
+ }
+
+ const entityType = notification.related_entity_type;
+ const entityId = notification.related_entity_id;
+ const activityType = notification.activity_type || '';
+
+ // Route based on entity type
+ if (entityType === 'event' || activityType.includes('event') || activityType.includes('order')) {
+ if (entityId) {
+ navigate(createPageUrl(`EventDetail?id=${entityId}`));
+ } else {
+ navigate(createPageUrl('Events'));
+ }
+ } else if (entityType === 'task' || activityType.includes('task')) {
+ navigate(createPageUrl('TaskBoard'));
+ } else if (entityType === 'invoice' || activityType.includes('invoice')) {
+ if (entityId) {
+ navigate(createPageUrl(`Invoices?id=${entityId}`));
+ } else {
+ navigate(createPageUrl('Invoices'));
+ }
+ } else if (entityType === 'staff' || activityType.includes('staff')) {
+ if (entityId) {
+ navigate(createPageUrl(`EditStaff?id=${entityId}`));
+ } else {
+ navigate(createPageUrl('StaffDirectory'));
+ }
+ } else if (entityType === 'message' || activityType.includes('message')) {
+ navigate(createPageUrl('Messages'));
+ } else if (notification.action_link) {
+ navigate(createPageUrl(notification.action_link));
+ }
+
+ onClose();
+ };
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+
+
+
+
Notifications
+
+
+
+
+
+
+ {/* Filter Tabs */}
+
+
+
+
+
+
+
+ {/* Notifications List */}
+
+ {filteredNotifications.length === 0 ? (
+
+
+
No notifications
+
You're all caught up!
+
+ ) : (
+ <>
+ {/* TODAY */}
+ {groupedNotifications.today.length > 0 && (
+
+
TODAY
+
+ {groupedNotifications.today.map((notification) => {
+ const Icon = iconMap[notification.icon_type] || AlertCircle;
+ const isUnread = !notification.is_read;
+ const notifDate = new Date(notification.created_date);
+
+ return (
+
+ {isUnread && (
+
+ )}
+
handleAction(notification)}
+ className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
+ notification.icon_color === 'blue' ? 'bg-blue-100' :
+ notification.icon_color === 'green' ? 'bg-green-100' :
+ notification.icon_color === 'red' ? 'bg-red-100' :
+ notification.icon_color === 'purple' ? 'bg-purple-100' :
+ 'bg-slate-100'
+ }`}>
+
+
+
handleAction(notification)}
+ >
+
+
+ {notification.title} {notification.description}
+
+
+ {formatDistanceToNow(notifDate, { addSuffix: true })}
+
+
+
+ {notification.action_link && (
+ {notification.action_label}
+ )}
+ {format(notifDate, 'h:mm a')}
+ {isUnread && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* YESTERDAY */}
+ {groupedNotifications.yesterday.length > 0 && (
+
+
YESTERDAY
+
+ {groupedNotifications.yesterday.map((notification) => {
+ const Icon = iconMap[notification.icon_type] || AlertCircle;
+ const isUnread = !notification.is_read;
+ const notifDate = new Date(notification.created_date);
+
+ return (
+
+ {isUnread && (
+
+ )}
+
handleAction(notification)}
+ className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
+ notification.icon_color === 'blue' ? 'bg-blue-100' :
+ notification.icon_color === 'green' ? 'bg-green-100' :
+ notification.icon_color === 'red' ? 'bg-red-100' :
+ notification.icon_color === 'purple' ? 'bg-purple-100' :
+ 'bg-slate-100'
+ }`}>
+
+
+
notification.action_link && handleAction(notification)}
+ >
+
+
+ {notification.title} {notification.description}
+
+
+ {formatDistanceToNow(notifDate, { addSuffix: true })}
+
+
+
+ {notification.action_link && (
+ {notification.action_label}
+ )}
+ {format(notifDate, 'MM/dd/yy • h:mm a')}
+ {isUnread && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* THIS WEEK */}
+ {groupedNotifications.thisWeek.length > 0 && (
+
+
THIS WEEK
+
+ {groupedNotifications.thisWeek.map((notification) => {
+ const Icon = iconMap[notification.icon_type] || AlertCircle;
+ const isUnread = !notification.is_read;
+ const notifDate = new Date(notification.created_date);
+
+ return (
+
+ {isUnread && (
+
+ )}
+
handleAction(notification)}
+ className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
+ notification.icon_color === 'blue' ? 'bg-blue-100' :
+ notification.icon_color === 'green' ? 'bg-green-100' :
+ notification.icon_color === 'red' ? 'bg-red-100' :
+ notification.icon_color === 'purple' ? 'bg-purple-100' :
+ 'bg-slate-100'
+ }`}>
+
+
+
notification.action_link && handleAction(notification)}
+ >
+
+
+ {notification.title} {notification.description}
+
+
+ {formatDistanceToNow(notifDate, { addSuffix: true })}
+
+
+
+ {notification.action_link && (
+ {notification.action_label}
+ )}
+ {format(notifDate, 'MM/dd/yy • h:mm a')}
+ {isUnread && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* OLDER */}
+ {groupedNotifications.older.length > 0 && (
+
+
OLDER
+
+ {groupedNotifications.older.map((notification) => {
+ const Icon = iconMap[notification.icon_type] || AlertCircle;
+ const isUnread = !notification.is_read;
+ const notifDate = new Date(notification.created_date);
+
+ return (
+
+ {isUnread && (
+
+ )}
+
handleAction(notification)}
+ className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
+ notification.icon_color === 'blue' ? 'bg-blue-100' :
+ notification.icon_color === 'green' ? 'bg-green-100' :
+ notification.icon_color === 'red' ? 'bg-red-100' :
+ notification.icon_color === 'purple' ? 'bg-purple-100' :
+ 'bg-slate-100'
+ }`}>
+
+
+
notification.action_link && handleAction(notification)}
+ >
+
+
+ {notification.title} {notification.description}
+
+
+ {formatDistanceToNow(notifDate, { addSuffix: true })}
+
+
+
+ {notification.action_link && (
+ {notification.action_label}
+ )}
+ {format(notifDate, 'MM/dd/yy • h:mm a')}
+ {isUnread && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ )}
+ >
+ )}
+
+
+ >
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/onboarding/CompletionStep.jsx b/frontend-web-free/src/components/onboarding/CompletionStep.jsx
new file mode 100644
index 00000000..84d302c8
--- /dev/null
+++ b/frontend-web-free/src/components/onboarding/CompletionStep.jsx
@@ -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 (
+
+
+
+
+
+
You're All Set! 🎉
+
Review your information before completing onboarding
+
+
+ {/* Summary Cards */}
+
+ {/* Profile Summary */}
+
+
+
+
+
+
+
+
Profile Information
+
+
+
Name
+
{profile.full_name}
+
+
+
Email
+
{profile.email}
+
+
+
Position
+
{profile.position}
+
+
+
Location
+
{profile.city}
+
+
+
+
+
+
+
+
+ {/* Documents Summary */}
+
+
+
+
+
+
+
+
Documents Uploaded
+
+ {documents.map((doc, idx) => (
+
+ {doc.name}
+
+ ))}
+ {documents.length === 0 && (
+
No documents uploaded
+ )}
+
+
+
+
+
+
+
+ {/* Training Summary */}
+
+
+
+
+
+
+
+
Training Completed
+
+ {training.completed.length} training modules completed
+
+ {training.acknowledged && (
+
Compliance Acknowledged
+ )}
+
+
+
+
+
+
+
+ {/* Next Steps */}
+
+
+ What Happens Next?
+
+ -
+
+ Your profile will be activated and available for shift assignments
+
+ -
+
+ You'll receive an email confirmation with your login credentials
+
+ -
+
+ Our team will review your documents within 24-48 hours
+
+ -
+
+ You can start accepting shift invitations immediately
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/onboarding/DocumentUploadStep.jsx b/frontend-web-free/src/components/onboarding/DocumentUploadStep.jsx
new file mode 100644
index 00000000..9225f229
--- /dev/null
+++ b/frontend-web-free/src/components/onboarding/DocumentUploadStep.jsx
@@ -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 (
+
+
+
Document Upload
+
Upload required documents for compliance
+
+
+
+ {requiredDocuments.map(doc => {
+ const uploadedDoc = getUploadedDoc(doc.id);
+ const isUploading = uploading[doc.id];
+
+ return (
+
+
+
+
+
+
+ {uploadedDoc && (
+
+ )}
+
+
{doc.description}
+
+ {uploadedDoc && (
+
+
+ {uploadedDoc.name}
+
+
+ )}
+
+
+
+
+ handleFileUpload(doc.id, e.target.files[0])}
+ disabled={isUploading}
+ />
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/onboarding/ProfileSetupStep.jsx b/frontend-web-free/src/components/onboarding/ProfileSetupStep.jsx
new file mode 100644
index 00000000..320e8e98
--- /dev/null
+++ b/frontend-web-free/src/components/onboarding/ProfileSetupStep.jsx
@@ -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 (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/onboarding/TrainingStep.jsx b/frontend-web-free/src/components/onboarding/TrainingStep.jsx
new file mode 100644
index 00000000..f525174e
--- /dev/null
+++ b/frontend-web-free/src/components/onboarding/TrainingStep.jsx
@@ -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 (
+
+
+
Compliance Training
+
Complete required training modules to ensure readiness
+
+
+
+ {trainingModules.map(module => (
+
+
+
+
+ {isComplete(module.id) ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {module.title}
+ {module.required && *}
+
+
{module.duration} · {module.description}
+
+
+
+
+ {module.topics.map((topic, idx) => (
+ - {topic}
+ ))}
+
+
+
+
+
+
+
+ ))}
+
+
+ {allRequiredComplete && (
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/CancellationFeeModal.jsx b/frontend-web-free/src/components/orders/CancellationFeeModal.jsx
new file mode 100644
index 00000000..50ea256d
--- /dev/null
+++ b/frontend-web-free/src/components/orders/CancellationFeeModal.jsx
@@ -0,0 +1,161 @@
+import React from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { AlertTriangle, Clock, DollarSign, Calendar, Users } from "lucide-react";
+import { format, differenceInHours } from "date-fns";
+
+// Calculate if cancellation fee applies
+export const calculateCancellationFee = (eventDate, eventStartTime, assignedCount) => {
+ const now = new Date();
+
+ // Combine event date and start time
+ const eventDateTime = new Date(`${eventDate}T${eventStartTime || '00:00'}`);
+ const hoursUntilEvent = differenceInHours(eventDateTime, now);
+
+ // Rule: 24+ hours = no fee, < 24 hours = 4-hour fee per worker
+ const feeApplies = hoursUntilEvent < 24;
+ const feeAmount = feeApplies ? assignedCount * 4 * 50 : 0; // Assuming $50/hour average
+
+ return {
+ feeApplies,
+ hoursUntilEvent,
+ feeAmount,
+ assignedCount
+ };
+};
+
+export default function CancellationFeeModal({
+ open,
+ onClose,
+ onConfirm,
+ event,
+ isSubmitting
+}) {
+ if (!event) return null;
+
+ const eventStartTime = event.shifts?.[0]?.roles?.[0]?.start_time || '09:00';
+ const assignedCount = event.assigned_staff?.length || 0;
+ const feeData = calculateCancellationFee(event.date, eventStartTime, assignedCount);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/OrderDetailModal.jsx b/frontend-web-free/src/components/orders/OrderDetailModal.jsx
new file mode 100644
index 00000000..39dea130
--- /dev/null
+++ b/frontend-web-free/src/components/orders/OrderDetailModal.jsx
@@ -0,0 +1,362 @@
+import React from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import { Calendar, MapPin, Users, DollarSign, Clock, Building2, FileText, X, Star, ExternalLink, Edit3, User } from "lucide-react";
+import { format, parseISO, isValid } from "date-fns";
+import { base44 } from "@/api/base44Client";
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+import { createPageUrl } from "@/utils";
+
+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 = (status) => {
+ const statusConfig = {
+ 'Draft': { bg: 'bg-slate-500', text: 'Draft' },
+ 'Pending': { bg: 'bg-amber-500', text: 'Pending' },
+ 'Partial Staffed': { bg: 'bg-orange-500', text: 'Partial Staffed' },
+ 'Fully Staffed': { bg: 'bg-emerald-500', text: 'Fully Staffed' },
+ 'Active': { bg: 'bg-blue-500', text: 'Active' },
+ 'Completed': { bg: 'bg-slate-400', text: 'Completed' },
+ 'Canceled': { bg: 'bg-red-500', text: 'Canceled' },
+ };
+
+ const config = statusConfig[status] || { bg: 'bg-slate-400', text: status };
+
+ return (
+
+ {config.text}
+
+ );
+};
+
+export default function OrderDetailModal({ open, onClose, order, onCancel }) {
+ const navigate = useNavigate();
+
+ const { data: allStaff = [] } = useQuery({
+ queryKey: ['staff-for-order-modal'],
+ queryFn: () => base44.entities.Staff.list(),
+ enabled: open,
+ });
+
+ if (!order) return null;
+
+ const canEditOrder = (order) => {
+ const eventDate = safeParseDate(order.date);
+ const now = new Date();
+ return order.status !== "Completed" &&
+ order.status !== "Canceled" &&
+ eventDate && eventDate > now;
+ };
+
+ const canCancelOrder = (order) => {
+ return order.status !== "Completed" && order.status !== "Canceled";
+ };
+
+ const handleViewFullOrder = () => {
+ navigate(createPageUrl(`EventDetail?id=${order.id}`));
+ };
+
+ const handleEditOrder = () => {
+ navigate(createPageUrl(`EditEvent?id=${order.id}`));
+ };
+
+ const handleCancelOrder = () => {
+ onClose();
+ if (onCancel) {
+ onCancel(order);
+ }
+ };
+
+ const assignedCount = order.assigned_staff?.length || 0;
+ const requestedCount = order.requested || 0;
+ const assignmentProgress = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0;
+
+ // Get event times
+ const firstShift = order.shifts?.[0];
+ const rolesInFirstShift = firstShift?.roles || [];
+ const startTime = rolesInFirstShift.length > 0 ? convertTo12Hour(rolesInFirstShift[0].start_time) : "-";
+ const endTime = rolesInFirstShift.length > 0 ? convertTo12Hour(rolesInFirstShift[0].end_time) : "-";
+
+ // Get staff details
+ const getStaffDetails = (staffId) => {
+ return allStaff.find(s => s.id === staffId) || {};
+ };
+
+ return (
+
+ );
+ }
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/OrderReductionAlert.jsx b/frontend-web-free/src/components/orders/OrderReductionAlert.jsx
new file mode 100644
index 00000000..f1c1e667
--- /dev/null
+++ b/frontend-web-free/src/components/orders/OrderReductionAlert.jsx
@@ -0,0 +1,109 @@
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { AlertTriangle, UserMinus, TrendingDown, CheckCircle } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+export default function OrderReductionAlert({
+ originalRequested,
+ newRequested,
+ currentAssigned,
+ onAutoUnassign,
+ onManualUnassign,
+ lowReliabilityStaff = []
+}) {
+ const excessStaff = currentAssigned - newRequested;
+
+ if (excessStaff <= 0) return null;
+
+ return (
+
+
+
+
+
+
+ Order Size Reduction Detected
+
+
+ Client reduced headcount from {originalRequested} to {newRequested}
+
+
+
+
+
+
+
+
+
+
+ Action Required:
+
+
+ You have {excessStaff} staff member{excessStaff !== 1 ? 's' : ''} assigned
+ that exceed{excessStaff === 1 ? 's' : ''} the new request.
+ You must unassign {excessStaff} worker{excessStaff !== 1 ? 's' : ''} to match the new headcount.
+
+
+
+
+
+
+
Original Request
+
{originalRequested}
+
+
+
New Request
+
{newRequested}
+
+
+
Must Remove
+
{excessStaff}
+
+
+
+
+
+
+ {lowReliabilityStaff.length > 0 && (
+
+ )}
+
+
+ {lowReliabilityStaff.length > 0 && (
+
+
+ Suggested for Auto-Removal (Lowest Reliability):
+
+
+ {lowReliabilityStaff.slice(0, excessStaff).map((staff, idx) => (
+
+ {staff.name}
+
+ Reliability: {staff.reliability}%
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/OrderStatusBadge.jsx b/frontend-web-free/src/components/orders/OrderStatusBadge.jsx
new file mode 100644
index 00000000..6837a5c5
--- /dev/null
+++ b/frontend-web-free/src/components/orders/OrderStatusBadge.jsx
@@ -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 (
+
+ {showDot && (
+
+ )}
+ {showIcon && }
+ {status.label}
+
+ );
+}
+
+// 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);
+ });
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/OrderStatusUtils.jsx b/frontend-web-free/src/components/orders/OrderStatusUtils.jsx
new file mode 100644
index 00000000..3cadf867
--- /dev/null
+++ b/frontend-web-free/src/components/orders/OrderStatusUtils.jsx
@@ -0,0 +1,37 @@
+// Utility to calculate order status based on current state
+export function calculateOrderStatus(event) {
+ // Check explicit statuses first
+ if (event.status === "Canceled" || event.status === "Cancelled") {
+ return "Canceled";
+ }
+
+ if (event.status === "Draft") {
+ return "Draft";
+ }
+
+ if (event.status === "Completed") {
+ return "Completed";
+ }
+
+ // Calculate status based on staffing
+ const requested = event.requested || 0;
+ const assigned = event.assigned_staff?.length || 0;
+
+ if (requested === 0) {
+ return "Draft"; // No staff requested yet
+ }
+
+ if (assigned === 0) {
+ return "Pending"; // Awaiting assignment
+ }
+
+ if (assigned < requested) {
+ return "Partial"; // Partially staffed
+ }
+
+ if (assigned >= requested) {
+ return "Confirmed"; // Fully staffed
+ }
+
+ return "Pending";
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/RapidOrderChat.jsx b/frontend-web-free/src/components/orders/RapidOrderChat.jsx
new file mode 100644
index 00000000..51719c09
--- /dev/null
+++ b/frontend-web-free/src/components/orders/RapidOrderChat.jsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ RAPID Order Assistant
+
+
Emergency staffing in minutes
+
+
+ URGENT
+
+
+
+
+
+ {/* Chat Messages */}
+
+ {conversation.length === 0 && (
+
+
+
+
+
Need staff urgently?
+
Just describe what you need, I'll handle the rest
+
+
+ Example: "We had a call out. Need 2 cooks ASAP"
+
+
+ Example: "Emergency! Need bartender for tonight"
+
+
+
+ )}
+
+
+ {conversation.map((msg, idx) => (
+
+
+ {msg.role === 'assistant' && (
+
+ )}
+
+ {msg.content}
+
+
+ {msg.showConfirm && detectedOrder && (
+
+
+
+
+
+
Staff Needed
+
{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}
+
+
+
+
+
+
Location
+
{detectedOrder.location}
+
+
+
+
+
+
Time
+
{detectedOrder.start_time} → {detectedOrder.end_time}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ ))}
+
+
+ {isProcessing && (
+
+
+
+
+
+
+
Processing your request...
+
+
+
+ )}
+
+
+ {/* Input */}
+
+ 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}
+ />
+
+
+
+ {/* Helper Text */}
+
+
+
+
+ Tip: Include role, quantity, and urgency for fastest processing.
+ AI will auto-detect your location and send to your preferred vendor.
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/RapidOrderInterface.jsx b/frontend-web-free/src/components/orders/RapidOrderInterface.jsx
new file mode 100644
index 00000000..abfa14fe
--- /dev/null
+++ b/frontend-web-free/src/components/orders/RapidOrderInterface.jsx
@@ -0,0 +1,276 @@
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Zap, Send, Mic, Calendar, Clock, ArrowLeft, Users, MapPin, Edit2, CheckCircle } from "lucide-react";
+import { format } from "date-fns";
+
+export default function RapidOrderInterface({ onBack, onSubmit }) {
+ const [message, setMessage] = useState("");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [parsedData, setParsedData] = useState(null);
+ const [showConfirmation, setShowConfirmation] = useState(false);
+
+ const handleBack = () => {
+ if (onBack) onBack();
+ };
+
+ const examples = [
+ { text: "We had a call out. Need 2 cooks ASAP", color: "bg-blue-50 border-blue-200 text-blue-700" },
+ { text: "Need 5 bartenders ASAP until 5am", color: "bg-purple-50 border-purple-200 text-purple-700" },
+ { text: "Emergency! Need 3 servers right now till midnight", color: "bg-green-50 border-green-200 text-green-700" },
+ ];
+
+ const parseRapidMessage = (msg) => {
+ // Extract count (numbers)
+ const countMatch = msg.match(/(\d+)/);
+ const count = countMatch ? parseInt(countMatch[1]) : 1;
+
+ // Extract role (common keywords)
+ const roles = ['server', 'cook', 'chef', 'bartender', 'dishwasher', 'host', 'runner'];
+ let role = 'staff';
+ for (const r of roles) {
+ if (msg.toLowerCase().includes(r)) {
+ role = r + (count > 1 ? 's' : '');
+ break;
+ }
+ }
+
+ // Extract time (until X, till X, by X)
+ const timeMatch = msg.match(/until\s+(\d+(?::\d+)?\s*(?:am|pm)?)|till\s+(\d+(?::\d+)?\s*(?:am|pm)?)|by\s+(\d+(?::\d+)?\s*(?:am|pm)?)/i);
+ const endTime = timeMatch ? (timeMatch[1] || timeMatch[2] || timeMatch[3]) : '11:59pm';
+
+ // Current time as start
+ const now = new Date();
+ const startTime = format(now, 'h:mm a');
+
+ return {
+ count,
+ role,
+ startTime,
+ endTime,
+ location: "Client's location" // Default, can be auto-detected
+ };
+ };
+
+ const handleSend = async () => {
+ if (!message.trim()) return;
+ setIsProcessing(true);
+
+ // Parse the message
+ const parsed = parseRapidMessage(message);
+ setParsedData(parsed);
+ setShowConfirmation(true);
+
+ setIsProcessing(false);
+ };
+
+ const handleConfirm = () => {
+ if (onSubmit && parsedData) {
+ onSubmit({
+ rawMessage: message,
+ orderType: 'rapid',
+ ...parsedData
+ });
+ }
+ };
+
+ const handleEdit = () => {
+ setShowConfirmation(false);
+ setParsedData(null);
+ };
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ const handleExampleClick = (exampleText) => {
+ setMessage(exampleText);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ RAPID Order
+
+
Emergency staffing in minutes
+
+
+
+
+
+ {format(new Date(), 'EEE, MMM dd, yyyy')}
+
+
+
+ {format(new Date(), 'h:mm a')}
+
+
+
+
+
+
+ {/* Section Header */}
+
+
Tell us what you need
+ URGENT
+
+
+ {/* Main Content */}
+
+ {/* Icon + Message */}
+
+
+
+
+
Need staff urgently?
+
Type or speak what you need. I'll handle the rest
+
+
+ {/* Example Prompts */}
+
+ {examples.map((example, idx) => (
+
+ ))}
+
+
+ {/* Input Area */}
+ {showConfirmation && parsedData ? (
+
+ {/* AI Confirmation Card */}
+
+
+
+
+
+
+
AI Assistant
+
+ Is this a RAPID ORDER for **{parsedData.count} {parsedData.role}** at **{parsedData.location}**?
+
+
+
+
+
+
Start Time: {parsedData.startTime}
+
End Time: {parsedData.endTime}
+
+
+ {/* Details Card */}
+
+
+
+
+
+
+
Staff Needed
+
{parsedData.count} {parsedData.role}
+
+
+
+
+
+
+
+
+
Location
+
{parsedData.location}
+
+
+
+
+
+
+
+
+
Time
+
Start: {parsedData.startTime} | End: {parsedData.endTime}
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+ ) : (
+
+ )}
+
+ {/* Tip */}
+
+
+
+ i
+
+
+ Tip: 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.
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/SmartAssignModal.jsx b/frontend-web-free/src/components/orders/SmartAssignModal.jsx
new file mode 100644
index 00000000..bafc8dbd
--- /dev/null
+++ b/frontend-web-free/src/components/orders/SmartAssignModal.jsx
@@ -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 (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/orders/WorkerConfirmationCard.jsx b/frontend-web-free/src/components/orders/WorkerConfirmationCard.jsx
new file mode 100644
index 00000000..832f70a6
--- /dev/null
+++ b/frontend-web-free/src/components/orders/WorkerConfirmationCard.jsx
@@ -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 (
+
+
+
+
+
+
{event.event_name}
+ {event.is_rapid && (
+
+
+ RAPID
+
+ )}
+
+
{assignment.role}
+
+
+ {assignment.assignment_status}
+
+
+
+
+
+
+
+
Date
+
+ {event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"}
+
+
+
+
+
+
+
Time
+
+ {assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"}
+
+
+
+
+
+
+
Location
+
{event.event_location || event.hub}
+
+
+
+
+ {/* Shift Details */}
+ {event.shifts?.[0] && (
+
+
+
+ Shift Details
+
+
+ {event.shifts[0].uniform_type && (
+
Attire: {event.shifts[0].uniform_type}
+ )}
+ {event.addons?.meal_provided && (
+
Meal: Provided
+ )}
+ {event.notes && (
+
Notes: {event.notes}
+ )}
+
+
+ )}
+
+ {/* Action Buttons */}
+ {assignment.assignment_status === "Pending" && (
+
+
+
+
+ )}
+
+ {assignment.assignment_status === "Confirmed" && (
+
+
+
+ You're confirmed for this shift!
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/permissions/UserPermissionsModal.jsx b/frontend-web-free/src/components/permissions/UserPermissionsModal.jsx
new file mode 100644
index 00000000..e88dd397
--- /dev/null
+++ b/frontend-web-free/src/components/permissions/UserPermissionsModal.jsx
@@ -0,0 +1,228 @@
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; // Added AvatarImage
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ Users, Calendar, Package, DollarSign, FileText, BarChart3,
+ Shield, Building2, Briefcase, Info, ExternalLink
+} from "lucide-react";
+
+const ROLE_TEMPLATES = {
+ admin: { name: "Administrator", color: "bg-red-100 text-red-700" },
+ procurement: { name: "Procurement Manager", color: "bg-purple-100 text-purple-700" },
+ operator: { name: "Operator", color: "bg-blue-100 text-blue-700" },
+ sector: { name: "Sector Manager", color: "bg-cyan-100 text-cyan-700" },
+ client: { name: "Client", color: "bg-green-100 text-green-700" },
+ vendor: { name: "Vendor Partner", color: "bg-amber-100 text-amber-700" },
+ workforce: { name: "Workforce Member", color: "bg-slate-100 text-slate-700" }
+};
+
+const ROLE_PERMISSIONS = {
+ admin: [
+ { id: "system_admin", icon: Shield, name: "System Administration", description: "Full access to all system settings and user management", defaultEnabled: true },
+ { id: "enterprise_mgmt", icon: Building2, name: "Enterprise Management", description: "Create and manage enterprises, sectors, and partners", defaultEnabled: true },
+ { id: "vendor_oversight", icon: Package, name: "Vendor Oversight", description: "Approve/suspend vendors and manage all vendor relationships", defaultEnabled: true },
+ { id: "financial_admin", icon: DollarSign, name: "Financial Administration", description: "Access all financial data and process payments", defaultEnabled: true },
+ { id: "reports_admin", icon: BarChart3, name: "Admin Reports", description: "Generate and export all system reports", defaultEnabled: true },
+ ],
+ procurement: [
+ { id: "vendor_view", icon: Package, name: "View All Vendors", description: "Access complete vendor directory and profiles", defaultEnabled: true },
+ { id: "vendor_onboard", icon: Users, name: "Onboard Vendors", description: "Add new vendors to the platform", defaultEnabled: true },
+ { id: "vendor_compliance", icon: Shield, name: "Vendor Compliance Review", description: "Review and approve vendor compliance documents", defaultEnabled: true },
+ { id: "rate_cards", icon: DollarSign, name: "Rate Card Management", description: "Create, edit, and approve vendor rate cards", defaultEnabled: true },
+ { id: "vendor_performance", icon: BarChart3, name: "Vendor Performance Analytics", description: "View scorecards and KPI reports", defaultEnabled: true },
+ { id: "order_oversight", icon: Calendar, name: "Order Oversight", description: "View and manage all orders across sectors", defaultEnabled: false },
+ ],
+ operator: [
+ { id: "enterprise_events", icon: Calendar, name: "Enterprise Event Management", description: "Create and manage events across your enterprise", defaultEnabled: true },
+ { id: "sector_mgmt", icon: Building2, name: "Sector Management", description: "Configure and manage your sectors", defaultEnabled: true },
+ { id: "staff_mgmt", icon: Users, name: "Workforce Management", description: "Assign and manage staff across enterprise", defaultEnabled: true },
+ { id: "event_financials", icon: DollarSign, name: "Event Financials", description: "View costs and billing for all events", defaultEnabled: true },
+ { id: "approve_events", icon: Shield, name: "Approve Events", description: "Approve event requests from sectors", defaultEnabled: false },
+ { id: "enterprise_reports", icon: BarChart3, name: "Enterprise Reports", description: "Generate analytics for your enterprise", defaultEnabled: true },
+ ],
+ sector: [
+ { id: "sector_events", icon: Calendar, name: "Sector Event Management", description: "Create and manage events at your location", defaultEnabled: true },
+ { id: "location_staff", icon: Users, name: "Location Staff Management", description: "Schedule and manage staff at your sector", defaultEnabled: true },
+ { id: "timesheet_approval", icon: FileText, name: "Timesheet Approval", description: "Review and approve staff timesheets", defaultEnabled: true },
+ { id: "vendor_rates", icon: DollarSign, name: "View Vendor Rates", description: "Access rate cards for approved vendors", defaultEnabled: true },
+ { id: "event_costs", icon: BarChart3, name: "Event Cost Visibility", description: "View billing for your events", defaultEnabled: false },
+ ],
+ client: [
+ { id: "create_orders", icon: Calendar, name: "Create Orders", description: "Request staffing for events", defaultEnabled: true },
+ { id: "view_orders", icon: FileText, name: "View My Orders", description: "Track your event orders", defaultEnabled: true },
+ { id: "vendor_selection", icon: Package, name: "Vendor Selection", description: "View and request specific vendors", defaultEnabled: true },
+ { id: "staff_review", icon: Users, name: "Staff Review", description: "Rate staff and request changes", defaultEnabled: true },
+ { id: "invoices", icon: DollarSign, name: "Billing & Invoices", description: "View and download invoices", defaultEnabled: true },
+ { id: "spend_analytics", icon: BarChart3, name: "Spend Analytics", description: "View your spending trends", defaultEnabled: false },
+ ],
+ vendor: [
+ { id: "order_fulfillment", icon: Calendar, name: "Order Fulfillment", description: "Accept and manage assigned orders", defaultEnabled: true },
+ { id: "my_workforce", icon: Users, name: "My Workforce", description: "Manage your staff members", defaultEnabled: true },
+ { id: "staff_compliance", icon: Shield, name: "Staff Compliance", description: "Track certifications and background checks", defaultEnabled: true },
+ { id: "my_rates", icon: DollarSign, name: "Rate Management", description: "View and propose rate cards", defaultEnabled: true },
+ { id: "my_invoices", icon: FileText, name: "Invoices & Payments", description: "Create and track invoices", defaultEnabled: true },
+ { id: "performance", icon: BarChart3, name: "Performance Dashboard", description: "View your scorecard and metrics", defaultEnabled: false },
+ ],
+ workforce: [
+ { id: "my_schedule", icon: Calendar, name: "View My Schedule", description: "See upcoming shifts and assignments", defaultEnabled: true },
+ { id: "clock_inout", icon: FileText, name: "Clock In/Out", description: "Record shift start and end times", defaultEnabled: true },
+ { id: "my_profile", icon: Users, name: "My Profile", description: "Update contact info and availability", defaultEnabled: true },
+ { id: "upload_certs", icon: Shield, name: "Upload Certifications", description: "Add certificates and licenses", defaultEnabled: true },
+ { id: "earnings", icon: DollarSign, name: "View Earnings", description: "See pay, hours, and payment history", defaultEnabled: true },
+ { id: "performance_stats", icon: BarChart3, name: "My Performance", description: "View ratings and reliability metrics", defaultEnabled: false },
+ ]
+};
+
+export default function UserPermissionsModal({ user, open, onClose, onSave, isSaving }) {
+ const [selectedRole, setSelectedRole] = useState(user?.user_role || "client");
+ const [permissions, setPermissions] = useState({});
+
+ // Initialize permissions when user or role changes
+ useEffect(() => {
+ if (user && open) {
+ const rolePerms = ROLE_PERMISSIONS[user.user_role || "client"] || [];
+ const initialPerms = {};
+ rolePerms.forEach(perm => {
+ initialPerms[perm.id] = perm.defaultEnabled;
+ });
+ setPermissions(initialPerms);
+ setSelectedRole(user.user_role || "client");
+ }
+ }, [user, open]);
+
+ // Update permissions when role changes
+ useEffect(() => {
+ const rolePerms = ROLE_PERMISSIONS[selectedRole] || [];
+ const newPerms = {};
+ rolePerms.forEach(perm => {
+ newPerms[perm.id] = perm.defaultEnabled;
+ });
+ setPermissions(newPerms);
+ }, [selectedRole]);
+
+ const handleToggle = (permId) => {
+ setPermissions(prev => ({
+ ...prev,
+ [permId]: !prev[permId]
+ }));
+ };
+
+ const handleSave = () => {
+ onSave({
+ ...user,
+ user_role: selectedRole,
+ permissions: Object.keys(permissions).filter(key => permissions[key])
+ });
+ onClose();
+ };
+
+ if (!user) return null;
+
+ const roleTemplate = ROLE_TEMPLATES[selectedRole];
+ const rolePermissions = ROLE_PERMISSIONS[selectedRole] || [];
+ const userInitial = user.full_name?.charAt(0).toUpperCase() || user.email?.charAt(0).toUpperCase() || "U";
+
+ return (
+
+ );
+}
diff --git a/frontend-web-free/src/components/procurement/COIViewer.jsx b/frontend-web-free/src/components/procurement/COIViewer.jsx
new file mode 100644
index 00000000..320287c5
--- /dev/null
+++ b/frontend-web-free/src/components/procurement/COIViewer.jsx
@@ -0,0 +1,192 @@
+import React, { useState } from "react";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Shield, Download, Upload, Save, AlertCircle } from "lucide-react";
+
+export default function COIViewer({ vendor, onClose }) {
+ const coverageLines = [
+ {
+ name: "General Liability",
+ expires: "11/20/2025",
+ status: "Non-Compliant",
+ statusColor: "bg-red-100 text-red-700"
+ },
+ {
+ name: "Automobile Liability",
+ expires: "03/30/2025",
+ status: "Non-Compliant",
+ statusColor: "bg-red-100 text-red-700"
+ },
+ {
+ name: "Workers Compensation",
+ expires: "11/15/2025",
+ status: "Non-Compliant",
+ statusColor: "bg-red-100 text-red-700"
+ },
+ {
+ name: "Property",
+ expires: "",
+ status: "Non-Compliant",
+ statusColor: "bg-red-100 text-red-700"
+ }
+ ];
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Policy & Agent
+
+
Last Reviewed: 07/25/2025, 12:24 PM
+
+
+
+
+
+
+
+
+ {/* Lines Covered by Agent */}
+
+
+ Lines Covered by Agent
+
+ Legendary Event Staffing & Entertainment, LLC (Vendor as an Agent)
+
+ Uploaded 07/23/2025, 01:20 PM
+
+
+ {coverageLines.map((line, idx) => (
+
+
+
{line.name}
+ {line.expires && (
+
Expires {line.expires}
+ )}
+
+
+ {line.status}
+
+
+ ))}
+
+
+
+
+
+ {/* Non-Compliant Notes */}
+
+
+
+
+ Non-Compliant Notes
+
+
+
+ {/* General Liability */}
+
+
General Liability
+
+ -
+ •
+ Please confirm aggregate limit applies on a per location basis on the certificate and/or by uploading additional documentation.
+
+ -
+ •
+ Waiver of Subrogation
+
+ -
+ •
+ Primary and Non-Contributory
+
+ -
+ •
+ Confirm on certificate Contractual Liability is not excluded.
+
+ -
+ •
+ Confirm on the certificate that severability of interest is included.
+
+
+
+
+ {/* Property */}
+
+
Property
+
+ -
+ •
+ Please submit proof of coverage.
+
+
+
+
+ {/* Additional Coverage */}
+
+
Additional Coverage
+
+ -
+ •
+ Waiver of Subrogation applies to: South Bay Construction and Development I, LLC and South Bay Development Company, and their Employees, Agents
+
+
+
+
+ {/* Automobile Liability */}
+
+
Automobile Liability
+
+ -
+ •
+ Waiver of Subrogation
+
+
+
+
+ {/* Workers Compensation */}
+
+
Workers Compensation
+
+ -
+ •
+ Waiver of Subrogation
+
+
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+ 4 Items Non-Compliant
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/procurement/VendorDetailModal.jsx b/frontend-web-free/src/components/procurement/VendorDetailModal.jsx
new file mode 100644
index 00000000..e4c42737
--- /dev/null
+++ b/frontend-web-free/src/components/procurement/VendorDetailModal.jsx
@@ -0,0 +1,650 @@
+
+import React, { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Building2,
+ Award,
+ Shield,
+ FileText,
+ DollarSign,
+ TrendingUp,
+ Users,
+ MapPin,
+ Mail,
+ Phone,
+ Edit,
+ Download,
+ CheckCircle2,
+ AlertCircle,
+ Clock,
+ Target,
+ Upload,
+ ArrowLeft // Added for viewer components
+} from "lucide-react";
+
+// COI Viewer Component
+const COIViewer = ({ vendor, onClose }) => (
+
+
+
+
+
+ Certificate of Insurance for {vendor.name}
+
+
+
+
+
+ {/* Mock PDF Viewer / Content */}
+
+
+
COI Document Preview
+
This is a placeholder for the Certificate of Insurance document viewer. In a real application, an embedded PDF viewer or a document image would be displayed here.
+
Vendor Name: {vendor.name}
+
Policy Number: ABC-123456789
+
Expiration Date: 11/20/2025
+
+ [PDF Viewer Embed / Document Image Placeholder]
+
+
+
+
+);
+
+// W9 Form Viewer Component
+const W9FormViewer = ({ vendor, onClose }) => (
+
+
+
+
+
+ W-9 Form for {vendor.name}
+
+
+
+
+
+ {/* Mock PDF Viewer / Content */}
+
+
+
W-9 Form Document Preview
+
This is a placeholder for the W-9 Form document viewer. In a real application, an embedded PDF viewer or a document image would be displayed here.
+
Vendor Name: {vendor.name}
+
Tax ID: XX-XXXXXXX
+
Last Updated: 07/23/2025
+
+ [PDF Viewer Embed / Document Image Placeholder]
+
+
+
+
+);
+
+export default function VendorDetailModal({ vendor, open, onClose, onEdit }) {
+ const [activeTab, setActiveTab] = useState("overview");
+ const [showCOI, setShowCOI] = useState(false);
+ const [showW9, setShowW9] = useState(false);
+
+ if (!vendor) return null;
+
+ // Mock compliance data - would come from backend
+ const complianceData = {
+ overall: vendor.name === "Legendary Event Staffing" ? 98 : 95,
+ coi: { status: "valid", expires: "11/20/2025" },
+ w9: { status: "valid", lastUpdated: "07/23/2025" },
+ backgroundChecks: { status: "valid", percentage: 100 },
+ insurance: { status: vendor.name === "Legendary Event Staffing" ? "valid" : "expiring", expires: "03/30/2025" }
+ };
+
+ const performanceData = {
+ overallScore: vendor.name === "Legendary Event Staffing" ? "A+" : vendor.name === "Instawork" ? "A" : "B+",
+ fillRate: vendor.fillRate,
+ onTimeRate: vendor.onTimeRate,
+ billingAccuracy: vendor.name === "Legendary Event Staffing" ? 99.5 : 96,
+ clientSatisfaction: vendor.csat,
+ reliability: vendor.name === "Legendary Event Staffing" ? 98 : 91,
+ avgHourlyRate: vendor.name === "Legendary Event Staffing" ? 23.50 : vendor.name === "Instawork" ? 22.00 : 21.00,
+ vendorFeePercentage: vendor.name === "Legendary Event Staffing" ? 19.6 : vendor.name === "Instawork" ? 20.5 : 22.0,
+ employeeWage: vendor.name === "Legendary Event Staffing" ? 18.50 : 17.00,
+ totalEmployees: vendor.employees,
+ monthlySpend: vendor.spend
+ };
+
+ const documents = [
+ { name: "COI (Certificate of Insurance)", status: complianceData.coi.status, lastUpdated: "2 days ago", type: "coi" },
+ { name: "W9 Forms", status: complianceData.w9.status, lastUpdated: "2 days ago", type: "w9" },
+ { name: "Contracts", status: "active", lastUpdated: "2 days ago", type: "contract" },
+ { name: "ESG Certification", status: "valid", lastUpdated: "2 days ago", type: "esg" },
+ { name: "Policies", status: "updated", lastUpdated: "2 days ago", type: "policy" },
+ { name: "Forms & Templates", status: "available", lastUpdated: "2 days ago", type: "forms" }
+ ];
+
+ const getScoreColor = (score) => {
+ if (score >= 97) return "text-green-600";
+ if (score >= 90) return "text-blue-600";
+ if (score >= 80) return "text-yellow-600";
+ return "text-red-600";
+ };
+
+ const getStatusBadge = (status) => {
+ const colors = {
+ valid: "bg-green-100 text-green-700",
+ active: "bg-blue-100 text-blue-700",
+ updated: "bg-blue-100 text-blue-700",
+ available: "bg-slate-100 text-slate-700",
+ expiring: "bg-yellow-100 text-yellow-700",
+ expired: "bg-red-100 text-red-700"
+ };
+ return colors[status] || "bg-gray-100 text-gray-700";
+ };
+
+ const getScoreBadgeColor = (score) => {
+ if (score >= 95) return "bg-green-100 text-green-700 border-green-200";
+ if (score >= 90) return "bg-blue-100 text-blue-700 border-blue-200";
+ if (score >= 85) return "bg-amber-100 text-amber-700 border-amber-200";
+ return "bg-red-100 text-red-700 border-red-200";
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend-web-free/src/components/procurement/VendorHoverCard.jsx b/frontend-web-free/src/components/procurement/VendorHoverCard.jsx
new file mode 100644
index 00000000..80328789
--- /dev/null
+++ b/frontend-web-free/src/components/procurement/VendorHoverCard.jsx
@@ -0,0 +1,133 @@
+import React from "react";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Badge } from "@/components/ui/badge";
+import { Award, Shield, DollarSign, TrendingUp, Users, MapPin } from "lucide-react";
+
+export default function VendorHoverCard({ vendor, children }) {
+ // Mock performance data - would come from backend
+ const performanceData = {
+ overallScore: vendor.name === "Legendary Event Staffing" ? "A+" : vendor.name === "Instawork" ? "A" : "B+",
+ compliance: vendor.name === "Legendary Event Staffing" ? 98 : 95,
+ billingAccuracy: vendor.name === "Legendary Event Staffing" ? 99.5 : 96,
+ fillRate: vendor.name === "Legendary Event Staffing" ? 97 : 92
+ };
+
+ const getScoreColor = (score) => {
+ if (score >= 97) return "text-green-600";
+ if (score >= 90) return "text-blue-600";
+ if (score >= 80) return "text-yellow-600";
+ return "text-red-600";
+ };
+
+ return (
+
+
+ {children}
+
+
+
+
+
+
{vendor.name}
+
{vendor.specialty}
+
+
+ {performanceData.overallScore}
+
+
+
+
+
+
+ {vendor.region}
+
+
+
+ {vendor.employees.toLocaleString()} staff
+
+
+
+
+
+
+ {/* Compliance */}
+
+
+
+ Compliance
+
+
+ {performanceData.compliance}%
+
+
+
+ {/* Billing Accuracy */}
+
+
+
+ Billing Accuracy
+
+
+ {performanceData.billingAccuracy}%
+
+
+
+ {/* Fill Rate */}
+
+
+
+ Fill Rate
+
+
+
+ {performanceData.fillRate}%
+
+
+
+
+
+
+ {/* Software Badge */}
+
+
Technology Stack
+ {vendor.softwareType === "platform" && (
+
+ ✅ {vendor.software}
+
+ )}
+ {vendor.softwareType === "building" && (
+
+ ⚙️ {vendor.software}
+
+ )}
+ {vendor.softwareType === "partial" && (
+
+ ⚙️ {vendor.software}
+
+ )}
+ {vendor.softwareType === "traditional" && (
+
+ ❌ {vendor.software}
+
+ )}
+
+
+
+ Click for full details
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/procurement/VendorScoreHoverCard.jsx b/frontend-web-free/src/components/procurement/VendorScoreHoverCard.jsx
new file mode 100644
index 00000000..86f48402
--- /dev/null
+++ b/frontend-web-free/src/components/procurement/VendorScoreHoverCard.jsx
@@ -0,0 +1,226 @@
+
+import React from "react";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Badge } from "@/components/ui/badge";
+import { Award, Shield, DollarSign, TrendingUp, Users, MapPin, Building2, Phone, Mail, Briefcase, Hash } from "lucide-react";
+
+export default function VendorScoreHoverCard({ vendor, children }) {
+ // Safety checks for vendor data
+ if (!vendor) {
+ return children;
+ }
+
+ const getScoreColor = (score) => {
+ if (!score) return "text-slate-400";
+ if (score >= 95) return "text-green-600";
+ if (score >= 90) return "text-blue-600";
+ if (score >= 85) return "text-yellow-600";
+ return "text-red-600";
+ };
+
+ const getPerformanceGrade = (fillRate) => {
+ if (!fillRate) return { grade: "N/A", color: "bg-slate-400" };
+ if (fillRate >= 97) return { grade: "A+", color: "bg-green-600" };
+ if (fillRate >= 95) return { grade: "A", color: "bg-green-500" };
+ if (fillRate >= 90) return { grade: "B+", color: "bg-blue-500" };
+ if (fillRate >= 85) return { grade: "B", color: "bg-yellow-500" };
+ return { grade: "C", color: "bg-orange-500" };
+ };
+
+ const performance = getPerformanceGrade(vendor.fillRate);
+
+ return (
+
+
+ {children}
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
{vendor.name || vendor.legal_name || "Vendor"}
+
+
+
+ {vendor.vendorNumber || vendor.vendor_number || "N/A"}
+
+
+
{vendor.specialty || "General Services"}
+
+
+
+ {performance.grade}
+
+
+
+
+
+
Monthly Spend
+
{vendor.spend || "N/A"}
+
+
+
Total Staff
+
{vendor.employees ? vendor.employees.toLocaleString() : "N/A"}
+
+
+
+
+ {/* Company Information */}
+
+
+
Company Information
+
+
+
+
+
{vendor.region || "N/A"}
+
{vendor.state || "N/A"}
+
+
+
+
+
+
+
{vendor.specialty || "N/A"}
+
Primary Service
+
+
+
+
+
+
{vendor.primary_contact_phone || "(555) 123-4567"}
+
+
+
+
+
{vendor.primary_contact_email || `contact@${(vendor.name || "vendor").toLowerCase().replace(/\s+/g, '').replace(/[()&]/g, '')}.com`}
+
+
+
+
+ {/* Technology Stack */}
+ {vendor.software && (
+
+
Technology
+ {vendor.softwareType === "platform" && (
+
+ ✅ {vendor.software}
+
+ )}
+ {vendor.softwareType === "building" && (
+
+ ⚙️ {vendor.software}
+
+ )}
+ {vendor.softwareType === "partial" && (
+
+ ⚙️ {vendor.software}
+
+ )}
+ {vendor.softwareType === "traditional" && (
+
+ ❌ {vendor.software}
+
+ )}
+
+ )}
+
+ {/* Performance Metrics */}
+ {(vendor.fillRate || vendor.onTimeRate || vendor.csat || vendor.employees) && (
+
+
Performance Metrics
+
+ {/* Fill Rate */}
+ {vendor.fillRate && (
+
+
+
+ Fill Rate
+
+
+ {vendor.fillRate}%
+
+
+
+ )}
+
+ {/* On-Time Rate */}
+ {vendor.onTimeRate && (
+
+
+
+ On-Time
+
+
+ {vendor.onTimeRate}%
+
+
+
+ )}
+
+ {/* Customer Satisfaction */}
+ {vendor.csat && (
+
+
+
+ {vendor.csat}/5.0
+
+
+
+ )}
+
+ {/* Workforce */}
+ {vendor.employees && (
+
+
+
+ Workforce
+
+
+ {vendor.employees.toLocaleString()}
+
+
Active staff
+
+ )}
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ Hover over vendor name in any table to see details • Click for full profile
+
+
+
+
+ );
+}
diff --git a/frontend-web-free/src/components/procurement/W9FormViewer.jsx b/frontend-web-free/src/components/procurement/W9FormViewer.jsx
new file mode 100644
index 00000000..c89f39ce
--- /dev/null
+++ b/frontend-web-free/src/components/procurement/W9FormViewer.jsx
@@ -0,0 +1,346 @@
+import React, { useState } from "react";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+import { FileText, Download, Upload, Save, X } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+
+export default function W9FormViewer({ vendor, onClose }) {
+ const [formData, setFormData] = useState({
+ entity_name: vendor?.business_name || "",
+ business_name: "",
+ tax_classification: "",
+ llc_classification: "",
+ has_foreign_partners: false,
+ exempt_payee_code: "",
+ fatca_code: "",
+ address: "",
+ city_state_zip: "",
+ account_numbers: "",
+ ssn_part1: "",
+ ssn_part2: "",
+ ssn_part3: "",
+ ein_part1: "",
+ ein_part2: "",
+ tin_type: "ssn",
+ signature: "",
+ date: ""
+ });
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleSave = () => {
+ // Save logic here
+ console.log("Saving W9 form:", formData);
+ onClose?.();
+ };
+
+ return (
+
+ {/* Header with actions */}
+
+
+
+
+
Form W-9 (Rev. March 2024)
+
+
Request for Taxpayer Identification Number and Certification
+
+
+
+
+
+
+
+ {/* Before you begin notice */}
+
+
Before you begin
+
Give form to the requester. Do not send to the IRS.
+
+
+ {/* Line 1 & 2 - Names */}
+
+
+
+
For a sole proprietor or disregarded entity, enter the owner's name on line 1
+
handleChange('entity_name', e.target.value)}
+ placeholder="Enter name as shown on your tax return"
+ className="font-medium"
+ />
+
+
+
+
+ handleChange('business_name', e.target.value)}
+ placeholder="Enter business or DBA name (if applicable)"
+ />
+
+
+
+ {/* Line 3a - Tax Classification */}
+
+
+
+
+ checked && handleChange('tax_classification', 'individual')}
+ />
+
+
+
+ checked && handleChange('tax_classification', 'c_corp')}
+ />
+
+
+
+ checked && handleChange('tax_classification', 's_corp')}
+ />
+
+
+
+ checked && handleChange('tax_classification', 'partnership')}
+ />
+
+
+
+ checked && handleChange('tax_classification', 'trust')}
+ />
+
+
+
+ checked && handleChange('tax_classification', 'llc')}
+ />
+
+ Enter tax classification:
+ handleChange('llc_classification', e.target.value)}
+ placeholder="C, S, or P"
+ className="w-20 h-8"
+ maxLength={1}
+ />
+
+
+ checked && handleChange('tax_classification', 'other')}
+ />
+
+
+
+
+ {/* Line 3b */}
+
+
+
handleChange('has_foreign_partners', checked)}
+ />
+
+
+
Check if you have any foreign partners, owners, or beneficiaries
+
+
+
+
+
+ {/* Line 4 - Exemptions */}
+
+
+ {/* Lines 5-7 - Address */}
+
+
+ {/* Part I - TIN */}
+
+
Part I - Taxpayer Identification Number (TIN)
+
+ Enter your TIN in the appropriate box. The TIN provided must match the name given on line 1 to avoid backup withholding.
+
+
+
+ {/* SSN */}
+
+
+
OR
+
+ {/* EIN */}
+
+
+
+
+ {/* Part II - Certification */}
+
+
Part II - Certification
+
+
Under penalties of perjury, I certify that:
+
+ - The number shown on this form is my correct taxpayer identification number (or I am waiting for a number to be issued to me); and
+ - I am not subject to backup withholding because (a) I am exempt from backup withholding, or (b) I have not been notified by the IRS that I am subject to backup withholding as a result of a failure to report all interest or dividends, or (c) the IRS has notified me that I am no longer subject to backup withholding; and
+ - I am a U.S. citizen or other U.S. person (defined below); and
+ - The FATCA code(s) entered on this form (if any) indicating that I am exempt from FATCA reporting is correct.
+
+
+
+
+
+
+ {/* Actions */}
+
+
+ Form W-9 (Rev. 3-2024)
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/rates/RateCardModal.jsx b/frontend-web-free/src/components/rates/RateCardModal.jsx
new file mode 100644
index 00000000..f7fad7d6
--- /dev/null
+++ b/frontend-web-free/src/components/rates/RateCardModal.jsx
@@ -0,0 +1,217 @@
+import React, { useState, useMemo } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Slider } from "@/components/ui/slider";
+import { Sparkles, DollarSign } from "lucide-react";
+
+const APPROVED_BASE_RATES = {
+ "FoodBuy": {
+ "Banquet Captain": 47.76, "Barback": 36.13, "Barista": 43.11, "Busser": 39.23, "BW Bartender": 41.15,
+ "Cashier/Standworker": 38.05, "Cook": 44.58, "Dinning Attendant": 41.56, "Dishwasher/ Steward": 38.38,
+ "Executive Chef": 70.60, "FOH Cafe Attendant": 41.56, "Full Bartender": 47.76, "Grill Cook": 44.58,
+ "Host/Hostess/Greeter": 41.56, "Internal Support": 41.56, "Lead Cook": 52.00, "Line Cook": 44.58,
+ "Premium Server": 47.76, "Prep Cook": 37.98, "Receiver": 40.01, "Server": 41.56, "Sous Chef": 59.75,
+ "Warehouse Worker": 41.15, "Baker": 44.58, "Janitor": 38.38, "Mixologist": 71.30, "Utilities": 38.38,
+ "Scullery": 38.38, "Runner": 39.23, "Pantry Cook": 44.58, "Supervisor": 47.76, "Steward": 38.38, "Steward Supervisor": 41.15
+ },
+ "Aramark": {
+ "Banquet Captain": 46.37, "Barback": 33.11, "Barista": 36.87, "Busser": 33.11, "BW Bartender": 36.12,
+ "Cashier/Standworker": 33.11, "Cook": 36.12, "Dinning Attendant": 34.62, "Dishwasher/ Steward": 33.11,
+ "Executive Chef": 76.76, "FOH Cafe Attendant": 34.62, "Full Bartender": 45.15, "Grill Cook": 36.12,
+ "Host/Hostess/Greeter": 34.62, "Internal Support": 37.63, "Lead Cook": 52.68, "Line Cook": 36.12,
+ "Premium Server": 40.64, "Prep Cook": 34.62, "Receiver": 34.62, "Server": 34.62, "Sous Chef": 60.20,
+ "Warehouse Worker": 34.62, "Baker": 45.15, "Janitor": 34.62, "Mixologist": 60.20, "Utilities": 33.11,
+ "Scullery": 33.11, "Runner": 33.11, "Pantry Cook": 36.12, "Supervisor": 45.15, "Steward": 33.11, "Steward Supervisor": 34.10
+ }
+};
+
+export default function RateCardModal({ isOpen, onClose, onSave, editingCard = null }) {
+ const [cardName, setCardName] = useState("");
+ const [baseRateBook, setBaseRateBook] = useState("FoodBuy");
+ const [discountPercent, setDiscountPercent] = useState(0);
+
+ // Reset form when modal opens/closes or editingCard changes
+ React.useEffect(() => {
+ if (isOpen) {
+ setCardName(editingCard?.name || "");
+ setBaseRateBook(editingCard?.baseBook || "FoodBuy");
+ setDiscountPercent(editingCard?.discount || 0);
+ }
+ }, [isOpen, editingCard]);
+
+ const baseRates = APPROVED_BASE_RATES[baseRateBook] || {};
+ const positions = Object.keys(baseRates);
+
+ const stats = useMemo(() => {
+ const rates = Object.values(baseRates);
+ const avgBase = rates.length > 0 ? rates.reduce((a, b) => a + b, 0) / rates.length : 0;
+ const avgNew = avgBase * (1 - discountPercent / 100);
+ const totalSavings = rates.reduce((sum, r) => sum + (r * discountPercent / 100), 0);
+ return { avgBase, avgNew, totalSavings, count: rates.length };
+ }, [baseRates, discountPercent]);
+
+ const handleSave = () => {
+ if (!cardName.trim()) return;
+
+ const discountedRates = {};
+ Object.entries(baseRates).forEach(([pos, rate]) => {
+ discountedRates[pos] = Math.round(rate * (1 - discountPercent / 100) * 100) / 100;
+ });
+
+ onSave({
+ name: cardName,
+ baseBook: baseRateBook,
+ discount: discountPercent,
+ rates: discountedRates
+ });
+ onClose();
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/reports/ClientTrendsReport.jsx b/frontend-web-free/src/components/reports/ClientTrendsReport.jsx
new file mode 100644
index 00000000..b3cadf39
--- /dev/null
+++ b/frontend-web-free/src/components/reports/ClientTrendsReport.jsx
@@ -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 (
+
+
+
+
Client Satisfaction & Booking Trends
+
Track client engagement and satisfaction metrics
+
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+
+
Total Clients
+
{totalClients}
+
+
+
+
+
+
+
+
+
+
+
+
+
Avg Satisfaction
+
{avgSatisfaction}/5
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Repeat Rate
+
{repeatRate}%
+
+
+
+
+
+
+
+
+
+ {/* Monthly Booking Trend */}
+
+
+ Booking Trend Over Time
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Top Clients */}
+
+
+ Top Clients by Bookings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Client List */}
+
+
+ Client Details
+
+
+
+ {topClients.map((client, idx) => (
+
+
+
{client.name}
+
{client.bookings} bookings
+
+
+ ${client.revenue.toLocaleString()}
+
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/reports/CustomReportBuilder.jsx b/frontend-web-free/src/components/reports/CustomReportBuilder.jsx
new file mode 100644
index 00000000..1b5f8a41
--- /dev/null
+++ b/frontend-web-free/src/components/reports/CustomReportBuilder.jsx
@@ -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 (
+
+
+
Custom Report Builder
+
Create custom reports with selected fields and filters
+
+
+
+ {/* Configuration Panel */}
+
+
+ Report Configuration
+
+
+
+
+ setReportConfig(prev => ({ ...prev, name: e.target.value }))}
+ placeholder="e.g., Monthly Performance Report"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {availableFields.map(field => (
+
+ handleFieldToggle(field)}
+ />
+
+
+ ))}
+
+
+
+
+
+ {/* Preview Panel */}
+
+
+ Report Preview
+
+
+ {reportConfig.name && (
+
+
+
{reportConfig.name}
+
+ )}
+
+
+
+
+ {reportConfig.dataSource.charAt(0).toUpperCase() + reportConfig.dataSource.slice(1)}
+
+
+
+ {reportConfig.fields.length > 0 && (
+
+
+
+ {reportConfig.fields.map(field => (
+
+ {field.replace(/_/g, ' ')}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {/* Saved Report Templates */}
+
+
+ Quick Templates
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/reports/OperationalEfficiencyReport.jsx b/frontend-web-free/src/components/reports/OperationalEfficiencyReport.jsx
new file mode 100644
index 00000000..045b25cb
--- /dev/null
+++ b/frontend-web-free/src/components/reports/OperationalEfficiencyReport.jsx
@@ -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 (
+
+
+
+
Operational Efficiency & Automation Impact
+
Track process improvements and automation effectiveness
+
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+
+
Automation Rate
+
{automationRate}%
+
+
+
+
+
+
+
+
+
+
+
+
+
Avg Time to Fill
+
{avgTimeToFill}h
+
+
+
+
+
+
+
+
+
+
+
+
+
Response Time
+
{avgResponseTime}h
+
+
+
+
+
+
+
+
+
+
+
+
+
Completed
+
{events.filter(e => e.status === 'Completed').length}
+
+
+
+
+
+
+
+
+
+ {/* Efficiency Trend */}
+
+
+ Efficiency Metrics Over Time
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Status Breakdown */}
+
+
+
+ Event Status Distribution
+
+
+
+
+ `${name}: ${(percent * 100).toFixed(0)}%`}
+ outerRadius={80}
+ fill="#8884d8"
+ dataKey="value"
+ >
+ {statusData.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+
+
+
+ Key Performance Indicators
+
+
+
+
+
Manual Work Reduction
+
85%
+
+
Excellent
+
+
+
+
First-Time Fill Rate
+
92%
+
+
Good
+
+
+
+
Staff Utilization
+
88%
+
+
Optimal
+
+
+
+
Conflict Detection
+
97%
+
+
High
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/reports/StaffPerformanceReport.jsx b/frontend-web-free/src/components/reports/StaffPerformanceReport.jsx
new file mode 100644
index 00000000..b964ae7f
--- /dev/null
+++ b/frontend-web-free/src/components/reports/StaffPerformanceReport.jsx
@@ -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 (
+
+
+
+
Staff Performance Metrics
+
Reliability, fill rates, and performance tracking
+
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+
+
Avg Reliability
+
{avgReliability.toFixed(1)}%
+
+
+
+
+
+
+
+
+
+
+
+
+
Avg Fill Rate
+
{avgFillRate.toFixed(1)}%
+
+
+
+
+
+
+
+
+
+
+
+
+
Total Cancellations
+
{totalCancellations}
+
+
+
+
+
+
+
+
+
+ {/* Fill Rate Distribution */}
+
+
+ Fill Rate Distribution
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Top Performers Table */}
+
+
+ Top Performers
+
+
+
+
+
+ Staff Member
+ Position
+ Shifts
+ Fill Rate
+ Reliability
+ Rating
+
+
+
+ {topPerformers.map((staff) => (
+
+
+
+
+
+ {staff.name.charAt(0)}
+
+
+
{staff.name}
+
+
+ {staff.position}
+
+ {staff.completedShifts}/{staff.totalShifts}
+
+
+ = 90 ? "bg-green-500" :
+ staff.fillRate >= 75 ? "bg-blue-500" : "bg-amber-500"
+ }>
+ {staff.fillRate}%
+
+
+
+ {staff.reliability}%
+
+
+ {staff.rating}/5
+
+
+ ))}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/reports/StaffingCostReport.jsx b/frontend-web-free/src/components/reports/StaffingCostReport.jsx
new file mode 100644
index 00000000..5c22940b
--- /dev/null
+++ b/frontend-web-free/src/components/reports/StaffingCostReport.jsx
@@ -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 (
+
+
+
+
Staffing Costs & Budget Adherence
+
Track spending and budget compliance
+
+
+
+
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+
+
Total Spent
+
${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+
+
+
+
+
+
+
+
+
+
+
+
Budget
+
${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+
+
+
+
+
+
+
+
+
+
+
+
Budget Adherence
+
{adherence}%
+
+ {adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
+
+
+
+
+
+
+
+
+ {/* Monthly Cost Trend */}
+
+
+ Monthly Cost Trend
+
+
+
+
+
+
+
+ `$${value.toLocaleString()}`} />
+
+
+
+
+
+
+
+
+ {/* Department Breakdown */}
+
+
+
+ Costs by Department
+
+
+
+
+ `${name}: ${(percent * 100).toFixed(0)}%`}
+ outerRadius={80}
+ fill="#8884d8"
+ dataKey="value"
+ >
+ {departmentData.map((entry, index) => (
+ |
+ ))}
+
+ `$${value.toLocaleString()}`} />
+
+
+
+
+
+
+
+ Department Spending
+
+
+
+ {departmentData.slice(0, 5).map((dept, idx) => (
+
+ {dept.name}
+ ${dept.value.toLocaleString()}
+
+ ))}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/AutomationEngine.jsx b/frontend-web-free/src/components/scheduling/AutomationEngine.jsx
new file mode 100644
index 00000000..ba6267a4
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/AutomationEngine.jsx
@@ -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;
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/ConflictDetection.jsx b/frontend-web-free/src/components/scheduling/ConflictDetection.jsx
new file mode 100644
index 00000000..fff0b043
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/ConflictDetection.jsx
@@ -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
;
+ case 'medium': return
;
+ case 'low': return
;
+ default: return
;
+ }
+ };
+
+ const getConflictIcon = (type) => {
+ switch (type) {
+ case 'staff_overlap': return
;
+ case 'venue_overlap': return
;
+ case 'time_buffer': return
;
+ default: return null;
+ }
+ };
+
+ return (
+
+ {conflicts.map((conflict, idx) => (
+
+
+
+ {getSeverityIcon(conflict.severity)}
+
+
+
+ {getConflictIcon(conflict.conflict_type)}
+
+ {conflict.conflict_type.replace('_', ' ')}
+
+
+ {conflict.severity.toUpperCase()}
+
+
+
+ {conflict.description}
+
+ {conflict.buffer_required && (
+
+ Buffer required: {conflict.buffer_required}
+
+ )}
+
+ {onDismiss && (
+
+ )}
+
+
+ ))}
+
+ );
+}
+
+export default {
+ detectTimeOverlap,
+ detectDateOverlap,
+ detectStaffConflicts,
+ detectVenueConflicts,
+ detectBufferViolations,
+ detectAllConflicts,
+ ConflictAlert,
+};
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/DoubleBookingOverrideDialog.jsx b/frontend-web-free/src/components/scheduling/DoubleBookingOverrideDialog.jsx
new file mode 100644
index 00000000..51826617
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/DoubleBookingOverrideDialog.jsx
@@ -0,0 +1,120 @@
+import React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { AlertTriangle, Clock, Calendar } from "lucide-react";
+import { format } from "date-fns";
+
+export default function DoubleBookingOverrideDialog({
+ open,
+ onClose,
+ conflict,
+ workerName,
+ onConfirm
+}) {
+ if (!conflict) return null;
+
+ const { existingEvent, existingShift, gapMinutes, canOverride } = conflict;
+
+ const existingShiftTime = existingShift?.roles?.[0] || existingShift || {};
+ const existingStart = existingShiftTime.start_time || '00:00';
+ const existingEnd = existingShiftTime.end_time || '23:59';
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/DoubleBookingValidator.jsx b/frontend-web-free/src/components/scheduling/DoubleBookingValidator.jsx
new file mode 100644
index 00000000..4872bad0
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/DoubleBookingValidator.jsx
@@ -0,0 +1,182 @@
+import { parseISO, isSameDay } from "date-fns";
+
+/**
+ * Parses time string (HH:MM or HH:MM AM/PM) into minutes since midnight
+ */
+const parseTimeToMinutes = (timeStr) => {
+ if (!timeStr) return 0;
+
+ try {
+ const cleanTime = timeStr.trim().toUpperCase();
+ let hours, minutes;
+
+ if (cleanTime.includes('AM') || cleanTime.includes('PM')) {
+ const isPM = cleanTime.includes('PM');
+ const timePart = cleanTime.replace(/AM|PM/g, '').trim();
+ [hours, minutes] = timePart.split(':').map(Number);
+
+ if (isPM && hours !== 12) hours += 12;
+ if (!isPM && hours === 12) hours = 0;
+ } else {
+ [hours, minutes] = cleanTime.split(':').map(Number);
+ }
+
+ return (hours * 60) + (minutes || 0);
+ } catch {
+ return 0;
+ }
+};
+
+/**
+ * Checks if a worker is already assigned to an event on a given date
+ */
+export const getWorkerAssignments = (workerId, events, targetDate) => {
+ const targetDateObj = typeof targetDate === 'string' ? parseISO(targetDate) : targetDate;
+
+ return events.filter(event => {
+ if (!event.assigned_staff || event.status === 'Canceled') return false;
+
+ // Check if worker is assigned to this event
+ const isAssigned = event.assigned_staff.some(staff =>
+ staff.staff_id === workerId || staff.id === workerId
+ );
+
+ if (!isAssigned) return false;
+
+ // Check if event is on the same date
+ const eventDate = typeof event.date === 'string' ? parseISO(event.date) : new Date(event.date);
+ return isSameDay(eventDate, targetDateObj);
+ });
+};
+
+/**
+ * Checks if two shifts overlap or violate spacing rules
+ * Returns: { allowed: boolean, needsOverride: boolean, reason: string, gapMinutes: number }
+ */
+export const checkShiftConflict = (shift1, shift2) => {
+ if (!shift1 || !shift2) {
+ return { allowed: true, needsOverride: false, reason: '', gapMinutes: 0 };
+ }
+
+ // Get time ranges from shifts
+ const shift1Start = shift1.roles?.[0]?.start_time || shift1.start_time || '00:00';
+ const shift1End = shift1.roles?.[0]?.end_time || shift1.end_time || '23:59';
+ const shift2Start = shift2.roles?.[0]?.start_time || shift2.start_time || '00:00';
+ const shift2End = shift2.roles?.[0]?.end_time || shift2.end_time || '23:59';
+
+ const s1Start = parseTimeToMinutes(shift1Start);
+ const s1End = parseTimeToMinutes(shift1End);
+ const s2Start = parseTimeToMinutes(shift2Start);
+ const s2End = parseTimeToMinutes(shift2End);
+
+ // Check for direct overlap
+ const overlaps = (s1Start < s2End && s1End > s2Start);
+
+ if (overlaps) {
+ return {
+ allowed: false,
+ needsOverride: false,
+ reason: 'Shifts overlap. This worker is unavailable due to an overlapping shift.',
+ gapMinutes: 0
+ };
+ }
+
+ // Calculate gap between shifts
+ let gapMinutes;
+ if (s1End <= s2Start) {
+ // Shift 1 ends before Shift 2 starts
+ gapMinutes = s2Start - s1End;
+ } else if (s2End <= s1Start) {
+ // Shift 2 ends before Shift 1 starts
+ gapMinutes = s1Start - s2End;
+ } else {
+ gapMinutes = 0;
+ }
+
+ // If gap is more than 1 hour (60 minutes), it's allowed without override
+ if (gapMinutes > 60) {
+ return {
+ allowed: true,
+ needsOverride: false,
+ reason: '',
+ gapMinutes
+ };
+ }
+
+ // If gap is 1 hour or less, vendor can override (double shift scenario)
+ return {
+ allowed: false,
+ needsOverride: true,
+ reason: `This employee is finishing another shift within ${gapMinutes} minutes of this assignment. Vendor can override to assign a double shift.`,
+ gapMinutes
+ };
+};
+
+/**
+ * Validates if a worker can be assigned to a shift
+ * Returns: { valid: boolean, conflict: object | null, message: string }
+ */
+export const validateWorkerAssignment = (workerId, targetEvent, targetShift, allEvents, userRole) => {
+ // Get all assignments for this worker on the target date
+ const existingAssignments = getWorkerAssignments(workerId, allEvents, targetEvent.date);
+
+ // If no existing assignments, allow
+ if (existingAssignments.length === 0) {
+ return { valid: true, conflict: null, message: '' };
+ }
+
+ // Check conflicts with each existing assignment
+ for (const existingEvent of existingAssignments) {
+ // Skip if it's the same event (editing existing assignment)
+ if (existingEvent.id === targetEvent.id) continue;
+
+ // Check each shift in the existing event
+ for (const existingShift of (existingEvent.shifts || [])) {
+ const conflict = checkShiftConflict(existingShift, targetShift);
+
+ if (!conflict.allowed) {
+ if (conflict.needsOverride) {
+ // Vendor can override for double shifts within 1 hour
+ if (userRole === 'vendor') {
+ return {
+ valid: false,
+ conflict: {
+ ...conflict,
+ existingEvent,
+ existingShift,
+ canOverride: true
+ },
+ message: conflict.reason
+ };
+ } else {
+ // Non-vendors cannot override
+ return {
+ valid: false,
+ conflict: {
+ ...conflict,
+ existingEvent,
+ existingShift,
+ canOverride: false
+ },
+ message: 'This worker is unavailable due to an overlapping shift or extended gap. Assigning this employee is not permitted.'
+ };
+ }
+ } else {
+ // Hard conflict - no override allowed
+ return {
+ valid: false,
+ conflict: {
+ ...conflict,
+ existingEvent,
+ existingShift,
+ canOverride: false
+ },
+ message: 'This worker is unavailable due to an overlapping shift or extended gap. Assigning this employee is not permitted.'
+ };
+ }
+ }
+ }
+ }
+
+ return { valid: true, conflict: null, message: '' };
+};
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/DragDropScheduler.jsx b/frontend-web-free/src/components/scheduling/DragDropScheduler.jsx
new file mode 100644
index 00000000..10515c6f
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/DragDropScheduler.jsx
@@ -0,0 +1,343 @@
+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";
+import { validateWorkerAssignment } from "./DoubleBookingValidator";
+import DoubleBookingOverrideDialog from "./DoubleBookingOverrideDialog";
+import { useToast } from "@/components/ui/use-toast";
+import { useQuery } from "@tanstack/react-query";
+import { base44 } from "@/api/base44Client";
+
+/**
+ * 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 [overrideDialog, setOverrideDialog] = useState({ open: false, conflict: null, staffMember: null, eventId: null });
+ const { toast } = useToast();
+
+ const { data: user } = useQuery({
+ queryKey: ['current-user-scheduler'],
+ queryFn: () => base44.auth.me(),
+ });
+
+ const { data: allEvents = [] } = useQuery({
+ queryKey: ['all-events-conflict-check'],
+ queryFn: () => base44.entities.Event.list(),
+ initialData: events,
+ });
+
+ const userRole = user?.user_role || user?.role || 'admin';
+
+ 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);
+ const targetEvent = localEvents.find(e => e.id === eventId);
+
+ if (!staffMember || !targetEvent) return;
+
+ // Validate double booking
+ const targetShift = targetEvent.shifts?.[0] || {};
+ const validation = validateWorkerAssignment(
+ staffMember.id,
+ targetEvent,
+ targetShift,
+ allEvents,
+ userRole
+ );
+
+ if (!validation.valid) {
+ if (validation.conflict?.canOverride) {
+ // Show override dialog for vendors
+ setOverrideDialog({
+ open: true,
+ conflict: validation.conflict,
+ staffMember,
+ eventId
+ });
+ } else {
+ // Hard block
+ toast({
+ title: "❌ Assignment Blocked",
+ description: validation.message,
+ variant: "destructive",
+ });
+ }
+ return;
+ }
+
+ 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;
+ }));
+ }
+ }
+ };
+
+ const handleOverrideConfirm = () => {
+ const { staffMember, eventId } = overrideDialog;
+
+ // Proceed with assignment
+ setLocalStaff(localStaff.filter(s => s.id !== staffMember.id));
+
+ setLocalEvents(localEvents.map(event => {
+ if (event.id === eventId) {
+ return {
+ ...event,
+ assigned_staff: [...(event.assigned_staff || []), { staff_id: staffMember.id, staff_name: staffMember.employee_name }]
+ };
+ }
+ return event;
+ }));
+
+ onAssign(eventId, staffMember);
+ setOverrideDialog({ open: false, conflict: null, staffMember: null, eventId: null });
+
+ toast({
+ title: "✅ Double Shift Assigned",
+ description: `${staffMember.employee_name} has been assigned with vendor override`,
+ });
+ };
+
+ return (
+ <>
+
+
+ {/* Unassigned Staff Pool */}
+
+
+ Available Staff
+ {localStaff.length} unassigned
+
+
+
+ {(provided, snapshot) => (
+
+ {localStaff.map((s, index) => (
+
+ {(provided, snapshot) => (
+
+
+
+
+ {s.employee_name?.charAt(0)}
+
+
+
+
{s.employee_name}
+
{s.position}
+
+
+
+ {s.rating || 4.5}
+
+
+ {s.reliability_score || 95}% reliable
+
+
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+ {localStaff.length === 0 && (
+
All staff assigned
+ )}
+
+ )}
+
+
+
+
+ {/* Events Schedule */}
+
+ {localEvents.map(event => (
+
+
+
+
+
{event.event_name}
+
+
+
+ {format(new Date(event.date), 'MMM d, yyyy')}
+
+
+
+ {event.hub || event.event_location}
+
+
+
+
= (event.requested || 0)
+ ? "bg-green-100 text-green-700"
+ : "bg-amber-100 text-amber-700"
+ }>
+ {event.assigned_staff?.length || 0}/{event.requested || 0} filled
+
+
+
+
+
+ {(provided, snapshot) => (
+
+
+ {event.assigned_staff?.map((s, index) => (
+
+ {(provided, snapshot) => (
+
+
+
+
+ {s.staff_name?.charAt(0)}
+
+
+
+
{s.staff_name}
+
{s.role}
+
+
+
+ )}
+
+ ))}
+
+ {provided.placeholder}
+ {(!event.assigned_staff || event.assigned_staff.length === 0) && (
+
+ Drag staff here to assign
+
+ )}
+
+ )}
+
+
+
+ ))}
+
+
+
+
+
setOverrideDialog({ open: false, conflict: null, staffMember: null, eventId: null })}
+ conflict={overrideDialog.conflict}
+ workerName={overrideDialog.staffMember?.employee_name || ''}
+ onConfirm={handleOverrideConfirm}
+ />
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/OvertimeCalculator.jsx b/frontend-web-free/src/components/scheduling/OvertimeCalculator.jsx
new file mode 100644
index 00000000..6173be0c
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/OvertimeCalculator.jsx
@@ -0,0 +1,221 @@
+/**
+ * Overtime & Double Time Calculator
+ * Calculates OT/DT exposure based on state regulations
+ */
+
+// State-specific OT/DT rules
+const STATE_RULES = {
+ CA: {
+ dailyOT: 8, // OT after 8 hours/day
+ dailyDT: 12, // DT after 12 hours/day
+ weeklyOT: 40, // OT after 40 hours/week
+ seventhDayDT: true, // 7th consecutive day = DT
+ otRate: 1.5,
+ dtRate: 2.0,
+ },
+ DEFAULT: {
+ dailyOT: null, // No daily OT in most states
+ dailyDT: null,
+ weeklyOT: 40,
+ seventhDayDT: false,
+ otRate: 1.5,
+ dtRate: 2.0,
+ }
+};
+
+export function getStateRules(state) {
+ return STATE_RULES[state] || STATE_RULES.DEFAULT;
+}
+
+/**
+ * Calculate OT status for a worker considering a shift
+ * @param {Object} worker - Worker with current hours
+ * @param {Object} shift - Shift to assign
+ * @param {Array} allEvents - All events to check existing assignments
+ * @returns {Object} OT analysis
+ */
+export function calculateOTStatus(worker, shift, allEvents = []) {
+ const state = shift.state || worker.state || "DEFAULT";
+ const rules = getStateRules(state);
+
+ // Get shift duration
+ const shiftHours = calculateShiftHours(shift);
+
+ // Calculate current hours from existing assignments
+ const currentHours = calculateWorkerCurrentHours(worker, allEvents, shift.date);
+
+ // Project new hours
+ const projectedDayHours = currentHours.currentDayHours + shiftHours;
+ const projectedWeekHours = currentHours.currentWeekHours + shiftHours;
+
+ // Calculate OT/DT
+ let otHours = 0;
+ let dtHours = 0;
+ let status = "GREEN";
+ let summary = "No OT or DT triggered";
+ let costImpact = 0;
+
+ // Daily OT/DT (CA-specific)
+ if (rules.dailyOT && projectedDayHours > rules.dailyOT) {
+ if (rules.dailyDT && projectedDayHours > rules.dailyDT) {
+ // Some hours are DT
+ dtHours = projectedDayHours - rules.dailyDT;
+ otHours = rules.dailyDT - rules.dailyOT;
+ status = "RED";
+ summary = `Triggers ${otHours.toFixed(1)}h OT + ${dtHours.toFixed(1)}h DT (${state})`;
+ } else {
+ // Only OT, no DT
+ otHours = projectedDayHours - rules.dailyOT;
+ status = projectedDayHours >= rules.dailyDT - 1 ? "AMBER" : "AMBER";
+ summary = `Triggers ${otHours.toFixed(1)}h OT (${state})`;
+ }
+ }
+
+ // Weekly OT
+ if (rules.weeklyOT && projectedWeekHours > rules.weeklyOT && !otHours) {
+ otHours = projectedWeekHours - rules.weeklyOT;
+ status = "AMBER";
+ summary = `Triggers ${otHours.toFixed(1)}h weekly OT`;
+ }
+
+ // Near thresholds (warning zone)
+ if (status === "GREEN") {
+ if (rules.dailyOT && projectedDayHours >= rules.dailyOT - 1) {
+ status = "AMBER";
+ summary = `Near daily OT threshold (${projectedDayHours.toFixed(1)}h)`;
+ } else if (rules.weeklyOT && projectedWeekHours >= rules.weeklyOT - 4) {
+ status = "AMBER";
+ summary = `Approaching weekly OT (${projectedWeekHours.toFixed(1)}h)`;
+ } else {
+ summary = `Safe · No OT (${projectedDayHours.toFixed(1)}h day, ${projectedWeekHours.toFixed(1)}h week)`;
+ }
+ }
+
+ // Calculate cost impact
+ const baseRate = worker.hourly_rate || shift.rate_per_hour || 20;
+ const baseCost = shiftHours * baseRate;
+ const otCost = otHours * baseRate * rules.otRate;
+ const dtCost = dtHours * baseRate * rules.dtRate;
+ costImpact = otCost + dtCost;
+
+ return {
+ status,
+ summary,
+ currentDayHours: currentHours.currentDayHours,
+ currentWeekHours: currentHours.currentWeekHours,
+ projectedDayHours,
+ projectedWeekHours,
+ otHours,
+ dtHours,
+ baseCost,
+ costImpact,
+ totalCost: baseCost + costImpact,
+ rulePattern: `${state}_${rules.dailyOT ? 'DAILY' : 'WEEKLY'}_OT`,
+ canAssign: true, // Always allow but warn
+ requiresApproval: status === "RED",
+ };
+}
+
+/**
+ * Calculate shift duration in hours
+ */
+function calculateShiftHours(shift) {
+ if (shift.hours) return shift.hours;
+
+ // Try to parse from start/end times
+ if (shift.start_time && shift.end_time) {
+ const [startH, startM] = shift.start_time.split(':').map(Number);
+ const [endH, endM] = shift.end_time.split(':').map(Number);
+ const startMins = startH * 60 + startM;
+ const endMins = endH * 60 + endM;
+ const duration = (endMins - startMins) / 60;
+ return duration > 0 ? duration : duration + 24; // Handle overnight
+ }
+
+ return 8; // Default 8-hour shift
+}
+
+/**
+ * Calculate worker's current hours for the day and week
+ */
+function calculateWorkerCurrentHours(worker, allEvents, shiftDate) {
+ let currentDayHours = 0;
+ let currentWeekHours = 0;
+
+ if (!allEvents || !shiftDate) {
+ return {
+ currentDayHours: worker.current_day_hours || 0,
+ currentWeekHours: worker.current_week_hours || 0,
+ };
+ }
+
+ const shiftDateObj = new Date(shiftDate);
+ const shiftDay = shiftDateObj.getDay();
+
+ // Get start of week (Sunday)
+ const weekStart = new Date(shiftDateObj);
+ weekStart.setDate(shiftDateObj.getDate() - shiftDay);
+ weekStart.setHours(0, 0, 0, 0);
+
+ // Count hours from existing assignments
+ allEvents.forEach(event => {
+ if (event.status === "Canceled" || event.status === "Completed") return;
+
+ const isAssigned = event.assigned_staff?.some(s => s.staff_id === worker.id);
+ if (!isAssigned) return;
+
+ const eventDate = new Date(event.date);
+
+ // Same day hours
+ if (eventDate.toDateString() === shiftDateObj.toDateString()) {
+ (event.shifts || []).forEach(shift => {
+ (shift.roles || []).forEach(role => {
+ if (event.assigned_staff?.some(s => s.staff_id === worker.id && s.role === role.role)) {
+ currentDayHours += role.hours || 8;
+ }
+ });
+ });
+ }
+
+ // Same week hours
+ if (eventDate >= weekStart && eventDate <= shiftDateObj) {
+ (event.shifts || []).forEach(shift => {
+ (shift.roles || []).forEach(role => {
+ if (event.assigned_staff?.some(s => s.staff_id === worker.id && s.role === role.role)) {
+ currentWeekHours += role.hours || 8;
+ }
+ });
+ });
+ }
+ });
+
+ return { currentDayHours, currentWeekHours };
+}
+
+/**
+ * Get OT badge component props
+ */
+export function getOTBadgeProps(status) {
+ switch (status) {
+ case "GREEN":
+ return {
+ className: "bg-emerald-500 text-white",
+ label: "Safe · No OT"
+ };
+ case "AMBER":
+ return {
+ className: "bg-amber-500 text-white",
+ label: "Near OT"
+ };
+ case "RED":
+ return {
+ className: "bg-red-500 text-white",
+ label: "OT/DT Risk"
+ };
+ default:
+ return {
+ className: "bg-slate-500 text-white",
+ label: "Unknown"
+ };
+ }
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/SmartAssignmentEngine.jsx b/frontend-web-free/src/components/scheduling/SmartAssignmentEngine.jsx
new file mode 100644
index 00000000..b1538bdc
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/SmartAssignmentEngine.jsx
@@ -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,
+};
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/TalentRadar.jsx b/frontend-web-free/src/components/scheduling/TalentRadar.jsx
new file mode 100644
index 00000000..1839c893
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/TalentRadar.jsx
@@ -0,0 +1,218 @@
+import React, { useMemo } 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 { Users, TrendingUp, UserPlus, AlertCircle, CheckCircle } from "lucide-react";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+
+export default function TalentRadar({ event, allStaff, availabilityData, onAssign }) {
+ // Calculate three intelligent buckets
+ const buckets = useMemo(() => {
+ const readyNow = [];
+ const likelyAvailable = [];
+ const needsWork = [];
+
+ allStaff.forEach(staff => {
+ const availability = availabilityData.find(a => a.staff_id === staff.id);
+
+ // Skip if already assigned to this event
+ if (event.assigned_staff?.some(a => a.staff_id === staff.id)) return;
+
+ // Skip if blocked
+ if (availability?.availability_status === 'BLOCKED') return;
+
+ // Calculate match score
+ const matchScore = calculateMatchScore(staff, event, availability);
+
+ // Ready Now: Confirmed available + good match
+ if (availability?.availability_status === 'CONFIRMED_AVAILABLE' && matchScore >= 70) {
+ readyNow.push({ staff, availability, matchScore });
+ }
+ // Likely Available: Unknown but high prediction score
+ else if (
+ availability?.availability_status === 'UNKNOWN' &&
+ availability?.predicted_availability_score >= 70
+ ) {
+ likelyAvailable.push({ staff, availability, matchScore });
+ }
+ // Needs Work: High need index
+ if (availability && availability.need_work_index >= 60) {
+ needsWork.push({ staff, availability, matchScore });
+ }
+ });
+
+ // Sort by match score and need
+ readyNow.sort((a, b) => {
+ const scoreA = a.matchScore + (a.availability?.need_work_index || 0) * 0.3;
+ const scoreB = b.matchScore + (b.availability?.need_work_index || 0) * 0.3;
+ return scoreB - scoreA;
+ });
+
+ likelyAvailable.sort((a, b) => b.matchScore - a.matchScore);
+ needsWork.sort((a, b) => b.availability.need_work_index - a.availability.need_work_index);
+
+ return { readyNow, likelyAvailable, needsWork };
+ }, [allStaff, availabilityData, event]);
+
+ const renderWorkerCard = (item, bucket) => {
+ const { staff, availability, matchScore } = item;
+
+ return (
+
+
+
+
+
+
+ {staff.employee_name?.charAt(0)?.toUpperCase()}
+
+
+
+
{staff.employee_name}
+
{staff.position || 'Staff'}
+
+
+
+ {matchScore}% match
+
+ {availability?.scheduled_hours_this_period > 0 && (
+
+ {availability.scheduled_hours_this_period}h scheduled
+
+ )}
+ {bucket === 'needs' && (
+
+ Needs work
+
+ )}
+ {bucket === 'likely' && (
+
+ {availability?.predicted_availability_score}% likely
+
+ )}
+
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
Talent Radar
+
Smart worker recommendations for this shift
+
+
+
+
+
+
+
+ Ready Now ({buckets.readyNow.length})
+
+
+
+ Likely Available ({buckets.likelyAvailable.length})
+
+
+
+ Needs Work ({buckets.needsWork.length})
+
+
+
+
+
+
+
+ Ready Now: These workers have confirmed their availability and are the best match for this shift.
+
+
+
+ {buckets.readyNow.length === 0 ? (
+ No workers confirmed available
+ ) : (
+
+ {buckets.readyNow.map(item => renderWorkerCard(item, 'ready'))}
+
+ )}
+
+
+
+
+
+
+ Likely Available: These workers haven't confirmed availability but historically accept similar shifts. Assignment requires worker confirmation.
+
+
+
+ {buckets.likelyAvailable.length === 0 ? (
+ No predictions available
+ ) : (
+
+ {buckets.likelyAvailable.map(item => renderWorkerCard(item, 'likely'))}
+
+ )}
+
+
+
+
+
+
+ Needs Work: These workers are under-scheduled and could benefit from additional hours. They may accept even if not explicitly available.
+
+
+
+ {buckets.needsWork.length === 0 ? (
+ No under-utilized workers
+ ) : (
+
+ {buckets.needsWork.map(item => renderWorkerCard(item, 'needs'))}
+
+ )}
+
+
+
+ );
+}
+
+// Helper function to calculate match score
+function calculateMatchScore(staff, event, availability) {
+ let score = 50; // Base score
+
+ // Skill match
+ const eventRole = event.shifts?.[0]?.roles?.[0]?.role;
+ if (eventRole && staff.position === eventRole) {
+ score += 20;
+ } else if (eventRole && staff.position_2 === eventRole) {
+ score += 10;
+ }
+
+ // Reliability
+ if (staff.reliability_score) {
+ score += (staff.reliability_score / 100) * 15;
+ }
+
+ // Compliance
+ if (staff.background_check_status === 'cleared') {
+ score += 10;
+ }
+
+ // Acceptance rate
+ if (availability?.acceptance_rate) {
+ score += (availability.acceptance_rate / 100) * 5;
+ }
+
+ return Math.min(100, Math.round(score));
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/scheduling/WorkerInfoCard.jsx b/frontend-web-free/src/components/scheduling/WorkerInfoCard.jsx
new file mode 100644
index 00000000..adc95e11
--- /dev/null
+++ b/frontend-web-free/src/components/scheduling/WorkerInfoCard.jsx
@@ -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 (
+
+
+ {trigger}
+
+
+
+ {/* Header */}
+
+
+
+ {staff.employee_name?.charAt(0)}
+
+
+
+
{staff.employee_name}
+
{staff.position}
+ {staff.position_2 && (
+
Also: {staff.position_2}
+ )}
+
+
+
+ {/* Rating & Reliability */}
+
+
+
+
+
Rating
+
{staff.rating || 4.5} ★
+
+
+
+
+
+
Reliability
+
+ {staff.reliability_score || 90}%
+
+
+
+
+
+ {/* Experience & History */}
+
+
+
+
+ {staff.total_shifts || 0} shifts completed
+
+
+
+
+ {staff.hub_location || staff.city || "Unknown location"}
+
+ {staff.certifications && staff.certifications.length > 0 && (
+
+
+
+ {staff.certifications.length} certification{staff.certifications.length > 1 ? 's' : ''}
+
+
+ )}
+
+
+ {/* Certifications */}
+ {staff.certifications && staff.certifications.length > 0 && (
+
+
Certifications
+
+ {staff.certifications.slice(0, 3).map((cert, idx) => (
+
+ {cert.name || cert.cert_name}
+
+ ))}
+
+
+ )}
+
+ {/* Performance Indicators */}
+
+
+
On-Time
+
+ {staff.shift_coverage_percentage || 95}%
+
+
+
+
No-Shows
+
+ {staff.no_show_count || 0}
+
+
+
+
Cancels
+
+ {staff.cancellation_count || 0}
+
+
+
+
+ {/* Warnings */}
+ {staff.background_check_status !== 'cleared' && (
+
+
+
Background check pending
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/staff/EmployeeCard.jsx b/frontend-web-free/src/components/staff/EmployeeCard.jsx
new file mode 100644
index 00000000..835fe0f2
--- /dev/null
+++ b/frontend-web-free/src/components/staff/EmployeeCard.jsx
@@ -0,0 +1,273 @@
+import React from "react";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Mail, Phone, MapPin, Calendar, Edit, User,
+ Star, TrendingUp, XCircle, CheckCircle, Home, UserX
+} from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { createPageUrl } from "@/utils";
+import { format } from "date-fns";
+
+const getInitials = (name) => {
+ if (!name) return "?";
+ return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
+};
+
+const renderStars = (rating) => {
+ const stars = [];
+ const fullStars = Math.floor(rating || 0);
+
+ for (let i = 0; i < 5; i++) {
+ if (i < fullStars) {
+ stars.push();
+ } else {
+ stars.push();
+ }
+ }
+ return stars;
+};
+
+const getReliabilityColor = (score) => {
+ if (score >= 90) return { bg: 'bg-green-500', text: 'text-green-700', bgLight: 'bg-green-50' };
+ if (score >= 70) return { bg: 'bg-yellow-500', text: 'text-yellow-700', bgLight: 'bg-yellow-50' };
+ if (score >= 50) return { bg: 'bg-orange-500', text: 'text-orange-700', bgLight: 'bg-orange-50' };
+ return { bg: 'bg-red-500', text: 'text-red-700', bgLight: 'bg-red-50' };
+};
+
+const calculateReliability = (staff) => {
+ const coverageScore = staff.shift_coverage_percentage || 0;
+ const cancellationPenalty = (staff.cancellation_count || 0) * 5;
+ const ratingBonus = ((staff.rating || 0) / 5) * 20;
+
+ let reliability = coverageScore + ratingBonus - cancellationPenalty;
+ reliability = Math.max(0, Math.min(100, reliability));
+
+ return Math.round(reliability);
+};
+
+export default function EmployeeCard({ staff }) {
+ const navigate = useNavigate();
+
+ const coveragePercentage = staff.shift_coverage_percentage || 0;
+ const cancellationCount = staff.cancellation_count || 0;
+ const noShowCount = staff.no_show_count || 0;
+ const rating = staff.rating || 0;
+ const reliabilityScore = staff.reliability_score || calculateReliability(staff);
+ const reliabilityColors = getReliabilityColor(reliabilityScore);
+
+ return (
+
+
+ {/* Header: Name + Position + Edit */}
+
+
+
+ {staff.initial || getInitials(staff.employee_name)}
+
+
+
+ {staff.employee_name}
+
+
{staff.position || 'Staff'}
+
+
+
+
+
+ {/* Rating */}
+
+ {renderStars(rating)}
+ ({rating.toFixed(1)})
+
+
+ {/* Reliability Bar */}
+
+
+ Reliability Score
+ {reliabilityScore}%
+
+
+
+
+ {/* Metrics Grid: Coverage, Cancellations, No Shows */}
+
+
= 90 ? 'bg-green-50' :
+ coveragePercentage >= 70 ? 'bg-yellow-50' :
+ 'bg-red-50'
+ }`}>
+
= 90 ? 'text-green-600' :
+ coveragePercentage >= 70 ? 'text-yellow-600' :
+ 'text-red-600'
+ }`} />
+ = 90 ? 'text-green-700' :
+ coveragePercentage >= 70 ? 'text-yellow-700' :
+ 'text-red-700'
+ }`}>
+ {coveragePercentage}%
+
+ Coverage
+
+
+
+
+
+ {cancellationCount}
+
+
Cancels
+
+
+
+
+
+ {noShowCount}
+
+
No Shows
+
+
+
+ {/* Position Badges (removed "Skills" label) */}
+ {(staff.position || staff.position_2) && (
+
+ {staff.position && (
+
+ {staff.position}
+
+ )}
+ {staff.position_2 && (
+
+ {staff.position_2}
+
+ )}
+
+ )}
+
+ {/* English Level & Profile Type */}
+
+ {staff.profile_type && (
+
+ {staff.profile_type}
+
+ )}
+ {staff.english && (
+
+ {staff.english}
+
+ )}
+ {staff.invoiced && (
+
+
+ Invoiced
+
+ )}
+
+
+ {/* Contact Information */}
+
+ {staff.manager && (
+
+
+ Manager:
+ {staff.manager}
+
+ )}
+
+ {staff.contact_number && (
+
+
+
{staff.contact_number}
+
+ )}
+
+ {staff.email && (
+
+
+ {staff.email}
+
+ )}
+
+ {staff.address && (
+
+
+ {staff.address}
+
+ )}
+
+ {staff.city && !staff.address && (
+
+
+ {staff.city}
+
+ )}
+
+ {staff.hub_location && (
+
+
+ {staff.hub_location}
+
+ )}
+
+ {staff.check_in && (
+
+
+ Last Check-in:
+ {format(new Date(staff.check_in), "MMM d, yyyy")}
+
+ )}
+
+
+ {/* Schedule */}
+ {staff.schedule_days && (
+
+
Schedule
+
{staff.schedule_days}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/staff/FilterBar.jsx b/frontend-web-free/src/components/staff/FilterBar.jsx
new file mode 100644
index 00000000..6e69cb62
--- /dev/null
+++ b/frontend-web-free/src/components/staff/FilterBar.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Search, Filter } from "lucide-react";
+
+export default function FilterBar({ searchTerm, setSearchTerm, departmentFilter, setDepartmentFilter, locationFilter, setLocationFilter, locations }) {
+ return (
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-10 border-slate-200 focus:border-blue-500 transition-colors"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/staff/StaffCard.jsx b/frontend-web-free/src/components/staff/StaffCard.jsx
new file mode 100644
index 00000000..ad2a1cfe
--- /dev/null
+++ b/frontend-web-free/src/components/staff/StaffCard.jsx
@@ -0,0 +1,179 @@
+import React from "react";
+import { Card, CardHeader, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Mail, Phone, MapPin, Calendar, Edit, Building2, Navigation, Route, Star, TrendingUp, XCircle } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { createPageUrl } from "@/utils";
+import { format } from "date-fns";
+
+const departmentColors = {
+ Operations: "bg-[#0A39DF]/10 text-[#0A39DF] border-[#0A39DF]/30",
+ Sales: "bg-emerald-100 text-emerald-800 border-emerald-300",
+ HR: "bg-[#C8DBDC]/40 text-[#1C323E] border-[#C8DBDC]",
+ Finance: "bg-purple-100 text-purple-800 border-purple-300",
+ IT: "bg-[#1C323E]/10 text-[#1C323E] border-[#1C323E]/30",
+ Marketing: "bg-pink-100 text-pink-800 border-pink-300",
+ "Customer Service": "bg-blue-100 text-blue-800 border-blue-300",
+ Logistics: "bg-amber-100 text-amber-800 border-amber-300"
+};
+
+export default function StaffCard({ staff }) {
+ const navigate = useNavigate();
+
+ const getInitials = (name) => {
+ if (!name) return "?";
+ return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
+ };
+
+ const renderStars = (rating) => {
+ const stars = [];
+ const fullStars = Math.floor(rating || 0);
+ const hasHalfStar = (rating || 0) % 1 >= 0.5;
+
+ for (let i = 0; i < 5; i++) {
+ if (i < fullStars) {
+ stars.push();
+ } else if (i === fullStars && hasHalfStar) {
+ stars.push();
+ } else {
+ stars.push();
+ }
+ }
+ return stars;
+ };
+
+ const coveragePercentage = staff.shift_coverage_percentage || 0;
+ const cancellationCount = staff.cancellation_count || 0;
+ const rating = staff.rating || 0;
+
+ const getCoverageColor = (percentage) => {
+ if (percentage >= 90) return "text-green-600 bg-green-50";
+ if (percentage >= 70) return "text-yellow-600 bg-yellow-50";
+ return "text-red-600 bg-red-50";
+ };
+
+ return (
+
+
+
+
+
+ {staff.initial || getInitials(staff.employee_name)}
+
+
+
+ {staff.employee_name}
+
+
{staff.position}
+
+ {/* Rating */}
+
+ {renderStars(rating)}
+ ({rating.toFixed(1)})
+
+
+
+
+
+
+
+
+ {/* Performance Metrics */}
+
+
+
+
+ Coverage
+
+
{coveragePercentage}%
+
+
+
5 ? 'bg-red-50 text-red-600' : cancellationCount > 2 ? 'bg-yellow-50 text-yellow-600' : 'bg-green-50 text-green-600'}`}>
+
+
+ Cancellations
+
+
{cancellationCount}
+
+
+
+
+ {staff.department && (
+
+
+ {staff.department}
+
+ )}
+ {staff.english && (
+
+ English: {staff.english}
+
+ )}
+ {staff.invoiced && (
+
+ Invoiced
+
+ )}
+
+
+
+ {staff.manager && (
+
+
+ Manager:
+ {staff.manager}
+
+ )}
+ {staff.contact_number && (
+
+
+
{staff.contact_number}
+
+ )}
+ {staff.hub_location && (
+
+
+ {staff.hub_location}
+
+ )}
+ {staff.event_location && (
+
+
+ Event:
+ {staff.event_location}
+
+ )}
+ {staff.track && (
+
+
+ Track:
+ {staff.track}
+
+ )}
+ {staff.check_in && (
+
+
+ Last Check-in:
+ {format(new Date(staff.check_in), "MMM d, yyyy")}
+
+ )}
+
+
+ {staff.schedule_days && (
+
+
Schedule
+
{staff.schedule_days}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/staff/StaffForm.jsx b/frontend-web-free/src/components/staff/StaffForm.jsx
new file mode 100644
index 00000000..26c0608f
--- /dev/null
+++ b/frontend-web-free/src/components/staff/StaffForm.jsx
@@ -0,0 +1,536 @@
+
+import React, { useState, useEffect } from "react";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Save, Loader2 } from "lucide-react";
+
+export default function StaffForm({ staff, onSubmit, isSubmitting }) {
+ const [formData, setFormData] = useState(staff || {
+ employee_name: "",
+ manager: "",
+ contact_number: "",
+ phone: "",
+ email: "", // Added email field
+ department: "",
+ hub_location: "",
+ event_location: "",
+ track: "",
+ address: "",
+ city: "",
+ position: "",
+ position_2: "",
+ initial: "",
+ profile_type: "",
+ employment_type: "",
+ english: "",
+ english_required: false,
+ check_in: "",
+ replaced_by: "",
+ ro: "",
+ mon: "",
+ schedule_days: "",
+ invoiced: false,
+ action: "",
+ notes: "",
+ accounting_comments: "",
+ rating: 0,
+ shift_coverage_percentage: 100,
+ cancellation_count: 0,
+ no_show_count: 0, // Added no_show_count field
+ total_shifts: 0,
+ reliability_score: 100
+ });
+
+ useEffect(() => {
+ if (staff) {
+ setFormData(staff);
+ }
+ }, [staff]);
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit(formData);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend-web-free/src/components/staff/StatsCard.jsx b/frontend-web-free/src/components/staff/StatsCard.jsx
new file mode 100644
index 00000000..e6dd4a4e
--- /dev/null
+++ b/frontend-web-free/src/components/staff/StatsCard.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { Card, CardHeader, CardContent } from "@/components/ui/card";
+import { TrendingUp } from "lucide-react";
+
+export default function StatsCard({ title, value, icon: Icon, gradient, change, textColor = "text-white" }) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {value}
+
+
+
+
+
+
+ {change && (
+
+
+
+ {change}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/tasks/TaskCard.jsx b/frontend-web-free/src/components/tasks/TaskCard.jsx
new file mode 100644
index 00000000..44b876ea
--- /dev/null
+++ b/frontend-web-free/src/components/tasks/TaskCard.jsx
@@ -0,0 +1,116 @@
+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, itemHeight = "normal", conditionalColoring = true }) {
+ const heightClasses = {
+ compact: "p-2",
+ normal: "p-4",
+ comfortable: "p-5"
+ };
+
+ const cardHeight = heightClasses[itemHeight] || heightClasses.normal;
+ const priority = priorityConfig[task.priority] || priorityConfig.normal;
+
+ const priorityBorder = conditionalColoring && task.priority === 'high' ? 'border-l-4 border-l-red-500' : '';
+ const priorityBg = conditionalColoring && task.priority === 'high' ? 'bg-red-50/50' : 'bg-white';
+
+ return (
+
+
+ {/* Header */}
+
+
{task.task_name}
+
+
+
+ {/* Priority & Date */}
+
+
+ {priority.label}
+
+ {task.due_date && (
+
+
+ {format(new Date(task.due_date), 'd MMM')}
+
+ )}
+
+
+ {/* Progress Bar */}
+
+
+
+
{task.progress || 0}%
+
+
+
+ {/* Footer */}
+
+ {/* Assigned Members */}
+
+ {(task.assigned_members || []).slice(0, 3).map((member, idx) => (
+
+
+
+ ))}
+ {(task.assigned_members?.length || 0) > 3 && (
+
+ +{task.assigned_members.length - 3}
+
+ )}
+
+
+ {/* Stats */}
+
+ {(task.attachment_count || 0) > 0 && (
+
+
+
{task.attachment_count}
+
+ )}
+ {(task.comment_count || 0) > 0 && (
+
+
+ {task.comment_count}
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/tasks/TaskColumn.jsx b/frontend-web-free/src/components/tasks/TaskColumn.jsx
new file mode 100644
index 00000000..9fedd525
--- /dev/null
+++ b/frontend-web-free/src/components/tasks/TaskColumn.jsx
@@ -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 (
+
+ {/* Column Header */}
+
+
+ {config.label}
+
+ {tasks.length}
+
+
+
+
+
+
+
+
+ {/* Droppable Area */}
+
+ {(provided, snapshot) => (
+
+ {children}
+ {provided.placeholder}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/tasks/TaskDetailModal.jsx b/frontend-web-free/src/components/tasks/TaskDetailModal.jsx
new file mode 100644
index 00000000..774db343
--- /dev/null
+++ b/frontend-web-free/src/components/tasks/TaskDetailModal.jsx
@@ -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 (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/components/ui/accordion.jsx b/frontend-web-free/src/components/ui/accordion.jsx
new file mode 100644
index 00000000..1c4416a4
--- /dev/null
+++ b/frontend-web-free/src/components/ui/accordion.jsx
@@ -0,0 +1,41 @@
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}>
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/frontend-web-free/src/components/ui/alert-dialog.jsx b/frontend-web-free/src/components/ui/alert-dialog.jsx
new file mode 100644
index 00000000..a4174f36
--- /dev/null
+++ b/frontend-web-free/src/components/ui/alert-dialog.jsx
@@ -0,0 +1,97 @@
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/frontend-web-free/src/components/ui/alert.jsx b/frontend-web-free/src/components/ui/alert.jsx
new file mode 100644
index 00000000..28597e84
--- /dev/null
+++ b/frontend-web-free/src/components/ui/alert.jsx
@@ -0,0 +1,47 @@
+import * as React from "react"
+import { cva } from "class-variance-authority";
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/frontend-web-free/src/components/ui/aspect-ratio.jsx b/frontend-web-free/src/components/ui/aspect-ratio.jsx
new file mode 100644
index 00000000..c4abbf37
--- /dev/null
+++ b/frontend-web-free/src/components/ui/aspect-ratio.jsx
@@ -0,0 +1,5 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+const AspectRatio = AspectRatioPrimitive.Root
+
+export { AspectRatio }
diff --git a/frontend-web-free/src/components/ui/avatar.jsx b/frontend-web-free/src/components/ui/avatar.jsx
new file mode 100644
index 00000000..49203243
--- /dev/null
+++ b/frontend-web-free/src/components/ui/avatar.jsx
@@ -0,0 +1,35 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/frontend-web-free/src/components/ui/badge.jsx b/frontend-web-free/src/components/ui/badge.jsx
new file mode 100644
index 00000000..a687ebad
--- /dev/null
+++ b/frontend-web-free/src/components/ui/badge.jsx
@@ -0,0 +1,34 @@
+import * as React from "react"
+import { cva } from "class-variance-authority";
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ ...props
+}) {
+ return ();
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend-web-free/src/components/ui/breadcrumb.jsx b/frontend-web-free/src/components/ui/breadcrumb.jsx
new file mode 100644
index 00000000..4d782a4a
--- /dev/null
+++ b/frontend-web-free/src/components/ui/breadcrumb.jsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Breadcrumb = React.forwardRef(
+ ({ ...props }, ref) =>
+)
+Breadcrumb.displayName = "Breadcrumb"
+
+const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+BreadcrumbList.displayName = "BreadcrumbList"
+
+const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+BreadcrumbItem.displayName = "BreadcrumbItem"
+
+const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+ ()
+ );
+})
+BreadcrumbLink.displayName = "BreadcrumbLink"
+
+const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+BreadcrumbPage.displayName = "BreadcrumbPage"
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}) => (
+ svg]:w-3.5 [&>svg]:h-3.5", className)}
+ {...props}>
+ {children ?? }
+
+)
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}) => (
+
+
+ More
+
+)
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/frontend-web-free/src/components/ui/button.jsx b/frontend-web-free/src/components/ui/button.jsx
new file mode 100644
index 00000000..bf3d2ef5
--- /dev/null
+++ b/frontend-web-free/src/components/ui/button.jsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva } from "class-variance-authority";
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+ ()
+ );
+})
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/frontend-web-free/src/components/ui/calendar.jsx b/frontend-web-free/src/components/ui/calendar.jsx
new file mode 100644
index 00000000..1b63f270
--- /dev/null
+++ b/frontend-web-free/src/components/ui/calendar.jsx
@@ -0,0 +1,71 @@
+import * as React from "react"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}) {
+ return (
+ (.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md"
+ ),
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "h-8 w-8 p-0 font-normal aria-selected:opacity-100"
+ ),
+ day_range_start: "day-range-start",
+ day_range_end: "day-range-end",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: ({ className, ...props }) => (
+
+ ),
+ IconRight: ({ className, ...props }) => (
+
+ ),
+ }}
+ {...props} />)
+ );
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/frontend-web-free/src/components/ui/card.jsx b/frontend-web-free/src/components/ui/card.jsx
new file mode 100644
index 00000000..2985cca8
--- /dev/null
+++ b/frontend-web-free/src/components/ui/card.jsx
@@ -0,0 +1,50 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/frontend-web-free/src/components/ui/carousel.jsx b/frontend-web-free/src/components/ui/carousel.jsx
new file mode 100644
index 00000000..99ea00e2
--- /dev/null
+++ b/frontend-web-free/src/components/ui/carousel.jsx
@@ -0,0 +1,193 @@
+import * as React from "react"
+import useEmblaCarousel from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+const Carousel = React.forwardRef((
+ {
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+ },
+ ref
+) => {
+ const [carouselRef, api] = useEmblaCarousel({
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ }, plugins)
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api) => {
+ if (!api) {
+ return
+ }
+
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback((event) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ }, [scrollPrev, scrollNext])
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return
+ }
+
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) {
+ return
+ }
+
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ };
+ }, [api, onSelect])
+
+ return (
+ (
+
+ {children}
+
+ )
+ );
+})
+Carousel.displayName = "Carousel"
+
+const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+ ()
+ );
+})
+CarouselContent.displayName = "CarouselContent"
+
+const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel()
+
+ return (
+ ()
+ );
+})
+CarouselItem.displayName = "CarouselItem"
+
+const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+ ()
+ );
+})
+CarouselPrevious.displayName = "CarouselPrevious"
+
+const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+ ()
+ );
+})
+CarouselNext.displayName = "CarouselNext"
+
+export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
diff --git a/frontend-web-free/src/components/ui/chart.jsx b/frontend-web-free/src/components/ui/chart.jsx
new file mode 100644
index 00000000..bcef1067
--- /dev/null
+++ b/frontend-web-free/src/components/ui/chart.jsx
@@ -0,0 +1,309 @@
+"use client";
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = {
+ light: "",
+ dark: ".dark"
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+ (
+
+
+
+ {children}
+
+
+ )
+ );
+})
+ChartContainer.displayName = "Chart"
+
+const ChartStyle = ({
+ id,
+ config
+}) => {
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+ (
+
+
+
+
+
+ {greeting} here's what matters today
+
+
+
+ {isCustomizing && hasChanges && (
+
+ Unsaved Changes
+
+ )}
+ {isCustomizing ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+ {(provided) => (
+
+ {visibleQuickActions.map((widgetId, index) => (
+
+ {(provided, snapshot) => (
+
+ {isCustomizing && (
+
+ )}
+
+ {renderWidget(widgetId)}
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+
+ {(provided) => (
+
+ {otherWidgetsFullWidth.map((widgetId, index) => (
+
+ {(provided, snapshot) => (
+
+ {isCustomizing && (
+
+ )}
+
+ {renderWidget(widgetId)}
+
+
+ )}
+
+ ))}
+
+ {gridPairVisible.length > 0 && (
+
+ {gridPairVisible.map((widgetId) => (
+
+ {isCustomizing && (
+
+ )}
+
+ {renderWidget(widgetId)}
+
+
+ ))}
+
+ )}
+
+ {provided.placeholder}
+
+ )}
+
+
+
+ {isCustomizing && availableToAdd.length > 0 && (
+
+
+
+
Add Widgets
+
{availableToAdd.length} available
+
+
+ {availableToAdd.map((widget) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web-free/src/pages/ClientInvoices.jsx b/frontend-web-free/src/pages/ClientInvoices.jsx
new file mode 100644
index 00000000..45cd215a
--- /dev/null
+++ b/frontend-web-free/src/pages/ClientInvoices.jsx
@@ -0,0 +1,126 @@
+import React from "react";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { FileText, Download, DollarSign, Clock, CheckCircle2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export default function ClientInvoices() {
+ const invoices = [
+ { id: "INV-001", event: "Tech Conference 2025", amount: 12400, status: "Paid", date: "2025-01-10", dueDate: "2025-01-20" },
+ { id: "INV-002", event: "Product Launch", amount: 8950, status: "Pending", date: "2025-01-15", dueDate: "2025-01-25" },
+ { id: "INV-003", event: "Annual Gala", amount: 15200, status: "Overdue", date: "2024-12-20", dueDate: "2025-01-05" },
+ ];
+
+ const stats = {
+ total: invoices.reduce((sum, inv) => sum + inv.amount, 0),
+ paid: invoices.filter(i => i.status === "Paid").length,
+ pending: invoices.filter(i => i.status === "Pending").length,
+ overdue: invoices.filter(i => i.status === "Overdue").length,
+ };
+
+ const getStatusColor = (status) => {
+ const colors = {
+ 'Paid': 'bg-green-100 text-green-700',
+ 'Pending': 'bg-yellow-100 text-yellow-700',
+ 'Overdue': 'bg-red-100 text-red-700',
+ };
+ return colors[status] || 'bg-slate-100 text-slate-700';
+ };
+
+ return (
+